Skip to content

fix(ui): stop the 401 polling storm when a session dies mid-visit - #318

Open
stubbi wants to merge 1 commit into
mainfrom
fix/query-401-stop-polling
Open

fix(ui): stop the 401 polling storm when a session dies mid-visit#318
stubbi wants to merge 1 commit into
mainfrom
fix/query-401-stop-polling

Conversation

@stubbi

@stubbi stubbi commented Aug 5, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The hosted cloud app is a React SPA whose data layer is a single app-wide react-query QueryClient, with CloudAccessGate deciding whether the visitor is signed in
  • A review of production analytics for the last two weeks found 1,547 HTTP 401s from 19 people, arriving in bursts of 50-120 per minute — including minutes with zero clicks and zero pageviews, i.e. a background tab hammering the API on its own
  • Two things combine to produce that: the QueryClient had no retry override, so every 401 was retried three times as if it were transient; and CloudAccessGate learns "signed out" only from its own session query, which is stale-cached for 30s, so a session dying mid-visit left every other query failing with no feedback path back to the gate
  • The user-visible result is the worst possible one: the app quietly stops loading instead of sending them to sign-in. One user re-authenticated ten times in forty minutes and never got a working session
  • This pull request makes 401/403 terminal for the retry budget, and makes any auth failure anywhere re-ask the one question that matters — are we still signed in?
  • The benefit is that a dead session now costs one request per query and ends in a redirect to sign-in, instead of an unbounded request storm against a page the user cannot use

Linked Issues or Issue Description

No public issue exists for this; describing it here per the bug report template.

What happened. In the hosted app, when a session expires while the tab is open, the UI does not send the user to sign-in. It keeps polling every resource endpoint (live-runs, issues, attention, dashboard, agents, labels, budgets, routines, skills, user-directory), each of which returns 401 and is retried three times, then polls again on its interval.

What I expected. One 401, then a redirect to sign-in.

Evidence. Minute-level client telemetry for one affected user:

14:09   99 x 401
14:17   51 x 401   <- 0 clicks, 0 pageviews: nobody was touching the app
14:27   60 x 401 + 3 sign-ins

Fork-wide over 14 days: 1,547 x 401 across 19 people, ~445 of them on /api/issues/*/live-runs.

Not related to #311 — that is a self-hosted authenticated-mode redirect loop in the SPA's own auth route. This is the cloud path, where /auth/* is served by the gateway. Max auth URL length in production is 140 characters and there are no 414s, so the two do not overlap.

What Changed

  • ui/src/lib/query-auth-policy.ts (new) — isAuthFailure() and shouldRetryQuery(). A 401/403 is an answer, not a transient failure, so it does not consume the retry budget; every other failure keeps react-query's stock 3 attempts.
  • ui/src/lib/query-auth-recheck.ts (new) — installAuthFailureRecheck() subscribes to the query cache and refetches the session query once when any other query reports an auth failure. CloudAccessGate then resolves a null session and redirects to sign-in exactly as it already does.
  • ui/src/main.tsx — wires retry: shouldRetryQuery into the QueryClient defaults and installs the re-check.
  • Tests for both modules (13 cases).

Two loop guards are deliberate and tested: the session query's own 401 never triggers a re-check (it is the answer), and a burst of 401s from many queries collapses into a single re-check.

Verification

pnpm vitest run ui/src/lib/query-auth-policy.test.ts    # 9 passed
pnpm vitest run ui/src/lib/query-auth-recheck.test.ts   # 4 passed
pnpm vitest run ui/src                                  # 3125 passed

The re-check tests drive a real QueryClient — no mocking of the module under test — and assert on the session queryFn actually being called again.

Full-suite comparison against a clean fork/main checkout on the same machine: 21 test files fail at baseline, 20 with this change applied, and the set of failures is a strict subset. No regressions; the pre-existing failures are the known timezone-dependent and macOS-flaky suites.

No screenshots: this changes no rendered output. The visible difference is a redirect to sign-in where the page previously stayed broken, which needs a live expiring session to show.

Risks

Low, but worth a reviewer's eye on one thing: the re-check widens what can trigger a session refetch. A genuine per-resource 403 (for example a company the user may not see) now causes one extra GET /api/auth/get-session. That resolves to the same signed-in session and changes nothing, so the cost is a single request, not a redirect — CloudAccessGate only redirects on a definitive null session, which is unchanged by this PR.

The retry change is strictly a reduction in requests. Nothing that previously succeeded on retry can regress, because a retried 401 never became a 200.

Model Used

  • Claude Opus 5 (claude-opus-5[1m], 1M context), extended thinking, via the Claude Code CLI harness with tool use (shell, file edits, PostHog MCP for the production telemetry). Change authored agent-assisted.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have not referenced internal/instance-local Paperclip issues or links (only public GitHub #NNN / github.com/paperclipai/paperclip URLs)
  • My branch name describes the change (e.g. docs/..., fix/...) and contains no internal Paperclip ticket id or instance-derived details
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

🤖 Generated with Claude Code

An expired session made every polling query on the page burn the full
react-query retry budget and then poll again on its own interval, with
nothing telling the access gate the session was gone. Production traffic
shows 50-120 401s per minute against /api/* in minutes with zero clicks
and zero pageviews -- a background tab hammering a dead session while the
user sees an app that has quietly stopped loading. 1,547 of these across
19 people in 14 days, ~445 on /api/issues/*/live-runs alone.

Two causes, both fixed here:

- The app-wide QueryClient had no retry override, so a 401 was retried
  three times like a transient failure. It is an answer, not a blip.
- CloudAccessGate decides "signed out, redirect" from its own session
  query alone. That query is stale-cached for 30s and otherwise only
  refetched on window focus, so when a session died mid-visit every other
  query started failing with no feedback path back to the gate.

query-auth-policy gives the QueryClient a retry predicate that treats
401/403 as terminal and leaves every other failure on the stock budget.
query-auth-recheck subscribes to the query cache and re-asks the session
question once when any other query reports an auth failure, so the gate
resolves null and redirects to sign-in instead of leaving a broken page.
A burst of 401s from many queries collapses into a single re-check, and
the session query's own 401 is ignored so it cannot loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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