Skip to content

SDK-392 Fix Pending/Fulfill never resolving when auth retries exhausted#1056

Open
sumeruchat wants to merge 2 commits intomasterfrom
SDK-392-fix-pending-fulfill-reapply
Open

SDK-392 Fix Pending/Fulfill never resolving when auth retries exhausted#1056
sumeruchat wants to merge 2 commits intomasterfrom
SDK-392-fix-pending-fulfill-reapply

Conversation

@sumeruchat
Copy link
Copy Markdown
Contributor

What

Re-applies the fix from #1023 (which was reverted in #1025).

Fixes a bug where RequestProcessorUtil.sendRequest() leaves its result Fulfill permanently unresolved when a 401 JWT error triggers auth token retry and all retries are eventually exhausted. This causes any upstream caller chained on the Pending to hang indefinitely — including the IterableAPI.initialize2 callback used by the React Native SDK.

Reported by: CarGurus (SDK-392) — Iterable.initialize() promise hangs on React Native New Architecture.

Root Cause

When sendRequest() receives a 401 JWT error, it schedules an auth token refresh via AuthManager.scheduleAuthTokenRefreshTimer with a callback that calls attemptSend() to retry the request. The result Fulfill is only resolved inside attemptSend() on success or non-JWT error — it is never resolved in the 401-JWT branch itself.

The retry chain eventually terminates when AuthManager.requestNewAuthToken() detects retryCount >= maxRetry and returns early at line 45 — but it does so without invoking the onSuccess callback. This means:

  1. invokePendingCallbacks is never called
  2. The attemptSend callback queued by RequestProcessorUtil is orphaned
  3. The result Fulfill is never resolved (neither .resolve() nor .reject())
  4. The entire Pending chain upstream — fetcher.fetch()inAppManager.start()implementation.start()initialize2 callback — hangs forever

Similarly, scheduleAuthTokenRefreshTimer silently returns when shouldSkipTokenRefresh is true (auth paused), dropping the callback without invoking it.

Call chain (verified):

JS: Iterable.initialize()
  → IterableAPI.initialize2(callback:)
    → implementation.start().onSuccess { callback(true) }.onError { callback(false) }
      → inAppManager.start() → scheduleSync() → synchronize() → fetcher.fetch()
        → RequestProcessorUtil.sendRequest()
          → 401 JWT → scheduleAuthTokenRefreshTimer(successCallback: { attemptSend() })
            → timer fires → requestNewAuthToken()
              → retryCount >= maxRetry → returns early (onSuccess never called)
                → attemptSend never called → result Fulfill never resolved
                  → initialize2 callback never fires → JS promise hangs

Changes

  • AuthManager.requestNewAuthToken: When shouldPauseRetry returns true (retries exhausted or paused), invoke onSuccess?(nil) before returning so the caller chain is notified instead of silently abandoned.
  • AuthManager.scheduleAuthTokenRefreshTimer: When shouldSkipTokenRefresh returns true, invoke successCallback?(nil) before returning instead of dropping the callback.
  • RequestProcessorUtil.sendRequest: Check the token in the scheduleAuthTokenRefreshTimer callback — if nil (retries exhausted), call reportFailure to reject the result Fulfill instead of calling attemptSend() again.

Regression Risk Analysis

Low risk

  • Normal auth refresh flow (token obtained successfully): Unaffected. requestNewAuthToken only invokes onSuccess(nil) when shouldPauseRetry is true — the happy path where a valid token is returned goes through onAuthTokenReceivedinvokePendingCallbacks(with: token) as before. RequestProcessorUtil only calls reportFailure when token is nil.
  • Non-JWT errors (network failures, bad API key, etc.): Unaffected. These already go through reportFailure in RequestProcessorUtil.

Medium risk — review carefully

  • Auth retry behavior change: Previously, when retries were exhausted, the request would silently hang. Now it fails explicitly. Callers that were accidentally relying on the request "disappearing" (no success, no failure) will now receive a failure callback. This is the correct behavior but could surface previously-hidden auth configuration issues.
  • shouldSkipTokenRefresh path: When pauseAuthRetry is true and a non-scheduled refresh is requested, we now invoke the callback with nil. If any caller was scheduling a refresh while auth was paused and expecting it to simply be ignored (no callback), they will now receive a nil callback. Review callers of scheduleAuthTokenRefreshTimer to confirm this is safe.
  • scheduleAuthTokenRefreshTimer called from onAuthTokenReceived(nil) (line 204): When auth delegate returns nil, onAuthTokenReceived schedules another refresh with the same onSuccess. With this fix, if that refresh is skipped (e.g. retries paused), onSuccess(nil) is called, which propagates up to invokePendingCallbacks(nil)RequestProcessorUtil reports failure. Previously this would have silently stalled. Verify this matches expected behavior.

Not affected

  • Android SDK (completely separate codebase, sync init)
  • apply() method in RequestProcessorUtil (does not use scheduleAuthTokenRefreshTimer)
  • Normal in-app fetch without auth (no 401 JWT, no retry path)

Testing

How to test:

  1. Configure SDK with authDelegate that always returns nil (simulating token retrieval failure)
  2. Set a saved user email/userId in UserDefaults so auth is triggered during init
  3. Call IterableAPI.initialize2(callback:) and verify the callback fires with false after retries are exhausted (instead of hanging forever)
  4. Verify normal auth flow still works: configure authDelegate that returns a valid token, confirm requests succeed and callback fires with true

Edge cases:

  • Auth delegate returns nil intermittently (some retries succeed, some fail)
  • pauseAuthRetries(true) called during active retry cycle
  • Multiple concurrent requests hitting 401 simultaneously

🤖 Generated with Claude Code

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 9, 2026

Codecov Report

❌ Patch coverage is 33.33333% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.65%. Comparing base (778d0a9) to head (edf4165).

Files with missing lines Patch % Lines
swift-sdk/Internal/AuthManager.swift 20.00% 8 Missing ⚠️
swift-sdk/Internal/RequestProcessorUtil.swift 50.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1056      +/-   ##
==========================================
- Coverage   70.72%   70.65%   -0.07%     
==========================================
  Files         112      112              
  Lines        9201     9215      +14     
==========================================
+ Hits         6507     6511       +4     
- Misses       2694     2704      +10     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

3 participants