From 49157b9efad8bcca4b5427f2b023dee317b58432 Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Thu, 23 Oct 2025 20:10:03 -0700 Subject: [PATCH] server: fetchers: redo all with good error detection --- src/server/trpc/trpc.router.fetchers.ts | 356 +++++++++++++++--------- 1 file changed, 227 insertions(+), 129 deletions(-) diff --git a/src/server/trpc/trpc.router.fetchers.ts b/src/server/trpc/trpc.router.fetchers.ts index 2a40d708e..9d1f0075a 100644 --- a/src/server/trpc/trpc.router.fetchers.ts +++ b/src/server/trpc/trpc.router.fetchers.ts @@ -16,81 +16,22 @@ const SERVER_LOG_FETCHERS_ERRORS = true; // log all fetcher errors to the consol // // JSON fetcher -export async function fetchJsonOrTRPCThrow(config: RequestConfig): Promise { - return _fetchFromTRPC(config, _jsonRequestParserOrThrow, 'json'); +export async function fetchJsonOrTRPCThrow(config: _RequestConfig): Promise { + return _fetchFromTRPC(config, _jsonResponseParserOrThrow, 'json'); } -async function _jsonRequestParserOrThrow(response: Response) { - let text = ''; - try { - text = await response.text(); - return JSON.parse(text) as any; - } catch (error) { - - // Errors: Cannot Parse - if (error instanceof SyntaxError) { - - const contentType = response.headers?.get('content-type')?.toLowerCase() || ''; - const contentTypeInfo = contentType && !contentType.includes('application/json') ? ` (Content-Type: ${contentType})` : ''; - - // Improve messaging of Empty or Incomplete JSON - if (error.message === 'Unexpected end of JSON input') - throw new TRPCError({ - code: 'PARSE_ERROR', - message: (!text?.length ? 'Empty response while expecting JSON' : 'Incomplete JSON response') + contentTypeInfo, - cause: error, - }); - - // Improve messaging of a real parsing error where we expected JSON and got something else - if (error.message.startsWith('Unexpected token')) { - const lcText = text.trim().toLowerCase(); - let inferredType = 'unknown'; - - if ([' lcText.startsWith(tag))) - inferredType = 'HTML'; - else if ([' lcText.startsWith(tag))) - inferredType = 'XML'; - else if ([' lcText.startsWith(tag))) - inferredType = 'HTML-like'; - else if (lcText.startsWith('{') || lcText.startsWith('[')) - inferredType = 'malformed JSON'; - - throw new TRPCError({ - code: 'PARSE_ERROR', - message: `Expected JSON data but received ${inferredType ? inferredType + ', likely an error page' : 'NON-JSON content'}${contentTypeInfo}: \n\n"${text.length > 200 ? text.slice(0, 200) + '...' : text}"`, - cause: error, - }); - } - - throw new TRPCError({ - code: 'PARSE_ERROR', - message: `Error parsing JSON data${contentTypeInfo}: ${safeErrorString(error) || 'unknown error'}`, - }); - - } - - // Other errors: AbortError (request aborted), TypeError (body locked, decoding error for instance due to Content-Encoding mismatch), etc.. - throw new TRPCError({ - code: 'PARSE_ERROR', - message: `Error reading JSON data: ${safeErrorString(error) || 'unknown error'}`, - }); - } - // unreachable -} - - // Text fetcher -export async function fetchTextOrTRPCThrow(config: RequestConfig): Promise { +export async function fetchTextOrTRPCThrow(config: _RequestConfig): Promise { return _fetchFromTRPC(config, async (response) => await response.text(), 'text'); } // Response fetcher -export async function fetchResponseOrTRPCThrow(config: RequestConfig): Promise { +export async function fetchResponseOrTRPCThrow(config: _RequestConfig): Promise { return _fetchFromTRPC(config, async (response) => response, 'response'); } -type RequestConfig = { +type _RequestConfig = { url: string; headers?: HeadersInit; signal?: AbortSignal; @@ -104,13 +45,74 @@ type RequestConfig = { ); +// +// TRPCFetcherError - unified error for all fetch failures +// + +/** + * Error class for all _fetchFromTRPC failures, easy to pattern-match and retry. + * + * Is-a TRPCError: { code: 'BAD_REQUEST'; message?: string; cause?: unknown; } + * + * Retry Decision Matrix: + * - HTTP 503/429/502 → Retry with exponential backoff (1-30s) + * - Connection errors (ECONNREFUSED, ETIMEDOUT) → Retry with network profile + * - HTTP 4xx (except 429) → Don't retry (client error) + * - Abort → Don't retry (user initiated) + * - Parse → Don't retry (upstream bug) + */ +export class TRPCFetcherError extends TRPCError { + public override readonly name = 'TRPCFetcherError'; + + readonly category: TRPCFetcherErrorCategory; + readonly connErrorName?: string; // [category='connection'] System error code (ECONNREFUSED, ETIMEDOUT, ENOTFOUND, etc.) + readonly httpStatus?: number; // [category='http'] HTTP status code (503, 429, 502, etc.) + + constructor(opts: { + category: TRPCFetcherErrorCategory, + connErrorName?: string, + httpStatus?: number, + // -> TRPCError fields (code, cause) + // code?: TRPCError['code'], + cause?: unknown, + // -> Error fields (message) + message: string, + }) { + const code = // opts.code ? opts.code + opts.category === 'parse' ? 'UNPROCESSABLE_CONTENT' + : opts.category === 'abort' ? 'CLIENT_CLOSED_REQUEST' + : 'BAD_REQUEST'; + super({ code, message: opts.message, cause: opts.cause }); + + this.category = opts.category; + this.connErrorName = opts.connErrorName; + this.httpStatus = opts.httpStatus; + + // Maintains proper prototype chain for instanceof checks + Object.setPrototypeOf(this, TRPCFetcherError.prototype); + } +} + +/** + * @abort: aborted by client/signal + * @connection: network/TCP errors before HTTP response + * @http: upstream returned HTTP error (4xx, 5xx) + * @parse: response parsing failed (malformed JSON, encoding issues) + */ +type TRPCFetcherErrorCategory = + | 'abort' + | 'connection' + | 'http' + | 'parse'; + + /** * Internal fetcher * - Parses errors on connection, http responses, and parsing * - Throws TRPCErrors (as this is used within tRPC procedures) */ async function _fetchFromTRPC( - config: RequestConfig, + config: _RequestConfig, responseParser: (response: Response) => Promise, parserName: 'json' | 'text' | 'response', ): Promise { @@ -156,96 +158,111 @@ async function _fetchFromTRPC }; // upstream FETCH + // @throws DOMException.name=AbortError when the request is aborted by the user + // @throws Error.name=ResponseAborted (Next.js) when the request is aborted (e.g. HMR) + // @throws TypeError: network error occurred (URL invalid, invalid RequestInit, network error such as DNS failure or no connectivity or IP, etc.) response = await fetch(url, request); } catch (error: any) { - // NOTE: if signal?.aborted is true, we also come here, likely with a error?.name = ResponseAborted (Next.js) or AbortError (standard from signal) - // since we don't handle this case specially, the same TRPCError will be thrown as for other connection errors. - if (error?.name === 'AbortError') - throw new TRPCError({ - code: 'BAD_REQUEST', - message: (!throwWithoutName ? `[${moduleName} cancelled]: ` : '') + (error?.message || 'This operation was aborted.'), - cause: error, - }); - - // [logging - Connection error] candidate for the logging system - const errorCause: any | undefined = error ? error?.cause ?? undefined : undefined; + const errorName: string = error?.name || 'UnknownError'; const errorString = safeErrorString(error) || 'unknown fetch error'; - // Show server-access warning for common connection issues - const showAccessWarning = [ - 'ECONNREFUSED', - 'ENOTFOUND', - 'ETIMEDOUT', - 'DNS_ERROR', - 'ECONNRESET', - 'ENETUNREACH', // example an IP is unreachable - ].includes(errorCause?.code) || [ - 'network connection lost.', - 'connect timeout error', - ].includes(errorString.toLowerCase()); + // 1. [shall be handled before] AbortError - user cancelled the request, signal?.aborted shall be true, or ResponseAborted in Next.js (when HMR or similar) + if (['AbortError', 'ResponseAborted'].includes(errorName) /*|| (signal && signal.aborted)*/) + throw new TRPCFetcherError({ + category: 'abort', + message: (!throwWithoutName ? `[${moduleName} cancelled]: ` : '') + + (errorString || 'This operation was aborted.'), + // cause: error, + }); + + // 2. TypeError - network/connection error + + // Resolve Technical details about the Cause - Heuristic + const _cause = !error || !(error instanceof Error) || !('cause' in error) ? null : error.cause ?? null; + const causeName = !_cause || !(_cause instanceof Error) ? null : _cause.name ?? null; + const causeCode = !_cause || !(_cause instanceof Error || typeof _cause === 'object') ? null : (_cause as any).code ?? null; + const causeMessage = safeErrorString(_cause); + // const causeMessage = !_cause ? null : causeName === 'AggregateError' ? safeErrorString(_cause) : _cause?.toString() || safeErrorString(_cause) || null; + const connErrorName = causeCode || causeName || errorName; + + // decide whether to show the URL + const prettyShowUrl = [ + 'ConnectTimeoutError', // timeout connecting to server - cause Name + 'UND_ERR_CONNECT_TIMEOUT', // timeout connecting to server - cause Code + 'ENOTFOUND', // DNS failure + 'ECONNREFUSED', // connection refused (e.g. connecting to localhost from a public server) - often an AggregateError + 'EHOSTUNREACH', // when I unplug the network cable + // not verified, but likely: + 'ETIMEDOUT', // connection timed out + 'ECONNRESET', // connection reset by peer + ].includes(connErrorName); // NOTE: This may log too much - for instance a 404 not found, etc.. - so we're putting it under the flag // Consider we're also throwing the same, so there will likely be further logging. if (SERVER_DEBUG_WIRE || SERVER_LOG_FETCHERS_ERRORS) - console.log(`[${method}] [${moduleName} network issue]: ${errorString}`, { error, errorCause, debugCleanUrl, urlShown: showAccessWarning }); + console.log(`[${method}] [${moduleName} network issue]: "${errorString}"`, { error, _cause, debugCleanUrl, urlShown: prettyShowUrl }); - // Handle (NON) CONNECTION errors -> HTTP 400 - throw new TRPCError({ - code: 'BAD_REQUEST', + // -> throw Connection error: will be a 400 (BAD_REQUEST), with preserved cause + throw new TRPCFetcherError({ + category: 'connection', + connErrorName: connErrorName, message: (!throwWithoutName ? `[${moduleName} network issue]: ` : '') - + `Could not connect: ${errorString}` - + (errorCause ? ` \nTechnical Details: ${safeErrorString(errorCause)}` : '') - + (showAccessWarning ? ` \n\nPlease make sure the Server can access -> ${debugCleanUrl}` : ''), - cause: errorCause, + + `Could not connect: ${errorString}.` + + (causeMessage ? ` \nTechnical cause: ${causeMessage}.` : '') + + (prettyShowUrl ? ` \n\nPlease make sure the Server can access -> ${debugCleanUrl}` : ''), + // cause: _cause, }); } + // 2. Check for non-200s // These are the MOST FREQUENT errors, application level response. Such as: // - 400 when requesting an invalid size to Dall-E-3, etc.. // - 403 when requesting a localhost URL from a public server, etc.. if (!response.ok) { - // try to parse a json or text payload, which frequently contains the error, if present - let payload: any | null = await response.text().catch(() => null); + // parse status and potential payload (frequently contain error details) + let notOkayPayload: any | null = await response.text().catch(() => null); try { - if (payload) - payload = JSON.parse(payload) as string; + if (notOkayPayload) + notOkayPayload = JSON.parse(notOkayPayload) as string; } catch { // ...ignore } // [logging - HTTP error] candidate for the logging system - const status = response.status; - let payloadString = safeErrorString(payload); + const s: number = response.status; + let payloadString = safeErrorString(notOkayPayload); if (payloadString) { + // truncate if (payloadString.length > 200) payloadString = payloadString.slice(0, 200) + '...'; - const lcPayload = payloadString.trim().toLowerCase(); - if ([' lcPayload.startsWith(tag))) - payloadString = 'The data looks like HTML, likely an error page: \n\n"' + payloadString + '"'; + // frame + const inferredType = _inferTextPayloadType(payloadString); + if (inferredType) + payloadString = `The data looks like ${inferredType}: \n\n"${payloadString}"`; } if (SERVER_DEBUG_WIRE || SERVER_LOG_FETCHERS_ERRORS) - console.warn(`[${method}] [${moduleName} issue] (http ${status}, ${response.statusText}):`, { parserName, payloadString }); + console.warn(`[${method}] [${moduleName} issue] (http ${s}, ${response.statusText}):`, { parserName, payloadString }); - // Handle HTTP Response errors -> HTTP 400 - throw new TRPCError({ - code: 'BAD_REQUEST', + // -> throw HTTP error: will be a 400 (BAD_REQUEST), with preserved status + throw new TRPCFetcherError({ + category: 'http', + httpStatus: s, message: (throwWithoutName ? '' : `[${moduleName} issue]: `) - + `Upstream responded with HTTP ${status} ${response.statusText}` + + `Upstream responded with HTTP ${s} ${response.statusText}` + (payloadString ? ` - \n${payloadString}` : '') - + (payload?.error?.failed_generation && url.includes('api.groq.com') // [Groq] - ? ` \n\nGroq: failed_generation: ${payload.error.failed_generation}` : '') - + (status === 403 && moduleName === 'Gemini' && payloadString?.includes('Requests from referer') - ? ' \n\nGemini: Check API key restrictions in Google Cloud Console' : '') - + ((status === 404 || status === 403 || status === 502) && !url.includes('app.openpipe.ai') // [OpenPipe] 403 when the model is associated to the project, 404 when not found - ? ` \n\nPlease make sure the Server can access -> ${debugCleanUrl}` : ''), + // Custom hints for common issues from select providers + + (s === 403 && moduleName === 'Gemini' && payloadString?.includes('Requests from referer') ? ' \n\nGemini: Check API key restrictions in Google Cloud Console' : '') + + ((s === 404 || s === 403 || s === 502) && !url.includes('app.openpipe.ai') ? ` \n\nPlease make sure the Server can access -> ${debugCleanUrl}` : ''), // [OpenPipe] 403 when the model is associated to the project, 404 when not found + // cause: payload, // NOT an Error - do not use even to preserve original error payload as cause }); } + // 3. Safe Parse let value: TOut; try { @@ -254,25 +271,106 @@ async function _fetchFromTRPC // [logging - Parsing error] candidate for the logging system if (SERVER_LOG_FETCHERS_ERRORS) - console.warn(`[${method}] [${moduleName}]: (${parserName} parsing error): ${error?.name}`, { error, url }); + console.warn(`[${method}] [${moduleName}]: (${parserName} parsing error): ${error?.name}`, { error, url }); - // Forward already processed Parsing error -> 422 - if (error instanceof TRPCError) - throw new TRPCError({ - code: 'UNPROCESSABLE_CONTENT', - message: (!throwWithoutName ? `[${moduleName}]: ` : '') - + error.message + // Forward already processed Parsing error, adding the module name if required + if (error instanceof TRPCFetcherError) + throw throwWithoutName ? error : new TRPCFetcherError({ + category: error.category, + connErrorName: error.connErrorName, + httpStatus: error.httpStatus, + message: `[${moduleName} parsing issue]: ${error.message}` + ` \n\nPlease make sure the Server can access -> ${debugCleanUrl}`, - cause: error.cause, + // cause: error.cause, // REMOVE the cause }); - // Handle PARSING Errors -> HTTP 422 - throw new TRPCError({ - code: 'UNPROCESSABLE_CONTENT', - message: (throwWithoutName ? `cannot parse ${parserName}: ` : `[${moduleName} parsing issue]: `) - + (safeErrorString(error) || 'unknown error'), + // -> wrap other PARSING ERRORS / ABORTS + throw new TRPCFetcherError({ + category: !!error && (error as any)?.name === 'AbortError' ? 'abort' : 'parse', + message: (throwWithoutName ? '' : `[${moduleName} parsing issue]: `) + + `Error reading ${parserName} data: ${safeErrorString(error) || 'unknown error'}`, + // cause: error, }); } return value; } + + +// --- Utilities --- + +/** + * JSON Response parser with improved error messages due to fragile responses + */ +async function _jsonResponseParserOrThrow(response: Response) { + let text = ''; + try { + // @throws: AbortError (a DOMException.name, request aborted) + // @throws: TypeError (operation could not be performed: body locked, decoding error for instance due to Content-Encoding mismatch) + text = await response.text(); + + // @throws: SyntaxError (malformed JSON) + return JSON.parse(text) as any; + } catch (error) { + + // 2. JSON.parse errors + if (error instanceof SyntaxError) { + + // specialize by error message + const { message: errorMessage } = error; + const contentType = response.headers?.get('content-type')?.toLowerCase() || ''; + const contentTypeInfo = contentType && !contentType.includes('application/json') ? ` (Content-Type: ${contentType})` : ''; + + // 2.A JSON incomplete / empty + if (errorMessage === 'Unexpected end of JSON input') + throw new TRPCFetcherError({ + category: 'parse', + message: (text?.length ? 'Incomplete JSON response' : 'Empty response while expecting JSON') + contentTypeInfo, + // cause: error, + }); + + // 2.B NOT JSON + if (errorMessage.startsWith('Unexpected token')) { + const inferredType = _inferTextPayloadType(text); + throw new TRPCFetcherError({ + category: 'parse', + message: `Expected JSON data but received ${inferredType ? inferredType + ', likely an error page' : 'NON-JSON content'}${contentTypeInfo}:` + + ` \n\n"${text.length > 200 ? text.slice(0, 200) + '...' : text}"`, + // cause: error, + }); + } + + // 2.C Other SyntaxError + throw new TRPCFetcherError({ + category: 'parse', + message: `Error parsing JSON data${contentTypeInfo}: ${safeErrorString(error) || 'unknown error'}`, + // cause: error, + }); + + } + + // 1. response.text(): AbortError (shall have been dealt with already) or TypeError + throw new TRPCFetcherError({ + category: !!error && (error as any)?.name === 'AbortError' ? 'abort' : 'parse', // note: the second may as well be 'connection' but let's make it 'parse' for safety, so it's not retried + message: `Error reading JSON data: ${safeErrorString(error) || 'unknown error'}`, + // cause: error, + }); + + } + // unreachable +} + +function _inferTextPayloadType(payload?: string) { + if (!payload || typeof (payload as unknown) !== 'string') + return; + + const lcText = payload.trim().toLowerCase(); + if ([' lcText.startsWith(tag))) + return 'HTML'; + else if ([' lcText.startsWith(tag))) + return 'XML'; + else if ([' lcText.startsWith(tag))) + return 'HTML-like'; + else if (lcText.startsWith('{') || lcText.startsWith('[')) + return 'malformed JSON'; +}