Skip to content
markpaper

src/errors/transport.ts

v0.3.0 · 13.5 KB

Download file
// Did a failed /exchange call reach the matching engine?
//
// - HTTP 429 and other 4xx (except 408): rejected before execution. The outcome is KNOWN: nothing
//   was applied. Treating 4xx/429 as "unknown" costs an openOrders reconcile every tick.
// - HTTP 5xx, 408, timeout, abort, connection drop, unreadable 200 body: the outcome is UNKNOWN. The
//   order may be live. Never blindly re-send a placement (a re-send after a timeout can duplicate it);
//   reconcile by cloid / openOrders first. Only idempotent actions may be retried.
//
// Works on SDK errors (`HttpRequestError`, `WebSocketRequestError`, `ValidationError`,
// `AbstractWalletError`, `ApiRequestError`), on HlHttpError-like objects `{ status }` from this kit's
// transport, and on plain errors that only mention `HTTP 502` in their message.

import { classifyExchangeError, errorKindInfo, RATE_LIMIT_TEXT } from './classify.js';
import { causeChain, findInCauseChain } from './sdk.js';

/** `'not-applied'`: the exchange provably did not apply the request. `'unknown'`: it may have. */
export type TransportOutcome = 'not-applied' | 'unknown';

/** Detected failure class. */
export type TransportFailureReason =
  /** SDK ValidationError: rejected locally before signing; nonce not used. */
  | 'validation'
  /** Wallet failed to sign or return its address; nothing was sent. */
  | 'signing'
  /** The exchange answered with a rejection of the whole action (`status: 'err'` or a TWAP error). */
  | 'exchange-rejected'
  /** The exchange answered with per-element errors in a batch: some elements may be applied. */
  | 'exchange-partial'
  /** HTTP 429 or a rate-limit message. */
  | 'rate-limited'
  /** HTTP 4xx other than 408 and 429. */
  | 'http-4xx'
  /** HTTP 408 Request Timeout. */
  | 'http-408'
  /** HTTP 5xx. */
  | 'http-5xx'
  /** Other HTTP statuses on an error (1xx, 3xx). */
  | 'http-other'
  /** A 2xx with an unusable body: invalid JSON or a non-JSON content type (HTML from a load balancer). */
  | 'bad-response-body'
  | 'timeout'
  /** Aborted through an AbortSignal: the request may already have been sent. */
  | 'aborted'
  | 'network'
  /** WebSocket closed before the frame was sent (the SDK guarantees it never reached the server). */
  | 'ws-not-sent'
  /** WebSocket request failed after sending, or the server rejected it. */
  | 'ws-failed'
  /** Nothing recognizable: treated as unknown. */
  | 'unrecognized';

/** Full classification of a failed call. */
export interface TransportFailureInfo {
  readonly outcome: TransportOutcome;
  readonly reason: TransportFailureReason;
  /** HTTP status when one was found. */
  readonly httpStatus?: number;
  /** Worth retrying for an idempotent action (and, when `outcome` is `'not-applied'`, for any action). */
  readonly transient: boolean;
  /** Rejected by a rate limit before execution. */
  readonly rateLimited: boolean;
  /** Message of the outermost error. */
  readonly message: string;
}

type Loose = {
  name?: unknown;
  message?: unknown;
  status?: unknown;
  statusCode?: unknown;
  code?: unknown;
  response?: unknown;
  transientResponseBody?: unknown;
};

function asLoose(node: unknown): Loose | null {
  return typeof node === 'object' && node !== null ? (node as Loose) : null;
}

function messageOf(node: unknown): string {
  if (typeof node === 'string') return node;
  const o = asLoose(node);
  return o && typeof o.message === 'string' ? o.message : '';
}

function isHttpStatus(v: unknown): v is number {
  return typeof v === 'number' && Number.isInteger(v) && v >= 100 && v <= 599;
}

/** HTTP status carried in a field of a single node (no cause walking, no message parsing). */
function fieldHttpStatus(node: unknown): number | undefined {
  const o = asLoose(node);
  if (!o) return undefined;
  // An ApiRequestError's `response` is the exchange JSON body, not an HTTP response.
  if (o.name === 'ApiRequestError') return undefined;
  if (isHttpStatus(o.status)) return o.status;
  const res = asLoose(o.response);
  if (res && isHttpStatus(res.status)) return res.status;
  if (isHttpStatus(o.statusCode)) return o.statusCode;
  return undefined;
}

/** HTTP status mentioned as `HTTP nnn` in the message of a single node. */
function messageHttpStatus(node: unknown): number | undefined {
  const o = asLoose(node);
  if (!o || o.name === 'ApiRequestError') return undefined;
  // A plain `Error("... HTTP 502 ...")` without `.status`: without this fallback such a 5xx would
  // never be retried while timeouts would.
  const m = /\bhttp (\d{3})\b/i.exec(messageOf(o));
  return m ? Number(m[1]) : undefined;
}

