Skip to content
markpaper

src/transport/errors.ts

v0.2.0 · 9.2 KB

Download file
// 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;
}
All files