Skip to content

fix(auth): retry the OAuth token exchange on transient failures - #2931

Merged
bruno-imx merged 3 commits into
mainfrom
gam-509-retry-token-exchange
Jul 28, 2026
Merged

fix(auth): retry the OAuth token exchange on transient failures#2931
bruno-imx merged 3 commits into
mainfrom
gam-509-retry-token-exchange

Conversation

@bruno-imx

@bruno-imx bruno-imx commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
  SELECT
    CASE
      WHEN reason IN ('Failed to fetch','Load failed')                      THEN '1_network_error (RETRIED)'
      WHEN reason LIKE '%Bad Gateway%' OR reason LIKE '%Gateway Timeout%'   THEN '2_cloudfront_5xx (RETRIED)'
      WHEN LOWER(reason) LIKE '%failed to obtain access token%'             THEN '3_token_exchange_failed (RETRIED if 5xx)'
      WHEN screen = 'ABLogin' AND reason IS NULL                            THEN '4_ab_login_callback (RETRIED, redirect)'
      WHEN reason = 'Forbidden' OR reason LIKE '%403 ERROR%'
        OR reason LIKE '%Request blocked%'                                 THEN '5_forbidden_4xx (NOT retried)'
      WHEN LOWER(reason) LIKE '%rate limit%'                                THEN '6_rate_limit_429 (NOT retried, 4xx)'
      ELSE NULL
    END AS category,
    COUNT(*) AS failures
  FROM (
    SELECT JSON_VALUE(properties,'$.screen') AS screen,
           JSON_VALUE(properties,'$.reason') AS reason
    FROM `prod-im-data.app_immutable_play.event`
    WHERE event_name = 'Login_Failed' AND event_ts >= TIMESTAMP('2026-07-01')
  )
  GROUP BY category HAVING category IS NOT NULL ORDER BY category

  ┌───────────────────────────────────────────────┬──────────────┬───────────────────┐
  │                   Category                    │ 4-week count │ Retried by #2931? │
  ├───────────────────────────────────────────────┼──────────────┼───────────────────┤
  │ network_error (Failed to fetch / Load failed) │          220 │        ✅         │
  ├───────────────────────────────────────────────┼──────────────┼───────────────────┤
  │ cloudfront_5xx (502/504)                      │           23 │        ✅         │
  ├───────────────────────────────────────────────┼──────────────┼───────────────────┤
  │ token_exchange_failed                         │            4 │    ✅ (if 5xx)    │
  ├───────────────────────────────────────────────┼──────────────┼───────────────────┤
  │ ab_login_callback (redirect, reason null)     │           40 │  ✅                 │


Adds a bounded retry to exchangeCodeForTokens in @imtbl/auth — the single helper behind both loginWithPopup and the redirect-flow handleLoginCallback — so a transient failure on the code→token exchange no longer drops an otherwise-valid login.

Problem

exchangeCodeForTokens does a single POST /oauth/token and throws on any non-2xx, with no retry. A transient 5xx (e.g. a CloudFront 502/504), a network blip, or a rate-limit therefore fails the whole login even though the authorization code is still valid and an immediate re-attempt would have succeeded. Consumers (e.g. Immutable Play) are left on a dead "Signing in…" state.

Change

  • Retry on network error and HTTP ≥ 500 only (transient). The auth code is not consumed on a 5xx, so re-POSTing it is safe.
  • No retry on 4xx (bad / expired / already-consumed code) — permanent, throws immediately.
  • 2 retries, 1s → 2s backoff. No public API change — callers get the same resolved TokenResponse or a thrown error, just with transient blips smoothed over.

Because both flows share this helper, the popup and redirect paths are both covered by the one change.

Tests

packages/auth/src/login/standalone.test.ts (exercised through handleLoginCallback):

  • 5xx → retry → success
  • network error → retry → success
  • 4xx → no retry, throws
  • retries exhausted → throws after 3 attempts

Local: pnpm --filter @imtbl/auth typecheck ✅ · test ✅ (4/4) · oxlint

Context

  • Linear: GAM-509
  • play-side companion (redirect-callback retry, no-UX-change): immutable/play#5617
  • Origin: a "Login timed out" / stuck "Signing in…" investigation — transient CloudFront 502/504 + Forbidden were captured as Login_Failed reasons during the token exchange.

🤖 Generated with Claude Code

@bruno-imx
bruno-imx requested a review from a team as a code owner July 28, 2026 01:41
@nx-cloud

nx-cloud Bot commented Jul 28, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit fc3c341

Command Status Duration Result
nx affected -t build,test ✅ Succeeded 1m 31s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-07-28 03:55:33 UTC

exchangeCodeForTokens (shared by loginWithPopup and the redirect
handleLoginCallback) retries on network error / HTTP >= 500, not on 4xx.
2 retries, 1s->2s backoff. No public API change.

Refs GAM-509.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bruno-imx bruno-imx changed the title GAM-509: retry the OAuth token exchange on transient failures fix(auth): retry the OAuth token exchange on transient failures Jul 28, 2026
@bruno-imx
bruno-imx force-pushed the gam-509-retry-token-exchange branch from e1c961f to e9bcdf4 Compare July 28, 2026 01:53
…e backoff

Per-attempt AbortController timeout so a hung request becomes a retryable
failure instead of an indefinite wait; full jitter on the backoff so mass
5xx retries don't synchronise into a thundering herd. Body reuse across
attempts documented as deliberate/safe.

Refs GAM-509.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread packages/auth/src/login/standalone.ts Outdated
const errorMessage = await parseTokenErrorMessage(response);

// Only 5xx is transient; a 4xx (bad/expired/consumed code) is permanent.
if (response.status >= 500 && retriesLeft > 0) {

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.

Suggestion: Should we consider retrying 429 here whilst we're at it? Similar to

if (response.status === 429) {

Comment thread packages/auth/src/login/standalone.ts
Comment thread packages/auth/src/login/standalone.ts Outdated
fetch resolves on headers, so clearing the timeout before reading the body
left response.json()/text() unbounded — a server that stalls the body hung
forever. Body reads now happen inside the abort window, and each attempt's
outcome is classified as data so a permanent 4xx isn't retried.

Adds standaloneTokenExchangeFailed / standaloneTokenExchangeRecovered
telemetry so retry frequency and recovery rate are observable.

Refs GAM-509.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bruno-imx
bruno-imx added this pull request to the merge queue Jul 28, 2026
Merged via the queue into main with commit e8f6eba Jul 28, 2026
6 checks passed
@bruno-imx
bruno-imx deleted the gam-509-retry-token-exchange branch July 28, 2026 05:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants