Repository navigation
fix: [SDK-5535] retry failed operations without blocking the queue - #2790
Conversation
📊 Diff Coverage ReportDiff Coverage Report (Changed Lines Only)Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff). Changed Files Coverage
Overall (aggregate gate)108/111 touched executable lines covered (97.3% — requires ≥ 80%) Per-file detail (informational; gate is aggregate above):
|
There was a problem hiding this comment.
Multi-model review
Claude Opus 5.5, GPT 5.6 Sol, Grok 4.7 on the 351-line Kotlin change.
Per-op backoff for 5xx without Retry-After looks right, and same-record ordering is tested. Two gaps undermine the “force immediately / don’t stall unrelated ops” claims.
Act on
forceExecuteOperations does not force. It wakes with LoopWaiterMessage(false), so waitForNewOperationAndExecutionInterval still waits a full opRepoExecutionInterval (5s). The retry-timeout path already uses force=true. The “immediately wakes retry waiters” test passes only because MockHelper sets the interval to 1ms. Wake with true.
Consider
- Retry-After still stalls the loop (3/3).
HttpClient.delayNewRequestsUntilis process-wide and uncapped. After a 429, the next unrelated op sleeps on the single repo thread, so per-opretryAfterUntilisolation (and the 1h cap) do not hold for that path. Pre-existing, but it is the case this PR special-cases. - Network callback is noisy (Opus + Grok).
onAvailablefires at registration and on every matching network, each zeroing everybackoffUntil. Prefer a validated/default network and ignore the registration snapshot. - Wall-clock deadlines (Opus + Sol).
retryAfterUntilis set tonoweven when there is noRetry-After. A clock rollback can strand an op pastforceExecuteOperations. - In-flight force is dropped (Sol). A focus/network event during
executeonly clears queued items; a followingFAIL_RETRYinstalls a fresh backoff. The oldretryWaiterbuffered the force. - Login hold can retarget (Grok + Opus).
LoginUserOperation.applyToRecordIdisexistingOnesignalId ?: onesignalId; a merge while waiting changes which later ops are blocked.
Noted
Rebuild ops returned with FAIL_RETRY now run immediately. Retry-After on non-retry results is no longer applied at the repo layer. No e2e test that forceExecuteOperations still honors Retry-After through the loop.
Sent by Cursor Automation: PR Reviews
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Two things before this lands, since it should not change architecture:
- Removing
delayNewRequestsUntilmakes each caller handleRetry-After, but several don't.LoginUserFromSubscriptionOperationExecutor(92-95) andLoginUserOperationExecutor(163) dropretryAfterSeconds.ConfigModelStoreListenerparams retry andsendSavedOutcomesignore it. Either move all of them over, or keep the shared gate and scope it. OperationRetryTriggeradds the SDK's first network callback. Is it needed for this fix? If yes: skip the registration callback, force only when connectivity goes from offline to online, and inject the connectivity source.
Also:
- Store deadlines on a monotonic clock (
elapsedRealtime, as inDeviceGestureDetector). - Keep a force that lands during
executeso it isn't overwritten by a new backoff. - Move
MAX_RETRY_DELAY_MStoConfigModel. - Add back one debug line per retry with the backoff and
Retry-Aftervalues.
b35265b to
1afaf52
Compare
|
@abdulraqeeb33 thanks, addressed in b94a1cd and 317e00a.
Also:
I also matched iOS on backoff: 5s doubling to 300s with jitter, Retry-After kept as a minimum a force can't end. @cursor the in-flight force, wall clock and noisy callback points are covered above. Login retargeting is fine since the hold follows whatever record the login now applies to. Rebuild ops running right away and Retry-After on non-retry results match the old behavior. |
A FAIL_RETRY op now waits on its own instead of stalling the whole OperationRepo loop. Ops on the same record still wait behind it, so a create runs before its updates. Retry-After is honored and capped at 1h. Foreground and network return end the backoff early. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry-After Operations and the IAM fetch wait out Retry-After themselves, matching iOS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Keep HttpClient's shared Retry-After gate, capped by maxRetryDelay, but fail fast with a 429 instead of suspending, so no caller stalls on it. - Login executors pass Retry-After through. - One retry deadline per operation, on elapsedRealtime. A force ends it; an active Retry-After still holds through the gate. - A force during execute is kept, and skips the batching wait when it ends a retry wait. - Drop the network callback; foreground still ends the wait. - maxRetryDelay moves to ConfigModel; one debug line per retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Backoff is exponential from 5s to 300s with 0.5-1x jitter, like iOS. - Retry-After is kept per operation as a minimum a force cannot end. - Foreground forces a retry; coming back online forces one only while in the foreground. Connectivity is watched from the first focus. - Retry-After parsing accepts fractions and falls back on negatives. - maxRetryDelay is now maxRetryAfter; opRepoMaxFailRetryBackoff caps backoff. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Login Retry-After, the monotonic clock, maxRetryDelay, the debug line and dropping the network callback all look good.
One blocker: the fail-fast 429 in HttpClient changes behavior for every caller outside the operation queue. On main they waited, then sent. Now:
- IAM display retries three times with no delay, then dismisses the message for the session (
attemptToShowInAppMessage). - Receive receipts are swallowed and the worker returns success.
- IAM click, impression and page impression are dropped. The IAM fetch falls back without the RYW token.
- A foreground during Retry-After increments
retrieson a request that was never sent, which pushes the real send past the window.
Suggest keeping main's suspending gate (now on elapsedRealtime, capped by maxRetryDelay) and using retryAt only for ordinary backoff. Also, CI hasn't run on this head.
e80c7b2 to
317e00a
Compare
Failing fast changed behavior for callers outside the operation queue (IAM display, receipts, impressions), which drop a 429 instead of waiting. Restore main's suspending gate, now on elapsedRealtime and capped by maxRetryAfter. OperationRepo still waits max(backoff, Retry-After) per op, but a force ends the whole wait since the gate holds the request until Retry-After passes. Requests are no longer refused, so retries only count real attempts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
My review above was written against 831dd37, before e80c7b2 landed. Updates:
|
|
@abdulraqeeb33 good catch, I missed that the other callers drop a 429. Went back to main's waiting gate in 9f61748, just on elapsedRealtime and capped at an hour, so IAM and receipts behave like before. Two things I did differently:
|
|
@abdulraqeeb33 thanks for the update. 1 and 2 should both be covered by 9f61748, since the gate waits again instead of returning a 429. Nothing gets a fake failure now, so retries only go up on real sends, no matter which request set the gate On 3, yeah it's the first one, but the ticket asks for it and iOS has the same trigger. It's scoped to coming back online in the foreground and fails quietly if registration throws. On 4, the backoff matches iOS #1775 exactly (5s doubling to 300s with jitter), so splitting it out would leave the two SDKs retrying differently. It's also all in ConfigModel if we want to tune it |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Thanks, the suspending gate is back and IAM, receipts and outcomes behave like main again. ConnectivityMonitor is fine given the ticket and iOS parity. Remaining:
- CI:
FAIL_NORETRY on login keeps grouped create-subscriptionraces becausestartingOp.wakeWaitersruns before the followers are re-queued (OperationRepo.kt:492-495). Same order on main; moving the wake after thesynchronizedblock should fix it. - Upgraded installs likely keep a persisted
opRepoDefaultFailRetryBackoff = 15000, since the getter writes its default into the model on first read and the config store saves it. Rename the key or migrate it so the 5s base applies. - Jitter is drawn per op, so a batched request splits into separate requests on retry. One jitter sample per
executeOperationskeeps the group together. - Nits: qualify "unrelated ops keep running" (not while Retry-After holds the gate), seed
ConnectivityMonitorfrom the callback instead ofactiveNetworkInfo, considerNET_CAPABILITY_VALIDATED, and trim theOperationRetryTriggerKDoc to 4 lines.
…n upgrade Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@abdulraqeeb33 thanks, good catches.
|
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Thanks, all four look good. The key rename and single batch wait are what I was after, and the reasoning on activeNetworkInfo and VALIDATED makes sense. The backoff policy matches iOS so I'm fine shipping it together. Approving, please hold the merge until CI is green.


