Repository navigation
fix: [SDK-5481] retry failed operations within the same run - #1775
Conversation
Executors kept a request after a retryable failure but only resent it on the next launch. They now back off exponentially with jitter (5s to 300s, honoring Retry-After) and resend in the same run. Foreground and network return end the backoff early. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Multi-model review (Claude Opus 5.5, GPT 5.6 Sol, Grok 4.7) of SDK-5481 same-run executor retry.
Act on
- (3/3)
OSRequestRetryBackoff.retryAfterSecondsaccepts non-finite and uncapped values (inf,1e400, multi-day seconds). The User executor passes that delay toasyncAfter, which can trap converting to nanoseconds; a huge finite wait then holds the ordered user queue for the rest of the run, andretryNowwill not overrideRetry-After. Accept only finite, non-negative delay-seconds and clamp (e.g.maxDelaySecondsor 1h). - (Grok; others missed) A non-retryable Create User (400/409/…) stays at the head with
sentToClient = falseand no backoff entry.retryNow()always callsexecutePendingRequests(), so everydidBecomeActiveand every foreground network return resends it. The comment still says this waits for a new session.
Consider
- (Opus) Tracker maps
willResignActive→onUnfocusanddidBecomeActive→retryNow, so Control Center / notification shade / Face ID blips cancel exponential backoff (exceptRetry-After) and flush the operation repo even when nothing is waiting. - (2/3 Opus+Grok) Repo-polled executors skip only the waiting request. A later snapshot can succeed first; the older
OSRequestUpdateProperties/ alias / Create Subscription body is then sent after the backoff. Same class of race as a relaunch, now inside minutes. Update Subscription is safer because it refreshes from the live model. - (Opus) A retryable Fetch User for a user who is no longer current stays at the head and blocks a later login until it succeeds or the process dies.
- (Sol)
systemUptimefreezes in sleep, so aRetry-Afterstarted before lock can still block after hours of wall time.
Noted
OneSignalClientstill burns 5/15/45/135s on 0/5xx before this layer seesonFailure, so the new trigger cannot speed the first attempt (documented).- HTTP-date
Retry-Afteris intentionally unsupported and becomes 60s. - Tests cover Identity Add Aliases + one Fetch User path; Create/Identify,
onFocus→retryNow, and path-monitor transitions are untested.
Dismissed
- NSMapTable weak-key zeroing during
retryNowenumeration as a likely crash: requests stay strongly held by the executor queues until removed.
Sent by Cursor Automation: PR Reviews
Keeps OneSignalUserManagerImpl.swift under SwiftLint's 1000 line limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retry-After must be a finite, non-negative delay and is capped at an hour. The foreground and network triggers only wake the executors when a backoff actually ended, so held work such as a paused Create User is not resent on every foreground. Backoff now uses a clock that counts sleep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks. On the Consider items:
|
Fixed sleeps were too short on CI, and a late response leaked into the next test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Requesting changes.
This adds a second retry path next to OneSignalClient (5, 15, 45, 135s for status 0 and 5xx) and the existing 5s operation-repo poll. Clear sentToClient on a retryable failure and re-enter the user executor from that poll. Keep the delay in willReattemptRequest.
- The user executor timer is mach_absolute_time. retryAt is CLOCK_MONOTONIC. After sleep past retryAt, retryNow does not flush, and the ordered queue waits out the leftover awake time.
- An Update Subscription left with sentToClient false is removed by the last-wins coalescer, and the replacement is sent with no wait.
- A non-retryable Create User has sentToClient false and no backoff entry, so a foreground that shortens some other request resends it in the same run.
…aits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Requesting changes.
The timer, the sleep stall, and the Update Subscription wait drop are fixed. The user executor now retries from the poll, and keeping the backoff gate is right: 408 and 429 never enter OneSignalClient's ladder, so a bare 5s poll would ignore Retry-After.
- Logout leaves a rejected Create User at the head. The next user's Create User sits behind it and nothing sends until a later session, which sends the logged-out user's request first.
- A session that starts while that Create User is still in flight drains an empty list. The 4xx then parks the request and pauses the repo again.
…ease one rejected mid-session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Requesting changes.
A rejected Create User for someone who is no longer current is dropped on the next flush, and a 4xx that lands after a new session started is sent again immediately.
Logout or login while that Create User is still in flight is not covered. The session count has not changed, so the 4xx is appended and the block does not re-enter the queue. The new user's Create User stays behind the old one, and the repo stays paused, until some later wake.
…te User Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nan-li
left a comment
There was a problem hiding this comment.
Thanks, I think it is reasonable, so approving. I noticed some of the review rounds got stuck on failed Create User which is inherently a problem that can’t really tackled in this PR, and it was just peeling layers of the onion and exposing the next event that would happen once one thing is addressed, which is not a blocker for me.
|
Thanks @nan-li , I filed SDK-5541 |


Description
One Line Summary
Executors retry a retryable failure in the same run, with backoff, instead of waiting for the next launch.
Details
Motivation
After a retryable failure (0, 5xx, 429 and so on), most executors kept the request with
sentToClientstill set, so it was only resent after a relaunch. The User executor was worse: its queue is ordered, so a stuck Create User or Fetch User blocked every user request behind it for the rest of the run. Update Subscription went the other way and retried on every 5s poll with no backoff.This is the shared fix discussed on the sessions PR (#1768), so sessions can be an ordinary executor instead of carrying their own retry code. The same root cause is behind SDK-5468 (Live Activities), though that executor is separate and not changed here.
Scope
OSRequestRetryBackoff(OSCore): per-request backoff, 5s doubling to 300s with jitter.Retry-Afteris the minimum wait, and a 429 without one waits 60s. State is in memory and weakly keyed, so after a relaunch cached requests are sent right away as before.OSOperationRetryTrigger(OSCore) ends the backoff early on foreground, and when the network returns while in the foreground (NWPathMonitor).Retry-Afteris still honored.OneSignalTrackerdrives it throughOneSignalUserManagerImpl.onFocus/onUnfocus.OneSignalClient's own short reattempts, which still run first.Testing
Unit testing
OSRequestRetryBackoffTests: delay growth and cap, jitter,Retry-Afterand the 429 default,retryNowending the backoff but notRetry-After.OSOperationRetryTriggerTests: foreground, network return only while foregrounded, monitor started once.ExecutorRetryBackoffTests: a failed request is resent once the backoff passes and removed on success; non-retryable failures are not retried;retryNowandRetry-After; the User executor retries a failed Fetch User from the poll once the backoff passes; a rejected Create User is only resent on a new session, is dropped once its user logs out, and is resent right away if a session started while it was in flight.UserConcurrencyTests.testSubscriptionExecutorConcurrency, which also fails on main.Manual testing
Demo app on an iOS 26.5 simulator, using the retry harness from #1776: mitmproxy fails only SDK requests, Maestro flows drive the demo, and
reportchecks each retry gap against the expected window. Its README has setup and the scenarios.Identity Verification, on an app that requires it (no anonymous Create User is sent):
updateUserJwtsent it (201).Not run: network return via NWPathMonitor (drops the Mac's Wi-Fi).
Call outs:
OneSignalClientresends on its own 5/15/45/135s schedule before the executor sees the failure, and foreground and network return do not shorten it. In the offline run, requests went out on their client resend times, not on foreground. So after airplane mode ends, a request can still wait up to 135s. Pre-existing, but it limits how much the new triggers help for offline.Affected code checklist
Checklist
Overview
Testing
Final pass
🤖 Generated with Claude Code