feat: [SDK-5065] add bounded retry/backoff to remote log export - #21
feat: [SDK-5065] add bounded retry/backoff to remote log export#21abdulraqeeb33 wants to merge 1 commit into
Conversation
Dropping OpenTelemetry silently removed retry behavior that OtlpHttpLogRecordExporter enabled by default: RetryUtil treated 429/502/503/504 as retryable and the okhttp sender's RetryInterceptor applied exponential backoff with jitter across ~5 attempts. The hand-rolled replacement did one POST and dropped the batch, so a transient 503 permanently lost up to 100 records and a 429 was ignored instead of backed off. Adds ExportRetrier in the shared batch/export path, so Android and iOS both inherit it: 5 attempts, 1s initial backoff x1.6 up to 5s, 20% jitter, 15s total elapsed ceiling. 429/502/503/504, transport failures (statusCode -1) and thrown senders are retried; 4xx and the -2 "logging disabled" sentinel are not. Waiting uses delay so cancellation propagates. The crash-upload path is untouched — it already retries across launches. No change to the ILogHttpSender/LogHttpResponse contract; retryability is derived from the statusCode both platform senders already report. Co-authored-by: Cursor <cursoragent@cursor.com>
| } catch (e: CancellationException) { | ||
| throw e |
There was a problem hiding this comment.
do you need this first catch?
|
Retry path looks right for fast 429/503. A few things the claims and tests don't currently pin down:
Happy to ignore the rest (500 not retried, no |
What OTel did by default, and what we lost
The remote-log exporter used to be
OtlpHttpLogRecordExporter, which came with retryturned on without anyone writing a config line for it — which is exactly why nobody
noticed when it went away. In the pinned
1.55.0:io/opentelemetry/exporter/internal/RetryUtilhardcodes the retryable HTTP statuses429, 502, 503, 504.
opentelemetry-exporter-sender-okhttpships aRetryInterceptorthat appliesexponential backoff with jitter across roughly 5 attempts.
The hand-rolled replacement does one POST and throws the batch away:
LogBatchProcessorcaught the export failure and deliberately dropped("best-effort drop (no retry)"), and
LogTelemetryRemoteImpl.exportBatchdiscardedpost()'s return value, so a 503 was indistinguishable from a 200.Consequence: one transient backend blip permanently lost up to 100 remote log records
(
maxQueueSize/maxExportBatchSizeare both 100), and a 429 was ignored rather thanbacked off, so a rate-limited client kept posting at the same cadence.
Retry policy
ExportRetrier(kmp/src/commonMain/.../internal/LogExportRetry.kt) sits in the sharedbatch/export path, so Android and iOS both inherit it. Defaults mirror OTel's, plus an
elapsed ceiling OTel did not have:
Both bounds are enforced — whichever trips first ends the retry, and the final backoff is
clamped to the remaining elapsed budget so we never sleep past the ceiling.
Retryable vs permanent.
classifyStatustreats 429/502/503/504 as retryable, plustransport-level failures — a thrown sender, or
statusCode == -1, the sentinel bothplatform senders already return when there is no HTTP response (DNS, timeout, reset).
Everything else is permanent: 4xx, and the
-2"remote logging disabled" sentinel theiOS sender returns, which would otherwise burn four pointless retries with the feature
switched off.
No contract change.
ILogHttpSender/LogHttpResponseare untouched — retryabilityis derived entirely from the
statusCodeboth senders already report, so there is nofollow-up needed in the Android or iOS repos.
Retry-Afteris not honored — deliberately. Neither platform sender surfaces responseheaders, so plumbing it through means adding a field to
LogHttpResponse. Kotlin defaultarguments are not exported to Objective-C, so a new constructor parameter would break the
Swift build until the iOS repo was updated in lockstep. That is not cheap, so it is left
out rather than half-done. Jittered exponential backoff still de-escalates against a 429.
Pipeline and memory. Retrying happens inside
onExport, which the batch processoralready runs under
exportMutex.enqueueonly takes the separate buffer mutex, so newrecords keep flowing while a retry is in flight; they fill the existing bounded queue and
are dropped past
maxQueueSizeas before. Only the batch under retry is held, so memoryis capped at two batches. The consumer coroutine can stall for at most the 15s ceiling.
Waiting uses
delay, never a blocking sleep, andCancellationExceptionpropagates.Happy path is unchanged: one POST, no added latency, no delay ever entered.
Out of scope
The crash-upload path is untouched.
LogCrashUploader.sendReportsalready retriescorrectly — it deletes a record only after success and breaks on first failure, so
failures retry on the next launch.
exportEncoded, which it calls, still uses theoriginal single-shot
post.Tests
kmp/src/commonTest/kotlin/com/onesignal/logger/LogExportRetryTest.kt(8 tests, usingthe existing
FakeHttpSender, which gained anexceptionsqueue so a send can throw):happyPathPostsExactlyOnceretryableStatusIsRetriedUntilSuccess(503 → 429 → 200, same body each attempt)transportFailureIsRetried(thrown sender, then-1, then 200)permanentStatusIsNotRetried(400)attemptCapIsHonoredWhenBackendKeepsFailingelapsedCapStopsRetriesBeforeAttemptCap(injected clock)cancellationDuringBackoffDelayPropagatesclassifiesStatusCodesVerified non-vacuous: with
exportBatchreverted to the single-POST behavior,retryableStatusIsRetriedUntilSuccess,transportFailureIsRetriedandattemptCapIsHonoredWhenBackendKeepsFailingall fail../gradlew :kmp:allTests spotlessCheckpasses. Confirmed inkmp/build/test-results/*/TEST-com.onesignal.logger.LogExportRetryTest.xmlthat all 8ran with 0 failures on both
iosSimulatorArm64TestandtestDebugUnitTest.