src/errors/interpret.ts
v0.3.0 · 6 KB
// One entry point for "an exchange call threw - what happened and what now?".
import { classifyExchangeError, errorKindInfo, type ExchangeErrorKind } from './classify.js';
import {
exchangeErrorBody,
extractPartialBatch,
isUserRejectedSignature,
type ExchangeErrorBody,
type PartialBatch,
} from './sdk.js';
import type { OrderStatusParseOptions } from './statuses.js';
import { describeTransportFailure, type TransportFailureInfo } from './transport.js';
/** Normalized view of an error thrown by an exchange call. */
export type ExchangeFailure =
/**
* A batch where some elements were rejected. Other elements MAY BE LIVE: record
* `batch.placedOids`, and handle each result on its own.
*/
| { readonly type: 'partial'; readonly message: string; readonly batch: PartialBatch }
/** The exchange rejected the action (`status: 'err'` or a TWAP error). Nothing was applied. */
| {
readonly type: 'rejected';
readonly message: string;
readonly kind: ExchangeErrorKind;
readonly body: ExchangeErrorBody;
}
/** SDK parameter validation failed before signing; nothing was sent and no nonce was used. */
| { readonly type: 'validation'; readonly message: string }
/** The wallet did not sign; nothing was sent. `userRejected` when a human declined. */
| { readonly type: 'signing'; readonly message: string; readonly userRejected: boolean }
/** Transport-level failure or anything unrecognized; see `info.outcome`. */
| { readonly type: 'transport'; readonly message: string; readonly info: TransportFailureInfo };
/**
* Turns anything thrown by an `ExchangeClient` call (or a custom transport) into an
* {@link ExchangeFailure}:
*
* - `ApiRequestError` with per-element `statuses` -> `partial` (the SDK throws even when neighbours
* were placed; their oids are recovered);
* - `ApiRequestError` with `status: 'err'` or a TWAP `data.status.error` -> `rejected` with a
* classified `kind`; any other `ApiRequestError` body proves nothing and becomes `transport` /
* `unknown`;
* - `ValidationError` -> `validation`; `AbstractWalletError` -> `signing`;
* - everything else -> `transport` with `info.outcome` `'not-applied'` or `'unknown'`.
*
* Unrecognized errors are `transport` / `unknown`: never assume nothing was placed.
*/
export function interpretExchangeError(err: unknown, opts: OrderStatusParseOptions = {}): ExchangeFailure {
const batch = extractPartialBatch(err, opts);
if (batch) return { type: 'partial', message: errorMessage(err), batch };
const body = exchangeErrorBody(err);
if (body) {
const message = errorMessage(err);
// `rejected` promises "nothing was applied", so it needs a body that proves it: `status: 'err'`
// or a TWAP-style `data.status.error`. Any other ApiRequestError body falls through to the
// transport classification, which reports it as outcome unknown.
const inner = rejectionText(body);
if (body.status === 'err' || inner !== undefined) {
const text = inner ?? message;
return { type: 'rejected', message: text, kind: classifyExchangeError(text), body };
}
}
const info = describeTransportFailure(err);
if (info.reason === 'validation') return { type: 'validation', message: info.message };
if (info.reason === 'signing') {
return { type: 'signing', message: info.message, userRejected: isUserRejectedSignature(err) };
}
return { type: 'transport', message: info.message, info };
}
function errorMessage(err: unknown): string {
if (typeof err === 'object' && err !== null && typeof (err as { message?: unknown }).message === 'string') {
return (err as { message: string }).message;
}
return String(err);
}
function rejectionText(body: ExchangeErrorBody): string | undefined {
if (body.status === 'err') return typeof body.response === 'string' ? body.response : undefined;
const data = (body.response as { data?: { status?: { error?: unknown } } } | undefined)?.data;
const error = data?.status?.error;
return typeof error === 'string' ? error : undefined;
}
/** What to do next after a failed exchange call. */
export type RetryAdvice =
/** Re-send the same request (through the limiter, with backoff). */
| 'retry'
/** Do NOT re-send: the outcome is unknown or partial. Reconcile (cloid / openOrders / position) first. */
| 'reconcile'
/** Do not re-send; the failure repeats until inputs or state change. */
| 'give-up';
/**
* Retry policy by outcome and action class.
*
* `idempotent` must be `true` only for actions whose repetition cannot change the result: info reads,
* cancels, `updateLeverage`, `agentEnableDexAbstraction`, a FULL reduce-only close. Opening and
* increasing orders, partial reduce-only orders, TP/SL pairs and transfers are NOT idempotent: a
* retry after "success with a timeout" doubles the entry, reduces twice or pays twice. Only a FULL
* reduce-only close is idempotent: marking every reduce-only order idempotent lets a partial reduce
* run twice.
*
* - not applied and transient (HTTP 429, "... and retry", WebSocket frame never sent) -> `retry` for
* any action;
* - unknown and transient (5xx, 408, timeout, network, unreadable 200) -> `retry` if idempotent,
* otherwise `reconcile`;
* - unknown and not transient (caller abort, partial batch, unrecognized) -> `reconcile`;
* - everything else (validation, signing, 4xx, exchange rejections) -> `give-up` (for
* `postOnlyWouldCross` / `iocNoMatch` re-plan on the next tick instead of re-sending as is).
*/
export function recommendRetry(err: unknown, opts: { idempotent: boolean }): RetryAdvice {
const failure = interpretExchangeError(err);
switch (failure.type) {
case 'partial':
return 'reconcile';
case 'rejected':
return errorKindInfo(failure.kind).disposition === 'retryable' ? 'retry' : 'give-up';
case 'validation':
case 'signing':
return 'give-up';
case 'transport': {
const { outcome, transient } = failure.info;
if (outcome === 'not-applied') return transient ? 'retry' : 'give-up';
if (!transient) return 'reconcile';
return opts.idempotent ? 'retry' : 'reconcile';
}
}
}