Description
One Line Summary
A retryable failure now backs off on its own in
OperationRepoinstead of stalling the whole queue.Details
Motivation
On
FAIL_RETRY,executeOperationsre-queued the op and then calleddelayBeforeNextExecution, which suspended the whole loop. One failing op (for example a 5xx on a tag update) held up every other op, including ones for unrelated records, forretries * 15sorRetry-After. This is the Android counterpart to iOS #1775 (SDK-5481), and lets sessions go throughOperationRepoas ordinary operations later. No sessions code here.Scope
Matches iOS #1775 unless noted.
FAIL_RETRYeach queue item waitsopRepoDefaultFailRetryBackoff(5s), doubling per failure up toopRepoMaxFailRetryBackoff(300s), times 0.5-1 jitter. The op also waits out any server Retry-After, capped bymaxRetryAfter(1h); 60s if a 429 has none or it can't be parsed. The globaldelayBeforeNextExecution/retryWaiterstall is removed.getNextOpsskips items that are still waiting. It also skips any op on the sameapplyToRecordIdas an earlier waiting op, so a create still runs before its updates and two updates to the same user keep their order.getGroupableOperationsfollows the same rule, so a waiting op is never pulled into another op's request.forceExecuteOperationsends an op's wait and skips the 5s batching wait only when it actually ended one. Retry-After still holds, sinceHttpClientkeeps the request until it passes. Android only: a force that arrives duringexecuteis kept, so the failed op skips its next backoff.OperationRetryTriggerforces on foreground, and when the device goes from offline to online while in the foreground. Connectivity is watched from the first focus, through an injectableIConnectivityMonitor; networks already up at registration and Wi-Fi/mobile switches don't count.HttpClientkeeps main's app-wide Retry-After gate (iOS has none), which suspends requests until the window ends, so IAM, receipts and outcomes behave as before. It is now onelapsedRealtimeand capped bymaxRetryAfter. During a Retry-After window the queue waits too, as on main; only plain backoff no longer blocks it. The login executors now pass Retry-After through.ITime.elapsedRealtimeMillis, so device clock changes don't affect them. Each retry logs its backoff and Retry-After at debug level.FAIL_PAUSE_OPREPO, non-retryable handling, Identity Verification (FAIL_UNAUTHORIZED,shouldSuppressAnonymousOp,removeOperationsWithoutExternalId), post-create delay, bucketing. Backoff state is in memory, so after a relaunch queued ops are sent right away as before.forceExecuteOperations(consent, JWT update, foreground, network) no longer cuts Retry-After short.OperationRepo lines changed
OperationRepo.kt: +89 / -40, for comparison with the +300 session-only lines in the #2777 stack. PlusOperationRetryTrigger,ConnectivityMonitor, and small changes inHttpClient,ITime,ConfigModel,CoreModuleand the two login executors.Testing
Unit testing
OperationRepoTests:forceExecuteOperationsends backoff, and skips the batching wait when it ended onedelayBeforeNextExecutionis goneHttpClientTests: the next request waits out a Retry-After on the monotonic clock; the wait is capped bymaxRetryAfter; fractional Retry-After rounds up, negative falls back to 60s.ConnectivityMonitorTests: networks up at registration and network switches don't count as coming online; offline to online does, every time; a registration failure doesn't throw.OperationRetryTriggerTests: foreground forces a retry, focus on subscribe does not; connectivity is watched from the first focus, once; coming online forces a retry only in the foreground../gradlew spotlessCheck detekt testDebugUnitTest apiCheck assembleReleasepasses.core.apiis re-dumped to fix drift already onmain(ErrorCode,LoginData, ...), with the same content as #2787. This PR adds no public API.Manual testing
Android 16 emulator, using the harness in #2791, on an earlier revision of this PR (linear 15s backoff):
The exponential backoff and the foreground-only network wake are covered by unit tests; I haven't re-run the harness against them.
Affected code checklist
Checklist
Overview
Testing
Final pass
🤖 Generated with Claude Code