/**
 * HTTP status of an error: `status` (kit `HlHttpError`), `response.status` (SDK `HttpRequestError`),
 * `statusCode`, or `HTTP nnn` in the message - on the error or anywhere in its cause chain. Status
 * fields anywhere in the chain win over message text. `undefined` for timeouts, aborts and network
 * failures, which have no response.
 */
export function httpStatusOf(err: unknown): number | undefined {
  const chain = causeChain(err);
  for (const node of chain) {
    const status = fieldHttpStatus(node);
    if (status !== undefined) return status;
  }
  for (const node of chain) {
    const status = messageHttpStatus(node);
    if (status !== undefined) return status;
  }
  return undefined;
}

/**
 * `true` for an IP rate-limit rejection: status 429, or `Too Many Requests` / `rate limit` / a
 * standalone `429` in a message (`14290`, `0.429` or `asset=429` do not count; see `RATE_LIMIT_TEXT`
 * in classify.ts).
 */
export function isRateLimitFailure(err: unknown): boolean {
  return (
    findInCauseChain(
      err,
      (n) => fieldHttpStatus(n) === 429 || messageHttpStatus(n) === 429 || RATE_LIMIT_TEXT.test(messageOf(n)),
    ) !== undefined
  );
}

const NETWORK_TEXT = /fetch failed|econnreset|etimedout|socket hang up|econnrefused|enotfound|eai_again|network|epipe|und_err/i;
const TIMEOUT_TEXT = /timed out|timeout/i;

function info(
  err: unknown,
  outcome: TransportOutcome,
  reason: TransportFailureReason,
  transient: boolean,
  httpStatus?: number,
  rateLimited: boolean = reason === 'rate-limited',
): TransportFailureInfo {
  const base = { outcome, reason, transient, rateLimited, message: messageOf(err) || String(err) };
  return httpStatus === undefined ? base : { ...base, httpStatus };
}

function classifyStatus(err: unknown, status: number): TransportFailureInfo {
  if (status === 429) return info(err, 'not-applied', 'rate-limited', true, status);
  if (status === 408) return info(err, 'unknown', 'http-408', true, status);
  if (status >= 400 && status <= 499) return info(err, 'not-applied', 'http-4xx', false, status);
  if (status >= 500) return info(err, 'unknown', 'http-5xx', true, status);
  // SDK 0.33.3 throws HttpRequestError with status 200 both for invalid JSON (SyntaxError in cause)
  // and for a non-JSON content type such as an HTML page from a load balancer (NO SyntaxError). A
  // detector that only looks for SyntaxError in the cause chain misses the second case entirely.
  if (status >= 200 && status <= 299) return info(err, 'unknown', 'bad-response-body', true, status);
  return info(err, 'unknown', 'http-other', false, status);
}

function isObj(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

/**
 * An SDK ApiRequestError: the exchange answered. Only a body that PROVES a whole-action rejection is
 * `not-applied`: `status: 'err'`, or a TWAP-style `data.status.error`. A body with a `statuses` array is
 * a batch whose neighbours may be live. Anything else (no body, an unexpected shape) cannot prove that
 * nothing happened and stays `unknown`.
 */
function classifyApiRequestError(root: unknown, node: Loose): TransportFailureInfo {
  const body = node.response;
  if (!isObj(body)) return info(root, 'unknown', 'unrecognized', false);
  const response = body.response;
  const data = isObj(response) ? response.data : undefined;
  if (body.status !== 'err' && isObj(data) && Array.isArray(data.statuses)) {
    return info(root, 'unknown', 'exchange-partial', false);
  }
  let text: string | undefined;
  if (body.status === 'err') {
    text = typeof response === 'string' ? response : messageOf(node);
  } else if (body.status === 'ok' && isObj(data) && isObj(data.status) && typeof data.status.error === 'string') {
    text = data.status.error;
  }
  if (text === undefined) return info(root, 'unknown', 'unrecognized', false);
  // `transient` follows the same kind table as recommendRetry, so the two never disagree.
  const kind = classifyExchangeError(text);
  const retryable = errorKindInfo(kind).disposition === 'retryable';
  const rateLimited = kind === 'rateLimited' || kind === 'addressRateLimit';
  return info(root, 'not-applied', 'exchange-rejected', retryable, undefined, rateLimited);
}

/** Signals from names, typed fields and SDK-defined messages; they outrank free text anywhere in the chain. */
function classifyStructural(root: unknown, node: unknown): TransportFailureInfo | null {
  const o = asLoose(node);
  if (!o) return null;
  const name = typeof o.name === 'string' ? o.name : '';
  const msg = messageOf(node);

  if (name === 'ValidationError') return info(root, 'not-applied', 'validation', false);
  if (name === 'AbstractWalletError') return info(root, 'not-applied', 'signing', false);
  if (name === 'ApiRequestError') return classifyApiRequestError(root, o);

  const status = fieldHttpStatus(node);
  if (status !== undefined) return classifyStatus(root, status);

  if (name === 'WebSocketRequestError') {
    // "... closed before the request was sent": the SDK rejects only frames it never wrote.
    if (/before the request was sent/i.test(msg)) return info(root, 'not-applied', 'ws-not-sent', true);
    if (TIMEOUT_TEXT.test(msg)) return info(root, 'unknown', 'timeout', true);
    if (/aborted/i.test(msg)) return info(root, 'unknown', 'aborted', false);
    return info(root, 'unknown', 'ws-failed', true);
  }
  if (name === 'TimeoutError' || name === 'HlTimeoutError') return info(root, 'unknown', 'timeout', true);
  // A caller-requested abort is not transient, but the request may already be on the wire.
  if (name === 'AbortError') return info(root, 'unknown', 'aborted', false);
  if (name === 'SyntaxError' || name === 'HlResponseParseError' || o.transientResponseBody === true) {
    return info(root, 'unknown', 'bad-response-body', true);
  }
  if (name === 'HlNetworkError' || (typeof o.code === 'string' && NETWORK_TEXT.test(o.code))) {
    return info(root, 'unknown', 'network', true);
  }
  return null;
}

/**
 * Free-text signals. Outcome-unknown wording (timeouts, aborts, network, bad bodies) is checked
 * BEFORE rate-limit wording: a wrapper message such as "gave up after rate limit backoff: request
 * timed out" must not become "not applied, safe to retry" for a non-idempotent order.
 */
function classifyText(root: unknown, node: unknown): TransportFailureInfo | null {
  const msg = messageOf(node);
  if (!msg) return null;
  const status = messageHttpStatus(node);
  if (status !== undefined) return classifyStatus(root, status);
  if (/timed out/i.test(msg)) return info(root, 'unknown', 'timeout', true);
  if (/request aborted|operation was aborted/i.test(msg)) return info(root, 'unknown', 'aborted', false);
  if (/invalid json|unparsable/i.test(msg)) return info(root, 'unknown', 'bad-response-body', true);
  if (NETWORK_TEXT.test(msg)) return info(root, 'unknown', 'network', true);
  if (RATE_LIMIT_TEXT.test(msg)) return info(root, 'not-applied', 'rate-limited', true);
  if (/\band retry\b/i.test(msg)) return info(root, 'unknown', 'unrecognized', true);
  return null;
}

/**
 * Full classification of a failed exchange call: outcome, reason, HTTP status, transient and
 * rate-limit flags. Walks up to 6 levels of `.cause` twice: typed signals (SDK error names, HTTP
 * status fields, error codes) anywhere in the chain decide first, message text only after that, so a
 * wrapper's wording cannot override the real failure inside it. An error with no recognizable signal
 * is `unknown` / `unrecognized` (fail-closed).
 */
export function describeTransportFailure(err: unknown): TransportFailureInfo {
  const chain = causeChain(err);
  for (const node of chain) {
    const result = classifyStructural(err, node);
    if (result) return result;
  }
  for (const node of chain) {
    const result = classifyText(err, node);
    if (result) return result;
  }
  return info(err, 'unknown', 'unrecognized', false);
}

/**
 * Did the exchange apply the failed request?
 *
 * - `'not-applied'`: HTTP 4xx except 408 (429 included), SDK `ValidationError` (thrown before the
 *   nonce), wallet signing failure, an exchange rejection of the whole action, or a WebSocket frame
 *   that was never sent;
 * - `'unknown'`: HTTP 5xx and 408, timeouts, aborts, network drops, an unreadable 200 body, a batch
 *   with per-element errors (use {@link extractPartialBatch}), and anything unrecognized. The order
 *   may be live: reconcile by cloid / openOrders before placing again.
 */
export function classifyTransportFailure(err: unknown): TransportOutcome {
  return describeTransportFailure(err).outcome;
}

/**
 * `true` when an error is worth retrying for an IDEMPOTENT call (info reads, cancels, updateLeverage,
 * agentEnableDexAbstraction, a full reduce-only close): rate limits, 408, 5xx, timeouts, network
 * failures, unreadable 200 bodies, "... and retry". Caller aborts, validation, signing and plain
 * rejections are not transient. For non-idempotent actions combine with the outcome
 * (see `recommendRetry`).
 */
export function isTransientFailure(err: unknown): boolean {
  return describeTransportFailure(err).transient;
}
All files