Skip to content

fix(audio): silently retry on 401 from /api/calls/:id/audio#30

Merged
revtex merged 1 commit intodevfrom
fix/audio-401-recovery
Apr 25, 2026
Merged

fix(audio): silently retry on 401 from /api/calls/:id/audio#30
revtex merged 1 commit intodevfrom
fix/audio-401-recovery

Conversation

@revtex
Copy link
Copy Markdown
Owner

@revtex revtex commented Apr 25, 2026

Symptom

After logging in on a phone with the same account, desktop playback starts failing with:

GET https://…/api/calls/127556/audio 401 (Unauthorized)
[audioPlayer] failed to play call 127556

The WS keeps delivering CALs (so the live feed UI looks fine), but every <audio src=…> fetch returns 401.

Root cause

The backend caps each user at 5 concurrent access JWTs (auth.MaxRefreshFamilies = 5, TokenTracker.MaxTokens = 5). Every login and every silent refresh issues a new JWT and bumps the oldest off the active list. Desktop + phone with 15-minute access TTL and 1-minute-pre-expiry refresh blows through 5 slots in roughly an hour, so the desktop's JWT \u2014 and therefore its os_session cookie \u2014 ends up server-side-revoked.

The WS connection survives because it authenticates once at connect time and isn't re-checked. But every <audio src=…> request re-authenticates from scratch. Unlike RTK Query traffic, media-element fetches don't go through baseQueryWithRefresh, so a 401 there isn't auto-recovered.

Fix

Add an auth-recovery hook on the audio player. When the element fires error for the current item, the player invokes the recovery callback once before skipping. main.tsx wires the callback to:

  1. POST /api/auth/refresh with credentials: include. Server installs a fresh os_session cookie and returns a fresh access JWT.
  2. store.dispatch(setCredentials(\u2026)) so the in-memory bearer is in sync (WS reconnects pick it up).
  3. audio.load() to re-fetch the same URL with the new cookie, then play().

If the refresh fails, or playback errors again on the retry, the player gives up and advances the queue. The per-item recoveryTried flag prevents loops.

Verification

  • pnpm exec tsc --noEmit clean.
  • 188 / 188 tests pass.
  • Manual smoke: log in on a second device, watch desktop console \u2014 first 401 should now be followed by a successful 200 on the same URL with no user-visible interruption.

Self-review

  • No XSS surface added.
  • The retry uses fetch with credentials: include against the same origin; no token leaves Redux memory; the cookie remains httpOnly.
  • Recovery is bounded (one attempt per call). No retry storms even if the server is genuinely down.
  • Anonymous publicAccess=true deployments continue to work because the recovery callback's failure is silent and doesn't gate playback startup \u2014 unauthenticated 401s still skip the same as before.

Note for follow-up

Consider raising auth.MaxRefreshFamilies / TokenTracker.MaxTokens from 5 to something more accommodating (10\u201320). With access TTL=15min and refresh-1-min-before, two devices generate ~10 tokens/hour, which is borderline. Out of scope for this PR.

When a sibling device (phone, tablet, second tab) logs in and pushes
the desktop's access JWT out of the per-user 5-token concurrent cap,
the desktop's session cookie now carries a revoked JWT. The WS
connection survives because it auths once at connect time, but every
subsequent <audio> fetch returns 401 — and unlike RTK Query traffic
those don't go through the auto-refresh path.

Add a recovery hook on the audio player: when the element fires
'error', POST /api/auth/refresh once. The fresh Set-Cookie installs
a new os_session, and we retry the same call. Subsequent failures
on the same item give up and skip to the next, so we can't loop.
@revtex revtex merged commit cfbdcb2 into dev Apr 25, 2026
7 checks passed
@revtex revtex deleted the fix/audio-401-recovery branch April 25, 2026 21:00
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.

1 participant