Files
opencode-mobile/src/lib/api-error.ts
Den 3b6dab8c27 fix(auth): stop infinite SSE retry on 401/403, surface auth failures (#79)
* fix(auth): stop infinite SSE retry on 401/403 and surface auth failures

Root cause (Sentry OPENCODE-MOBILE-1, 309 events / 65 users): auth is static
HTTP Basic and no code path treated 401 specially. The SSE reconnect loop in
events.ts retried on a fixed backoff regardless of cause, so a bad password
spammed Sentry and drained battery forever with zero user feedback.
Advanced-mode connection save also had no pre-flight check and silently
persisted bad credentials as the active connection.

- src/lib/api-error.ts: new pure ApiAuthError/isAuthStatus/isAuthError module
  (node --test covered) so 401/403 are distinguishable from other failures.
- src/lib/sdk.ts: request()/events() now throw ApiAuthError for 401/403
  instead of a generic Error.
- src/stores/events.ts: the SSE loop stops retrying on an auth error and sets
  a new `authError` flag instead of reconnecting forever; other errors keep
  the existing backoff. Fires connection_failed (source: sse, error_class:
  unauthorized) so it's visible in the existing funnel.
- app/(tabs)/index.tsx: sessions screen shows an "Authentication Failed"
  state with a link to the connection edit screen when authError is set.
- app/connection/[id].tsx: saving edited credentials for the active
  connection now reconnects SSE immediately instead of requiring an app
  restart.
- app/connection/add.tsx: Advanced-mode save now runs the same testConnection
  pre-flight as Quick Connect and shows the same "Connection Failed" alert
  (with diagnostics/share-report) instead of silently saving bad credentials.

Closes #76

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E

* fix(auth): add Retry button on 401 error state, widen ConnectionTestSource

- Authentication Failed screen now offers Retry alongside Check
  Credentials, calling events store's connect() directly to restart
  the SSE state machine on transient 401s without leaving the app.
- Widen ConnectionTestSource to include 'sse' (events.ts:389's
  connection_failed track call) and note the activation funnel only
  filters on source=onboarding.

Addresses PR #79 review follow-ups.

---------

Co-authored-by: engineer <engineer@gray-knight-m1.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:22:53 -07:00

39 lines
1.7 KiB
TypeScript

// Pure classification of HTTP auth failures, extracted so it's unit-testable
// under plain `node --test` without pulling in expo/fetch (sdk.ts is RN-only)
// — same pattern as analytics-classify.ts / diagnostics-classify.ts.
//
// Why this exists: sdk.ts's request()/events() used to throw a generic Error
// for every non-2xx response, so call sites (the SSE reconnect loop, screen
// error states) had no way to tell "your password is wrong" apart from "the
// server is briefly unreachable" — a 401 got treated like any transient
// failure and retried forever (see events.ts's reconnect loop / issue #76).
/** Thrown by sdk.ts's request()/events() when the server responds 401/403,
* so call sites can distinguish "bad credentials" from any other failure
* (network error, 5xx, timeout) instead of catching a generic Error. */
export class ApiAuthError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = "ApiAuthError"
this.status = status
}
}
/** True for HTTP statuses that mean "your credentials are wrong", as opposed
* to transient/network/server failures that should keep retrying. */
export function isAuthStatus(status: number): boolean {
return status === 401 || status === 403
}
/** Build the right error type for a failed HTTP response. */
export function apiErrorFor(status: number, message: string): Error {
return isAuthStatus(status) ? new ApiAuthError(status, message) : new Error(message)
}
/** Type guard for call sites (e.g. the SSE reconnect loop) that need to branch
* on whether a caught error was an auth failure. */
export function isAuthError(error: unknown): error is ApiAuthError {
return error instanceof ApiAuthError
}