From 8a409530194c01d5da926643497101e77e158742 Mon Sep 17 00:00:00 2001 From: chrisp Date: Wed, 27 May 2026 16:14:14 +0000 Subject: [PATCH] Fix unhandled rejection from session ping when network is unavailable (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The 15-minute session keep-alive interval in `useDraftAuthRecovery` had no `try-catch`. When `authClient.getSession()` is called while the network is down (e.g. a laptop waking from sleep), `better-fetch` throws a `TypeError: Failed to fetch` — producing an unhandled promise rejection that was reported in Sentry. - Fixed by wrapping the interval callback in a `try-catch`. Network-level `TypeError`s are silently swallowed because real session expiry (HTTP 401) is returned in-band as `{ data: null }` by `better-fetch` and already caught by the `!session` check. - Improved the comment to explain the mechanism rather than citing socket offline detection (which doesn't validate auth). - Added 3 tests covering: network throw (no authDegraded), null session response (authDegraded set), valid session (no change). ## Root cause User left the draft page open overnight. The 15-minute ping fired repeatedly. At some point the computer woke from sleep with the network not yet ready — `getSession()` threw, `setInterval` dropped the rejected promise, Sentry caught it as an unhandled rejection. Confirmed via Sentry breadcrumbs: last activity May 26 8:32 PM, error fired May 27 12:23 PM (~28 hours later). ## Test plan - [x] 3 new tests pass - [x] typecheck clean - [ ] After deploy: monitor Sentry for recurrence 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris Parsons Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/56 --- .../useDraftAuthRecovery.session-ping.test.ts | 118 ++++++++++++++++++ app/hooks/useDraftAuthRecovery.ts | 12 +- 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 app/hooks/__tests__/useDraftAuthRecovery.session-ping.test.ts diff --git a/app/hooks/__tests__/useDraftAuthRecovery.session-ping.test.ts b/app/hooks/__tests__/useDraftAuthRecovery.session-ping.test.ts new file mode 100644 index 0000000..ef38bad --- /dev/null +++ b/app/hooks/__tests__/useDraftAuthRecovery.session-ping.test.ts @@ -0,0 +1,118 @@ +/** + * Tests for the 15-minute session keep-alive ping in useDraftAuthRecovery. + * + * Verifies that: + * - A network TypeError (fetch throws) is silently swallowed and does NOT + * set authDegraded, because a transient network drop should not trigger the + * "Session Expired" overlay mid-draft. + * - A null session response (in-band 401 / expired) DOES set authDegraded. + * - A valid session response leaves authDegraded false. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useDraftAuthRecovery } from "../useDraftAuthRecovery"; + +// --------------------------------------------------------------------------- +// Mock auth-client +// --------------------------------------------------------------------------- + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})); + +vi.mock("~/lib/auth-client", () => ({ + authClient: { getSession: mockGetSession }, +})); + +// --------------------------------------------------------------------------- +// Minimal hook params — only what the interval ping needs +// --------------------------------------------------------------------------- + +function makeParams() { + return { + reconnectCount: 0, + revalidate: vi.fn(), + revalidatorState: "idle" as const, + currentUserId: "user-1", + userAutodraftSettings: null, + draftPicks: [], + season: {} as any, + userQueue: [], + timers: [], + autodraftSettings: [], + setPicks: vi.fn(), + setCurrentPick: vi.fn(), + setIsPaused: vi.fn(), + setIsDraftComplete: vi.fn(), + setQueue: vi.fn(), + setTeamTimers: vi.fn(), + setAutodraftStatus: vi.fn(), + setIsSyncing: vi.fn(), + setUserAutodraft: vi.fn(), + }; +} + +const FIFTEEN_MINUTES = 15 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("useDraftAuthRecovery — session keep-alive ping", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockGetSession.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("does NOT set authDegraded when getSession() throws a network TypeError", async () => { + mockGetSession.mockRejectedValue(new TypeError("Failed to fetch")); + + const { result } = renderHook(() => useDraftAuthRecovery(makeParams())); + + expect(result.current.authDegraded).toBe(false); + + await act(async () => { + vi.advanceTimersByTime(FIFTEEN_MINUTES); + // flush the async callback + await Promise.resolve(); + }); + + expect(result.current.authDegraded).toBe(false); + }); + + it("sets authDegraded when getSession() returns null data (session expired)", async () => { + mockGetSession.mockResolvedValue({ data: null, error: { status: 401 } }); + + const { result } = renderHook(() => useDraftAuthRecovery(makeParams())); + + expect(result.current.authDegraded).toBe(false); + + await act(async () => { + vi.advanceTimersByTime(FIFTEEN_MINUTES); + await Promise.resolve(); + }); + + expect(result.current.authDegraded).toBe(true); + }); + + it("leaves authDegraded false when getSession() returns a valid session", async () => { + mockGetSession.mockResolvedValue({ + data: { userId: "user-1" }, + error: null, + }); + + const { result } = renderHook(() => useDraftAuthRecovery(makeParams())); + + await act(async () => { + vi.advanceTimersByTime(FIFTEEN_MINUTES); + await Promise.resolve(); + }); + + expect(result.current.authDegraded).toBe(false); + }); +}); diff --git a/app/hooks/useDraftAuthRecovery.ts b/app/hooks/useDraftAuthRecovery.ts index d3df7a6..4273ea5 100644 --- a/app/hooks/useDraftAuthRecovery.ts +++ b/app/hooks/useDraftAuthRecovery.ts @@ -120,8 +120,16 @@ export function useDraftAuthRecovery({ if (authDegraded) return; const FIFTEEN_MINUTES = 15 * 60 * 1000; const id = setInterval(async () => { - const { data: session } = await authClient.getSession(); - if (!session) setAuthDegraded(true); + try { + const { data: session } = await authClient.getSession(); + if (!session) setAuthDegraded(true); + } catch { + // Network failures throw a TypeError from fetch(), but real session + // expiry (401) is returned in-band as { data: null } by better-fetch + // and caught by the !session check above. Swallowing the throw here + // means a transient network drop doesn't trigger the auth overlay + // mid-draft. + } }, FIFTEEN_MINUTES); return () => clearInterval(id); }, [authDegraded]);