src/transport/errors.ts
v0.2.0 · 9.2 KB
// Error classes and classifiers for gateway traffic.
//
// Retry safety depends on the class:
// - failure envelope on an execute: the sequencer answered, the order was NOT applied - final, never retried;
// - HTTP 429: rejected before execution, safe to repeat with backoff;
// - 5xx / timeout / network / unparsable 200: outcome UNKNOWN. Only idempotent calls (queries, cancels,
// a full reduce-only close) may be retried;
// - other 4xx: not applied, do not retry.
/** Maximum number of characters of a response body kept on an error. */
export const MAX_ERROR_BODY_CHARS = 2000;
function truncate(text: string): string {
return text.length > MAX_ERROR_BODY_CHARS ? `${text.slice(0, MAX_ERROR_BODY_CHARS)}...` : text;
}
/**
* Failure envelope (`{status:'failure', error, error_code}`). On an execute this is a definitive
* refusal by the sequencer: nothing was applied, and it must not be retried blindly (there is no
* "did it apply?" ambiguity). On a query it usually means bad parameters.
*/
export class NadoRejection extends Error {
override readonly name = 'NadoRejection';
/** `error_code` (-1 when the envelope carried none). */
readonly code: number;
/** `error` text from the envelope. */
readonly errorText: string;
/** `request_type` from the envelope, or the caller's label. */
readonly requestType: string | undefined;
/** `'execute'` or `'query'`. */
readonly path: 'query' | 'execute';
constructor(init: { code: number; error: string; requestType?: string; path: 'query' | 'execute' }) {
super(`Nado ${init.path} rejected (${init.code}): ${init.error}`);
this.code = init.code;
this.errorText = init.error;
this.requestType = init.requestType;
this.path = init.path;
}
}
/** Non-2xx HTTP response. Always carries a numeric `status` so classifiers can see a 429 or a 502. */
export class NadoHttpError extends Error {
override readonly name = 'NadoHttpError';
readonly status: number;
/** Response body (truncated), '' when unreadable. */
readonly bodyText: string;
readonly url: string;
readonly requestType: string | undefined;
constructor(init: { status: number; bodyText?: string; url: string; requestType?: string }) {
const what = init.requestType ? ` for ${init.requestType}` : '';
super(`Nado HTTP ${init.status}${what}`);
this.status = init.status;
this.bodyText = truncate(init.bodyText ?? '');
this.url = init.url;
this.requestType = init.requestType;
}
}
/**
* HTTP 2xx whose body is not JSON. The gateway answers PLAIN TEXT to a syntactically broken query, and a
* proxy can truncate a 200. Transient for queries; for an execute the outcome is unknown.
*/
export class NadoResponseParseError extends Error {
override readonly name = 'NadoResponseParseError';
readonly status: number;
readonly bodyText: string;
readonly url: string;
readonly requestType: string | undefined;
/** Marker understood by {@link isTransientError}. */
readonly transientResponseBody = true;
constructor(init: { status: number; bodyText: string; url: string; requestType?: string; cause: unknown }) {
const what = init.requestType ? ` for ${init.requestType}` : '';
super(`Nado returned an unparsable ${init.status} body${what}`, { cause: init.cause });
this.status = init.status;
this.bodyText = truncate(init.bodyText);
this.url = init.url;
this.requestType = init.requestType;
}
}
/** A 2xx JSON body that is not a `{status:'success'|'failure'}` envelope. Outcome unknown. */
export class NadoEnvelopeError extends Error {
override readonly name = 'NadoEnvelopeError';
readonly body: unknown;
readonly requestType: string | undefined;
constructor(init: { body: unknown; requestType?: string }) {
const what = init.requestType ? ` for ${init.requestType}` : '';
let preview = '';
try {
preview = JSON.stringify(init.body).slice(0, 300);
} catch {
preview = String(init.body);
}
super(`Nado returned an unexpected envelope${what}: ${preview}`);
this.body = init.body;
this.requestType = init.requestType;
}
}
/** The client's own per-attempt timeout fired (10 s by default). Outcome unknown for executes. */
export class NadoTimeoutError extends Error {
override readonly name = 'NadoTimeoutError';
readonly timeoutMs: number;
readonly url: string;
readonly requestType: string | undefined;
constructor(init: { timeoutMs: number; url: string; requestType?: string; cause?: unknown }) {
const what = init.requestType ? ` (${init.requestType})` : '';
super(`Nado request timed out after ${init.timeoutMs} ms${what}`, { cause: init.cause });
this.timeoutMs = init.timeoutMs;
this.url = init.url;
this.requestType = init.requestType;
}
}
/** `fetch` rejected for a reason other than an abort (DNS, reset, refused, TLS...). Outcome unknown. */
export class NadoNetworkError extends Error {
override readonly name = 'NadoNetworkError';
readonly url: string;
readonly requestType: string | undefined;
constructor(init: { url: string; requestType?: string; cause: unknown }) {
const causeMsg = init.cause instanceof Error ? init.cause.message : String(init.cause);
const what = init.requestType ? ` (${init.requestType})` : '';
super(`Nado network error${what}: ${causeMsg}`, { cause: init.cause });
this.url = init.url;
this.requestType = init.requestType;
}
}
/** Thrown by the network preflight when `contracts` reports a different chain id than expected. */
export class NadoNetworkMismatchError extends Error {
override readonly name = 'NadoNetworkMismatchError';
readonly expectedChainId: number;
readonly reportedChainId: number;
constructor(init: { expectedChainId: number; reportedChainId: number; url?: string }) {
super(
`Nado gateway${init.url ? ` ${init.url}` : ''} reports chain_id=${init.reportedChainId} but ${init.expectedChainId} was expected - refusing to sign anything against a mismatched network`,
);
this.expectedChainId = init.expectedChainId;
this.reportedChainId = init.reportedChainId;
}
}
type Loose = {
status?: unknown;
statusCode?: unknown;
message?: unknown;
cause?: unknown;
name?: unknown;
transientResponseBody?: unknown;
};
const MAX_CAUSE_DEPTH = 6;
/** HTTP status carried by an error (this package's classes or ad-hoc `{status}` / `HTTP 502` messages). */
export function statusOf(err: unknown): number | undefined {
if (err === null || typeof err !== 'object') return undefined;
const e = err as Loose;
for (const s of [e.status, e.statusCode]) if (typeof s === 'number' && Number.isInteger(s)) return s;
const m = /\bhttp (\d{3})\b/i.exec(String(e.message ?? ''));
return m ? Number(m[1]) : undefined;
}
function messageOf(err: unknown): string {
if (err === null || err === undefined) return '';
if (typeof err === 'object') return String((err as Loose).message ?? '').toLowerCase();
return String(err).toLowerCase();
}
/**
* True for a rate-limit rejection (HTTP 429): rejected before the matching engine, safe to repeat even
* for non-idempotent executes. A {@link NadoRejection} is never a rate limit (its text may contain
* numbers such as prices). Matches `\b429\b` in messages, never a bare substring.
*/
export function isRateLimitError(err: unknown): boolean {
if (err instanceof NadoRejection) return false;
const status = statusOf(err);
if (status !== undefined) return status === 429;
const msg = messageOf(err);
return /\b429\b/.test(msg) || msg.includes('too many requests') || msg.includes('rate limit');
}
const TRANSIENT_MESSAGES = [
'fetch failed',
'econnreset',
'etimedout',
'socket hang up',
'econnrefused',
'network',
'timeout',
'timed out',
'eai_again',
];
/**
* True when an error is worth retrying for an IDEMPOTENT call: 429, 408, 5xx, timeouts, network
* failures, unparsable or non-envelope 2xx bodies (walking `.cause` up to 6 levels). A
* {@link NadoRejection}, other 4xx and caller aborts are not transient.
*
* For non-idempotent executes (opens, increases, partial reductions) use {@link isRateLimitError}:
* after a 5xx or a timeout the action may have been applied.
*/
export function isTransientError(err: unknown): boolean {
if (isRateLimitError(err)) return true;
let cur: unknown = err;
for (let depth = 0; cur !== null && cur !== undefined && depth <= MAX_CAUSE_DEPTH; depth++) {
if (cur instanceof NadoRejection) return false;
if (
cur instanceof NadoTimeoutError ||
cur instanceof NadoNetworkError ||
cur instanceof NadoResponseParseError ||
cur instanceof NadoEnvelopeError ||
cur instanceof SyntaxError
) {
return true;
}
if (typeof cur !== 'object') {
const msg = messageOf(cur);
return TRANSIENT_MESSAGES.some((x) => msg.includes(x));
}
const e = cur as Loose;
if (e.transientResponseBody === true || e.name === 'SyntaxError' || e.name === 'TimeoutError') return true;
if (e.name === 'AbortError') return false;
const s = statusOf(cur);
if (s === 408 || (s !== undefined && s >= 500 && s <= 599)) return true;
if (s !== undefined && s >= 400 && s <= 499) return false;
if (s !== undefined && s >= 200 && s <= 299) return true;
const msg = messageOf(cur);
if (TRANSIENT_MESSAGES.some((x) => msg.includes(x))) return true;
cur = e.cause;
}
return false;
}