From cf007db4ad82d7df3513ae2a0ab8b6e8b96c5c75 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 30 Jul 2026 09:53:21 -0500 Subject: [PATCH] fix(clk-gateway): log unhandled route errors, harden ZyFi failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #118, which added ZyFi status and body logging. Three gaps remain. 1. The global Express error middleware returns a 500 to the client but never logs, so no unexpected error on any route leaves a server-side trace. Log the message and stack for 500s, and log handled HttpErrors at warn level with method and URL. 2. `fetchZyfiSponsored` did not distinguish a network-level failure (DNS, TLS, connection reset) from an HTTP rejection. The request never reaches ZyFi in that case, so there is no status or body to report; say so explicitly. 3. `await response.json()` ran before the `response.ok` check, so a non-JSON error body — an HTML or empty response from a proxy or load balancer in front of ZyFi — threw a SyntaxError that discarded the status and left the same blind spot #118 set out to close. Read the body as text first, then parse, so the status survives a non-JSON response. Keeps the `operation` label convention introduced in #118. --- clk-gateway/src/helpers.ts | 52 +++++++++++++++++++++++++++++++------- clk-gateway/src/index.ts | 9 +++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/clk-gateway/src/helpers.ts b/clk-gateway/src/helpers.ts index b5a84a0d..2b47925b 100644 --- a/clk-gateway/src/helpers.ts +++ b/clk-gateway/src/helpers.ts @@ -197,16 +197,50 @@ export async function fetchZyfiSponsored( operation = "unknown", ): Promise { console.log(`ZyFi request [${operation}]: ${JSON.stringify(request)}`); - const response = await fetch(zyfiSponsoredUrl!, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-API-KEY": process.env.ZYFI_API_KEY!, - }, - body: JSON.stringify(request), - }); - const responseBody = await response.json(); + // NB: `Response` in this module resolves to Express's type, so infer from fetch. + let response: Awaited>; + try { + response = await fetch(zyfiSponsoredUrl!, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-KEY": process.env.ZYFI_API_KEY!, + }, + body: JSON.stringify(request), + }); + } catch (error) { + // Network-level failure (DNS, TLS, connection reset). The request never + // reached ZyFi, so there is no status or body to report. + const cause = error instanceof Error ? error.message : String(error); + console.error(`ZyFi network failure [${operation}]: ${cause}`); + throw new Error(`Failed to reach zyfi sponsored [${operation}]: ${cause}`); + } + + // Read as text before parsing: an error emitted by a proxy or load balancer + // in front of ZyFi may be HTML or empty, and parsing that as JSON throws a + // SyntaxError that discards the status needed to diagnose the failure. + const rawBody = await response.text(); + let responseBody: unknown; + try { + responseBody = JSON.parse(rawBody); + } catch { + if (!response.ok) { + console.error( + `ZyFi error [${operation}] ${response.status} ${response.statusText} (non-JSON body): ${rawBody}`, + ); + throw new Error( + `Failed to fetch zyfi sponsored [${operation}]: ${response.status} ${response.statusText} — ${rawBody}`, + ); + } + console.error( + `ZyFi returned a non-JSON success body [${operation}]: ${rawBody}`, + ); + throw new Error( + `Failed to parse zyfi sponsored response [${operation}]: ${rawBody}`, + ); + } + if (!response.ok) { console.error( `ZyFi error [${operation}] ${response.status} ${response.statusText}:`, diff --git a/clk-gateway/src/index.ts b/clk-gateway/src/index.ts index c568ebec..81b589ee 100644 --- a/clk-gateway/src/index.ts +++ b/clk-gateway/src/index.ts @@ -518,11 +518,20 @@ app.use('/resolve', resolveRouter); app.use((err: Error, req: Request, res: Response, next: NextFunction): void => { if (err instanceof HttpError) { + console.warn( + `${req.method} ${req.originalUrl} -> ${err.statusCode}: ${err.message}` + ); res.status(err.statusCode).json({ error: err.message }); return; } const message = err instanceof Error ? err.message : String(err); + // Unexpected failures were previously returned to the client but never + // logged, leaving no server-side trace to debug from. + console.error(`${req.method} ${req.originalUrl} -> 500: ${message}`); + if (err instanceof Error && err.stack) { + console.error(err.stack); + } res.status(500).json({ error: message }); });