server: fetchers: redo all with good error detection

This commit is contained in:
Enrico Ros
2025-10-23 20:10:03 -07:00
parent c11684a9cf
commit 49157b9efa
+227 -129
View File
@@ -16,81 +16,22 @@ const SERVER_LOG_FETCHERS_ERRORS = true; // log all fetcher errors to the consol
//
// JSON fetcher
export async function fetchJsonOrTRPCThrow<TOut extends object = object, TBody extends object | undefined | FormData = undefined>(config: RequestConfig<TBody>): Promise<TOut> {
return _fetchFromTRPC<TBody, TOut>(config, _jsonRequestParserOrThrow, 'json');
export async function fetchJsonOrTRPCThrow<TOut extends object = object, TBody extends object | undefined | FormData = undefined>(config: _RequestConfig<TBody>): Promise<TOut> {
return _fetchFromTRPC<TBody, TOut>(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 (['<html', '<!doctype'].some(tag => lcText.startsWith(tag)))
inferredType = 'HTML';
else if (['<?xml', '<rss', '<feed', '<xml'].some(tag => lcText.startsWith(tag)))
inferredType = 'XML';
else if (['<div', '<span', '<p', '<script', '<br', '<body', '<head', '<title'].some(tag => 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<TBody extends object | undefined = undefined>(config: RequestConfig<TBody>): Promise<string> {
export async function fetchTextOrTRPCThrow<TBody extends object | undefined = undefined>(config: _RequestConfig<TBody>): Promise<string> {
return _fetchFromTRPC<TBody, string>(config, async (response) => await response.text(), 'text');
}
// Response fetcher
export async function fetchResponseOrTRPCThrow<TBody extends object | undefined = undefined>(config: RequestConfig<TBody>): Promise<Response> {
export async function fetchResponseOrTRPCThrow<TBody extends object | undefined = undefined>(config: _RequestConfig<TBody>): Promise<Response> {
return _fetchFromTRPC<TBody, Response>(config, async (response) => response, 'response');
}
type RequestConfig<TBody extends object | undefined | FormData> = {
type _RequestConfig<TBody extends object | undefined | FormData> = {
url: string;
headers?: HeadersInit;
signal?: AbortSignal;
@@ -104,13 +45,74 @@ type RequestConfig<TBody extends object | undefined | FormData> = {
);
//
// 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<TBody extends object | undefined | FormData, TOut>(
config: RequestConfig<TBody>,
config: _RequestConfig<TBody>,
responseParser: (response: Response) => Promise<TOut>,
parserName: 'json' | 'text' | 'response',
): Promise<TOut> {
@@ -156,96 +158,111 @@ async function _fetchFromTRPC<TBody extends object | undefined | FormData, TOut>
};
// 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 (['<!doctype', '<html', '<head', '<body', '<script', '<title'].some(tag => 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<TBody extends object | undefined | FormData, TOut>
// [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 (['<html', '<!doctype'].some(tag => lcText.startsWith(tag)))
return 'HTML';
else if (['<?xml', '<rss', '<feed', '<xml'].some(tag => lcText.startsWith(tag)))
return 'XML';
else if (['<div', '<span', '<p', '<script', '<br', '<body', '<head', '<title'].some(tag => lcText.startsWith(tag)))
return 'HTML-like';
else if (lcText.startsWith('{') || lcText.startsWith('['))
return 'malformed JSON';
}