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: getSession crashes with 500 when AppView unreachable (TypeError re-thrown) (#97)

* 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.

* fix: guard res.json() calls against SyntaxError from malformed AppView responses

The split-try-catch refactor (previous commit) isolated fetch() correctly but
left res.json() and permRes.json() unprotected. A proxy returning an HTML error
page on a 200 response would throw an unhandled SyntaxError, crashing the request
with no structured log at the failure site.

Wrap both .json() calls in their own try-catch blocks with specific error messages,
returning { authenticated: false } / empty permissions as appropriate.

Also: rename misleading test ("response is malformed" now clarifies it tests
missing fields, not SyntaxError), tighten TypeError assertion in
getSessionWithPermissions to verify did and error fields are logged.

authored by

Malpercio and committed by
GitHub
73d65a1e 0e46c369

+143 -40
+82 -1
apps/web/src/lib/__tests__/session.test.ts
··· 131 131 expect(logger.error).not.toHaveBeenCalled(); 132 132 }); 133 133 134 - it("returns unauthenticated when AppView response is malformed", async () => { 134 + it("returns unauthenticated when AppView response has missing fields", async () => { 135 135 mockFetch.mockResolvedValueOnce({ 136 136 ok: true, 137 137 json: () => ··· 149 149 expect(result).toEqual({ authenticated: false }); 150 150 }); 151 151 152 + it("returns unauthenticated when AppView returns invalid JSON", async () => { 153 + // A proxy or misconfigured server might return an HTML error page on a 200 response. 154 + // res.json() throws SyntaxError in that case — must be caught gracefully. 155 + mockFetch.mockResolvedValueOnce({ 156 + ok: true, 157 + json: () => 158 + Promise.reject( 159 + new SyntaxError("Unexpected token '<', \"<html>\" is not valid JSON") 160 + ), 161 + }); 162 + 163 + const result = await getSession( 164 + "http://localhost:3000", 165 + "atbb_session=token" 166 + ); 167 + 168 + expect(result).toEqual({ authenticated: false }); 169 + expect(logger.error).toHaveBeenCalledWith( 170 + expect.stringContaining("invalid JSON"), 171 + expect.any(Object) 172 + ); 173 + }); 174 + 152 175 it("returns unauthenticated and logs when AppView is unreachable (network error)", async () => { 153 176 mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); 154 177 ··· 161 184 expect(logger.error).toHaveBeenCalledWith( 162 185 expect.stringContaining("network error"), 163 186 expect.objectContaining({ error: expect.stringContaining("ECONNREFUSED") }) 187 + ); 188 + }); 189 + 190 + it("returns unauthenticated when fetch throws TypeError (undici 'fetch failed')", async () => { 191 + // undici throws TypeError: fetch failed — not a plain Error — for network failures. 192 + // This must NOT be re-thrown as a programming error. 193 + mockFetch.mockRejectedValueOnce(new TypeError("fetch failed")); 194 + 195 + const result = await getSession( 196 + "http://localhost:3000", 197 + "atbb_session=token" 198 + ); 199 + 200 + expect(result).toEqual({ authenticated: false }); 201 + expect(logger.error).toHaveBeenCalledWith( 202 + expect.stringContaining("network error"), 203 + expect.objectContaining({ error: "fetch failed" }) 164 204 ); 165 205 }); 166 206 ··· 249 289 expect(result.permissions.size).toBe(0); 250 290 expect(logger.error).toHaveBeenCalledWith( 251 291 expect.stringContaining("network error"), 292 + expect.any(Object) 293 + ); 294 + }); 295 + 296 + it("returns empty permissions when members/me throws TypeError (undici 'fetch failed')", async () => { 297 + // undici throws TypeError: fetch failed — not a plain Error — for network failures. 298 + // This must NOT be re-thrown as a programming error. 299 + mockFetch.mockResolvedValueOnce({ 300 + ok: true, 301 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 302 + }); 303 + mockFetch.mockRejectedValueOnce(new TypeError("fetch failed")); 304 + 305 + const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 306 + expect(result.authenticated).toBe(true); 307 + expect(result.permissions.size).toBe(0); 308 + expect(logger.error).toHaveBeenCalledWith( 309 + expect.stringContaining("network error"), 310 + expect.objectContaining({ error: "fetch failed", did: "did:plc:abc" }) 311 + ); 312 + }); 313 + 314 + it("returns empty permissions when members/me returns invalid JSON", async () => { 315 + // A proxy might return an HTML error page even on 200. permRes.json() would throw SyntaxError. 316 + mockFetch.mockResolvedValueOnce({ 317 + ok: true, 318 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 319 + }); 320 + mockFetch.mockResolvedValueOnce({ 321 + ok: true, 322 + json: () => 323 + Promise.reject( 324 + new SyntaxError("Unexpected token '<', \"<html>\" is not valid JSON") 325 + ), 326 + }); 327 + 328 + const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 329 + expect(result.authenticated).toBe(true); 330 + expect(result.permissions.size).toBe(0); 331 + expect(logger.error).toHaveBeenCalledWith( 332 + expect.stringContaining("invalid JSON"), 252 333 expect.any(Object) 253 334 ); 254 335 });
+61 -39
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 + let data: Record<string, unknown>; 49 + try { 50 + data = (await res.json()) as Record<string, unknown>; 51 + } catch { 52 + logger.error("getSession: AppView returned invalid JSON — treating as unauthenticated", { 53 + operation: "GET /api/auth/session", 54 + status: res.status, 55 + }); 56 + return { authenticated: false }; 57 + } 58 + 59 + if ( 60 + data.authenticated === true && 61 + typeof data.did === "string" && 62 + typeof data.handle === "string" 63 + ) { 64 + return { authenticated: true, did: data.did, handle: data.handle }; 65 + } 66 + 67 + return { authenticated: false }; 60 68 } 61 69 62 70 /** ··· 85 93 } 86 94 87 95 let permissions = new Set<string>(); 96 + let permRes: Response; 88 97 try { 89 - const res = await fetch(`${appviewUrl}/api/admin/members/me`, { 98 + permRes = await fetch(`${appviewUrl}/api/admin/members/me`, { 90 99 headers: { Cookie: cookieHeader! }, 91 100 }); 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[]); 101 + } catch (error) { 102 + logger.error( 103 + "getSessionWithPermissions: network error — continuing with empty permissions", 104 + { 105 + operation: "GET /api/admin/members/me", 106 + did: session.did, 107 + error: error instanceof Error ? error.message : String(error), 97 108 } 98 - } else if (res.status !== 404) { 99 - // 404 = no membership = expected for guests, no log needed 109 + ); 110 + return { ...session, permissions }; 111 + } 112 + 113 + if (permRes.ok) { 114 + let data: Record<string, unknown>; 115 + try { 116 + data = (await permRes.json()) as Record<string, unknown>; 117 + } catch { 100 118 logger.error( 101 - "getSessionWithPermissions: unexpected status from members/me", 119 + "getSessionWithPermissions: members/me returned invalid JSON — continuing with empty permissions", 102 120 { 103 121 operation: "GET /api/admin/members/me", 104 122 did: session.did, 105 - status: res.status, 123 + status: permRes.status, 106 124 } 107 125 ); 126 + return { ...session, permissions }; 108 127 } 109 - } catch (error) { 110 - if (isProgrammingError(error)) throw error; 128 + if (Array.isArray(data.permissions)) { 129 + permissions = new Set(data.permissions as string[]); 130 + } 131 + } else if (permRes.status !== 404) { 132 + // 404 = no membership = expected for guests, no log needed 111 133 logger.error( 112 - "getSessionWithPermissions: network error — continuing with empty permissions", 134 + "getSessionWithPermissions: unexpected status from members/me", 113 135 { 114 136 operation: "GET /api/admin/members/me", 115 137 did: session.did, 116 - error: error instanceof Error ? error.message : String(error), 138 + status: permRes.status, 117 139 } 118 140 ); 119 141 }