Skip to content

fix(diagnostics): classify 401/403 as auth-failed so a wrong password stops the retry loop - #170

Merged
dzianisv merged 1 commit into
mainfrom
age-107-auth-failed-classification
Aug 14, 2026
Merged

fix(diagnostics): classify 401/403 as auth-failed so a wrong password stops the retry loop#170
dzianisv merged 1 commit into
mainfrom
age-107-auth-failed-classification

Conversation

@dzianisv

Copy link
Copy Markdown
Owner

Closes the AGE-107 investigation: why did one device produce 498 API Error: 401 events?

It was not a token-refresh loop

The client's automated retry paths are correct and were never the cause:

  • src/stores/events.ts already terminates the SSE reconnect loop on ApiAuthError and sets a distinct authError state (issue bug: recurring 401 API errors on app session (Sentry OPENCODE-MOBILE-1, 65 users) #76 fix).
  • Every REST call site in src/stores/sessions.ts / connections.ts catches; sendMessage rethrows into a caught try in app/session/[id].tsx.
  • The only QueryClient in the app is constructed and never used — no polling.

Evidence from the live Sentry issue (7535855407, project opencode-mobile):

  • All 498 events, spanning 2026-06-08 → 2026-08-04, come from one user on app.version 0.4.4 — a release that is 10 versions stale.

  • handled: yes, mechanism: generic, tags connect.classification / connect.scheme present → captured by captureDiagnostic(), which in v0.4.4 took a rawError argument and uploaded the raw API Error: 401 verbatim.

  • The 7 surviving events are spaced 87s, 199s, 69s, 5s, 61s, 66s apart, and each is immediately preceded by touch breadcrumbs. That is a person tapping, not a backoff timer.

  • The decisive breadcrumb pair, repeated every time:

    xhr     GET http://…/global/health  ->  401
    console [diag] probe result ok - Health endpoint responded — connection actually works now.
    console [sentry] capture ok (uploading)
    

The real defect

timedFetch in v0.4.4 returned ok: true for any HTTP response, so a 401 health check short-circuited classify() to ok. The app told a user with a wrong password that the connection works. They retried for two months. requireOk (#114, shipped v0.4.8) fixed the false success — but 401 then landed in the generic health-failed bucket, "Likely wrong path, auth, or an old server version", which still doesn't tell anyone to fix their password.

This PR

  • New auth-failed classification. 401/403 from /global/health proves the server is up, reachable, and rejecting the credentials. The summary names the status, points at the password and OPENCODE_SERVER_USERNAME, and states the server itself is fine. It flows into the existing failure Alert on connection/add and connection/[id] — the screens that hold the password field, i.e. the re-auth prompt.
  • Ordered before the root/internet probes, so a flaky root probe can't downgrade a proven auth failure to server-unreachable.
  • health-failed copy no longer blames auth.
  • connect auth-failed added to the noise-gate drop-list. A wrong password is user config: unactionable server-side, already visible in the UI, already trended PII-free in PostHog as connection_failed{error_class:"unauthorized"}. connect health-failed and connect tls-error still report — an unhealthy box or a broken cert is actionable.

Tests

6 new, all pure:

  • 401 → auth-failed, summary mentions the status and credentials and never says "actually works"
  • 403 → auth-failed
  • auth-failed survives an unreachable root probe
  • 404 / 500 / 502 stay health-failed
  • health-failed copy contains no "auth"
  • noise gate drops connect auth-failed, still reports a raw API Error: 401

npm test 263 pass / 0 fail. tsc --noEmit clean.

Note on the quota

This does not change the AGE-105 numbers materially — the noise gate already collapsed this class to ≤1 report per 6h. The value here is that the user now gets told what's wrong, so the loop stops at the source instead of being silently absorbed.

… stops the retry loop

AGE-107. The 498 `API Error: 401` events from one device were not a client
token-refresh loop. Sentry breadcrumbs on the surviving events show a `touch`
event immediately before every capture, at irregular human-paced intervals
(87s, 199s, 69s, 5s, 61s, 66s) — a person re-tapping Connect, not a backoff
timer. The app's automated loops were already correct: events.ts terminates
the SSE reconnect loop on ApiAuthError (issue #76).

What actually drove it: in v0.4.4 the connection probe counted any HTTP
response as a successful health check, so a 401 was classified `ok` and shown
to the user as "Health endpoint responded — connection actually works now"
while their password was wrong. The user retried for two months. `requireOk`
(#114, v0.4.8) stopped the false success, but 401 then fell into the generic
`health-failed` bucket — "Likely wrong path, auth, or an old server version" —
which still doesn't tell anyone to fix their password.

- New `auth-failed` classification: a 401/403 from /global/health means the
  server is up and reachable and rejected the credentials. Its summary names
  the status, points at the password and OPENCODE_SERVER_USERNAME, and says
  the server is fine. It flows straight into the existing failure Alert on
  both the add and edit connection screens — which is where the password
  field is, i.e. the re-auth prompt.
- It short-circuits before the root/internet probes can downgrade it: a 401
  already proves the server answered.
- `health-failed` copy no longer blames auth.
- `connect auth-failed` joins the noise-gate drop-list. A wrong password is
  user config, unactionable server-side, already visible in the UI and
  already trended in PostHog as connection_failed{error_class:"unauthorized"}.
  `health-failed` and `tls-error` still report.

Tests: 6 new (401/403 -> auth-failed, message content, root-unreachable does
not override, 404/500/502 stay health-failed, health-failed copy drops "auth",
noise gate drops `connect auth-failed` but not a raw `API Error: 401`).
263 pass, tsc --noEmit clean.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@dzianisv
dzianisv merged commit 61f4b11 into main Aug 14, 2026
8 checks passed
@dzianisv
dzianisv deleted the age-107-auth-failed-classification branch August 14, 2026 14:14
omgoshjosh pushed a commit to omgoshjosh/opencode-mobile that referenced this pull request Aug 16, 2026
19 commits behind, three versionNames behind. Notable fixes this pulls in
that the evaluation build was missing: 401/403 classified as auth-failed
so a wrong password stops the retry loop (dzianisv#170), the Sentry noise gate,
the waitlist retry queue, and the in-app "a newer version exists" notice
for sideloaded installs (dzianisv#179).

Three conflicts, all resolved toward keeping both sides:

  - android/app/build.gradle: took upstream's versionCode 42 / 0.4.15 but
    kept the BUILD_VERSION_CODE env override. Evaluation builds need a
    monotonic code from CI so each side-by-side install supersedes the
    last; upstream release builds leave it unset and use the literal.

  - app/(tabs)/index.tsx: the group-by picker and UpdateBanner are
    unrelated additions to the same region. Kept both, banner above the
    picker so an update notice reads before the list controls.

  - app/(tabs)/settings.tsx: upstream and this branch independently fixed
    the same hard-coded "1.0.0". Took upstream's update-aware row but fed
    it appVersionLabel rather than CURRENT_VERSION -- sideloaded builds
    share a versionName across many CI builds, so the version alone
    cannot identify which APK is actually installed, which is the whole
    reason that row is being read.

382 tests pass, typecheck clean.

Co-Authored-By: Claude Opus 5 <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