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>
This commit is contained in:
Den
2026-07-17 02:22:53 -07:00
committed by GitHub
parent b86ffec02f
commit 3b6dab8c27
8 changed files with 214 additions and 15 deletions

View File

@@ -13,6 +13,7 @@ import {
import { router, useLocalSearchParams } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useConnections } from "../../src/stores/connections"
import { useEvents } from "../../src/stores/events"
import type { ConnectionType } from "../../src/lib/types"
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
import { captureDiagnostic } from "../../src/lib/sentry"
@@ -130,6 +131,13 @@ export default function EditConnectionScreen() {
directory: directory.trim() || undefined,
username: username.trim() || undefined,
})
// If this was the active connection, the SSE loop may have stopped
// retrying after a prior 401 (see events.ts) — reconnect now with the
// freshly saved credentials instead of leaving the user stuck until
// they relaunch the app.
if (useConnections.getState().activeConnection?.id === connection.id) {
useEvents.getState().connect()
}
setIsSaving(false)
router.back()
}

View File

@@ -133,23 +133,59 @@ export default function AddConnectionScreen() {
}
track(AnalyticsEvent.ConnectionFormSubmitted, { mode: "advanced" })
// Advanced mode saves directly without a pre-flight health check (see
// useConnections.addConnection), so unlike quick-connect there is no
// success/failure signal to report here — only that an attempt was made.
track(AnalyticsEvent.ConnectionAttempted, { source: "onboarding" })
setIsConnecting(true)
await addConnection(
// Pre-flight, mirroring Quick Connect: previously Advanced mode saved
// directly with no health check, so bad credentials (401/403) or an
// unreachable server silently became the active connection with zero
// feedback (issue #76). testConnection() also fires the
// connection_attempted/succeeded/failed analytics events.
const result = await testConnection(
{
id: "",
name: name.trim(),
type,
url: url.trim(),
directory: directory.trim() || undefined,
username: username.trim() || undefined,
},
"onboarding",
password || undefined,
)
if (result.ok) {
await addConnection(
{
name: name.trim(),
type,
url: url.trim(),
directory: directory.trim() || undefined,
username: username.trim() || undefined,
},
password || undefined,
)
setIsConnecting(false)
router.back()
return
}
// Failed: same "Connection Failed" alert as Quick Connect — run active
// diagnostics, capture to Sentry, and offer a shareable report instead of
// silently persisting an unreachable/unauthorized connection.
const report = await probeConnection(
url.trim(),
username.trim() && password ? { username: username.trim(), password } : undefined,
)
captureDiagnostic(report)
setIsConnecting(false)
router.back()
Alert.alert(
"Connection Failed",
`${report.summary}\n\nTarget: ${url.trim()}\nError: ${result.error || "Unknown error"}`,
[
{ text: "OK", style: "cancel" },
{ text: "Share report", onPress: () => shareReport(report) },
],
)
}
const handleJoinWaitlist = async () => {