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

@@ -19,6 +19,7 @@ import { router, useFocusEffect } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useSessions } from "../../src/stores/sessions"
import { useConnections } from "../../src/stores/connections"
import { useEvents } from "../../src/stores/events"
import { useCatalog } from "../../src/stores/catalog"
import type BottomSheet from "@gorhom/bottom-sheet"
import type { Session, Project } from "../../src/lib/sdk"
@@ -133,6 +134,8 @@ export default function SessionsScreen() {
addRecentDirectory,
recentDirectories,
} = useConnections()
const authError = useEvents((s) => s.authError)
const reconnect = useEvents((s) => s.connect)
const loadCatalog = useCatalog((s) => s.load)
const dirSheetRef = useRef<BottomSheet>(null)
const browserSheetRef = useRef<BottomSheet>(null)
@@ -348,6 +351,43 @@ export default function SessionsScreen() {
)
}
// The SSE loop stopped retrying because the server rejected our
// credentials (401/403) — no amount of pull-to-refresh fixes that, so
// point the user straight at the fix instead of a spinner that never
// resolves (issue #76).
if (authError) {
return (
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
<Ionicons name="lock-closed-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>Authentication Failed</Text>
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
{activeConnection.name} rejected your credentials. Check the username and password to reconnect.
</Text>
<View style={styles.authErrorButtonRow}>
<TouchableOpacity
style={[styles.addButton, isDark && styles.addButtonDark]}
onPress={() => router.push(`/connection/${activeConnection.id}`)}
testID="fix-connection-button"
>
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Check Credentials</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.addButton, isDark && styles.addButtonDark]}
onPress={() => {
// authError is cleared inside connect() itself once the retry
// attempt starts (see src/stores/events.ts), so a manual
// set() here isn't needed — just kick the SSE state machine.
reconnect()
}}
testID="retry-connection-button"
>
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Retry</Text>
</TouchableOpacity>
</View>
</View>
)
}
const shortPath = getShortPath(currentProject)
return (
@@ -832,6 +872,10 @@ const styles = StyleSheet.create({
borderRadius: 8,
marginTop: 24,
},
authErrorButtonRow: {
flexDirection: "row",
gap: 12,
},
addButtonDark: {
backgroundColor: "#ffffff",
},