Skip to content

feat: [SDK-5065] add bounded retry/backoff to remote log export - #21

Open
abdulraqeeb33 wants to merge 1 commit into
mainfrom
ar/sdk-5065-export-retry
Open

feat: [SDK-5065] add bounded retry/backoff to remote log export#21
abdulraqeeb33 wants to merge 1 commit into
mainfrom
ar/sdk-5065-export-retry

Conversation

@abdulraqeeb33

Copy link
Copy Markdown
Contributor

What OTel did by default, and what we lost

The remote-log exporter used to be OtlpHttpLogRecordExporter, which came with retry
turned 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/RetryUtil hardcodes the retryable HTTP statuses
    429, 502, 503, 504.
  • opentelemetry-exporter-sender-okhttp ships a RetryInterceptor that applies
    exponential backoff with jitter across roughly 5 attempts.

The hand-rolled replacement does one POST and throws the batch away:
LogBatchProcessor caught the export failure and deliberately dropped
("best-effort drop (no retry)"), and LogTelemetryRemoteImpl.exportBatch discarded
post()'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 / maxExportBatchSize are both 100), and a 429 was ignored rather than
backed off, so a rate-limited client kept posting at the same cadence.

Retry policy

ExportRetrier (kmp/src/commonMain/.../internal/LogExportRetry.kt) sits in the shared
batch/export path, so Android and iOS both inherit it. Defaults mirror OTel's, plus an
elapsed ceiling OTel did not have:

Bound Value
Max attempts 5 (1 initial + 4 retries)
Initial backoff 1s
Multiplier 1.6x
Max backoff 5s
Jitter ±20%
Max total elapsed 15s

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. classifyStatus treats 429/502/503/504 as retryable, plus
transport-level failures — a thrown sender, or statusCode == -1, the sentinel both
platform senders already return when there is no HTTP response (DNS, timeout, reset).
Everything else is permanent: 4xx, and the -2 "remote logging disabled" sentinel the
iOS sender returns, which would otherwise burn four pointless retries with the feature
switched off.

No contract change. ILogHttpSender / LogHttpResponse are untouched — retryability
is derived entirely from the statusCode both senders already report, so there is no
follow-up needed in the Android or iOS repos.

Retry-After is not honored — deliberately. Neither platform sender surfaces response
headers, so plumbing it through means adding a field to LogHttpResponse. Kotlin default
arguments 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 processor
already runs under exportMutex. enqueue only takes the separate buffer mutex, so new
records keep flowing while a retry is in flight; they fill the existing bounded queue and
are dropped past maxQueueSize as before. Only the batch under retry is held, so memory
is capped at two batches. The consumer coroutine can stall for at most the 15s ceiling.
Waiting uses delay, never a blocking sleep, and CancellationException propagates.

Happy path is unchanged: one POST, no added latency, no delay ever entered.

Out of scope

The crash-upload path is untouched. LogCrashUploader.sendReports already retries
correctly — 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 the
original single-shot post.

Tests

kmp/src/commonTest/kotlin/com/onesignal/logger/LogExportRetryTest.kt (8 tests, using
the existing FakeHttpSender, which gained an exceptions queue so a send can throw):

  • happyPathPostsExactlyOnce
  • retryableStatusIsRetriedUntilSuccess (503 → 429 → 200, same body each attempt)
  • transportFailureIsRetried (thrown sender, then -1, then 200)
  • permanentStatusIsNotRetried (400)
  • attemptCapIsHonoredWhenBackendKeepsFailing
  • elapsedCapStopsRetriesBeforeAttemptCap (injected clock)
  • cancellationDuringBackoffDelayPropagates
  • classifiesStatusCodes

Verified non-vacuous: with exportBatch reverted to the single-POST behavior,
retryableStatusIsRetriedUntilSuccess, transportFailureIsRetried and
attemptCapIsHonoredWhenBackendKeepsFailing all fail.

./gradlew :kmp:allTests spotlessCheck passes. Confirmed in
kmp/build/test-results/*/TEST-com.onesignal.logger.LogExportRetryTest.xml that all 8
ran with 0 failures on both iosSimulatorArm64Test and testDebugUnitTest.

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>
@abdulraqeeb33
abdulraqeeb33 requested a review from a team as a code owner August 25, 2026 20:22
Comment on lines +108 to +109
} catch (e: CancellationException) {
throw e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you need this first catch?

@fadi-george

Copy link
Copy Markdown
Contributor

Retry path looks right for fast 429/503. A few things the claims and tests don't currently pin down:

  1. Elapsed cap vs in-flight HTTP. maxElapsedMillis is checked after attempt(), then delay is clamped and another send() always starts. In-flight work is not cancelled, so stall can be 15s plus one sender timeout, not 15s. A 10s connect timeout (the -1 case) only gets about 2 attempts, not 5. Either charge the ceiling against backoff only, raise it above attempts × timeout, or drop the "consumer stalls at most 15s" wording.

  2. Shutdown is 5s, retry delays alone are ~9s. Retry holds exportMutex for the whole cycle, so disable/teardown that overlaps a 503 waits 5s, cancels, and drops the drained batch. Align the budgets or cancel the in-flight export instead of waiting on that mutex.

  3. Tests. elapsedCapStopsRetriesBeforeAttemptCap advances the clock on every nowMillis() read, including start, and only asserts attempts < 10. Nothing asserts backoff duration. Also missing: exportEncoded + 503 still posts once.

Happy to ignore the rest (500 not retried, no Retry-After) as intentional.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants