src/errors/transport.ts
v0.3.0 · 13.5 KB
// 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;
}