WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: don't re-throw TypeError from fetch() in getSession as a programming error

Node.js's undici throws TypeError: fetch failed for network failures (e.g.
AppView unreachable). The previous catch block called isProgrammingError()
which classifies all TypeErrors as code bugs and re-throws them, causing
every request to return a 500 when the AppView is down.

Fix: split the fetch() call into its own try-catch in both getSession and
getSessionWithPermissions so any throw from the raw fetch — regardless of
error type — is treated as a network failure and returns gracefully.

Adds regression tests using new TypeError("fetch failed") to match the
exact error undici produces in production.

+79 -44
+35
apps/web/src/lib/__tests__/session.test.ts
··· 164 164 ); 165 165 }); 166 166 167 + it("returns unauthenticated when fetch throws TypeError (undici 'fetch failed')", async () => { 168 + // undici throws TypeError: fetch failed — not a plain Error — for network failures. 169 + // This must NOT be re-thrown as a programming error. 170 + mockFetch.mockRejectedValueOnce(new TypeError("fetch failed")); 171 + 172 + const result = await getSession( 173 + "http://localhost:3000", 174 + "atbb_session=token" 175 + ); 176 + 177 + expect(result).toEqual({ authenticated: false }); 178 + expect(logger.error).toHaveBeenCalledWith( 179 + expect.stringContaining("network error"), 180 + expect.objectContaining({ error: "fetch failed" }) 181 + ); 182 + }); 183 + 167 184 it("returns unauthenticated when AppView returns authenticated:false", async () => { 168 185 mockFetch.mockResolvedValueOnce({ 169 186 ok: false, ··· 243 260 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 244 261 }); 245 262 mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); 263 + 264 + const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 265 + expect(result.authenticated).toBe(true); 266 + expect(result.permissions.size).toBe(0); 267 + expect(logger.error).toHaveBeenCalledWith( 268 + expect.stringContaining("network error"), 269 + expect.any(Object) 270 + ); 271 + }); 272 + 273 + it("returns empty permissions when members/me throws TypeError (undici 'fetch failed')", async () => { 274 + // undici throws TypeError: fetch failed — not a plain Error — for network failures. 275 + // This must NOT be re-thrown as a programming error. 276 + mockFetch.mockResolvedValueOnce({ 277 + ok: true, 278 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 279 + }); 280 + mockFetch.mockRejectedValueOnce(new TypeError("fetch failed")); 246 281 247 282 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 248 283 expect(result.authenticated).toBe(true);
+44 -44
apps/web/src/lib/session.ts
··· 1 - import { isProgrammingError } from "./errors.js"; 2 1 import { logger } from "./logger.js"; 3 2 4 3 export type WebSession = ··· 20 19 return { authenticated: false }; 21 20 } 22 21 22 + let res: Response; 23 23 try { 24 - const res = await fetch(`${appviewUrl}/api/auth/session`, { 24 + res = await fetch(`${appviewUrl}/api/auth/session`, { 25 25 headers: { Cookie: cookieHeader }, 26 26 }); 27 - 28 - if (!res.ok) { 29 - if (res.status !== 401) { 30 - logger.error("getSession: unexpected non-ok status from AppView", { 31 - operation: "GET /api/auth/session", 32 - status: res.status, 33 - }); 34 - } 35 - return { authenticated: false }; 36 - } 37 - 38 - const data = (await res.json()) as Record<string, unknown>; 39 - 40 - if ( 41 - data.authenticated === true && 42 - typeof data.did === "string" && 43 - typeof data.handle === "string" 44 - ) { 45 - return { authenticated: true, did: data.did, handle: data.handle }; 46 - } 47 - 48 - return { authenticated: false }; 49 27 } catch (error) { 50 - if (isProgrammingError(error)) throw error; 51 28 logger.error( 52 29 "getSession: network error — treating as unauthenticated", 53 30 { ··· 57 34 ); 58 35 return { authenticated: false }; 59 36 } 37 + 38 + if (!res.ok) { 39 + if (res.status !== 401) { 40 + logger.error("getSession: unexpected non-ok status from AppView", { 41 + operation: "GET /api/auth/session", 42 + status: res.status, 43 + }); 44 + } 45 + return { authenticated: false }; 46 + } 47 + 48 + const data = (await res.json()) as Record<string, unknown>; 49 + 50 + if ( 51 + data.authenticated === true && 52 + typeof data.did === "string" && 53 + typeof data.handle === "string" 54 + ) { 55 + return { authenticated: true, did: data.did, handle: data.handle }; 56 + } 57 + 58 + return { authenticated: false }; 60 59 } 61 60 62 61 /** ··· 85 84 } 86 85 87 86 let permissions = new Set<string>(); 87 + let permRes: Response; 88 88 try { 89 - const res = await fetch(`${appviewUrl}/api/admin/members/me`, { 89 + permRes = await fetch(`${appviewUrl}/api/admin/members/me`, { 90 90 headers: { Cookie: cookieHeader! }, 91 91 }); 92 - 93 - if (res.ok) { 94 - const data = (await res.json()) as Record<string, unknown>; 95 - if (Array.isArray(data.permissions)) { 96 - permissions = new Set(data.permissions as string[]); 97 - } 98 - } else if (res.status !== 404) { 99 - // 404 = no membership = expected for guests, no log needed 100 - logger.error( 101 - "getSessionWithPermissions: unexpected status from members/me", 102 - { 103 - operation: "GET /api/admin/members/me", 104 - did: session.did, 105 - status: res.status, 106 - } 107 - ); 108 - } 109 92 } catch (error) { 110 - if (isProgrammingError(error)) throw error; 111 93 logger.error( 112 94 "getSessionWithPermissions: network error — continuing with empty permissions", 113 95 { 114 96 operation: "GET /api/admin/members/me", 115 97 did: session.did, 116 98 error: error instanceof Error ? error.message : String(error), 99 + } 100 + ); 101 + return { ...session, permissions }; 102 + } 103 + 104 + if (permRes.ok) { 105 + const data = (await permRes.json()) as Record<string, unknown>; 106 + if (Array.isArray(data.permissions)) { 107 + permissions = new Set(data.permissions as string[]); 108 + } 109 + } else if (permRes.status !== 404) { 110 + // 404 = no membership = expected for guests, no log needed 111 + logger.error( 112 + "getSessionWithPermissions: unexpected status from members/me", 113 + { 114 + operation: "GET /api/admin/members/me", 115 + did: session.did, 116 + status: permRes.status, 117 117 } 118 118 ); 119 119 }