src/transport/interpret.ts
v0.2.0 · 7.8 KB
// "An execute threw - what happened and what now?" One entry point over the error classes.
//
// Error codes (knowledge base, `orders.md` §11):
// 2020 OrderNotFound - already cancelled OR filled (documentation)
// 2064 reduce-only oversized - presumably "reduce-only would increase the position" (never observed)
// 2067 resting reduce-only - reduce-only only with IOC/FOK (observed live)
// 2069 trading blocked - trading_status = not_tradable (observed live, see markets/status.ts)
// 2117 post-only market - only POST_ONLY accepted (observed live, see markets/status.ts)
import { isRateLimitError, isTransientError, NadoRejection } from './errors.js';
/** Known `error_code` values. */
export const NADO_ERROR_CODES = Object.freeze({
/** `OrderNotFound`: already cancelled or filled. By documentation. */
ORDER_NOT_FOUND: 2020,
/** Reduce-only larger than the position / would increase it. Semantics not confirmed. @experimental */
REDUCE_ONLY_OVERSIZED: 2064,
/** Resting reduce-only is not allowed (taker types only). Observed live. */
RESTING_REDUCE_ONLY: 2067,
/** `Trading is blocked for this market` (`not_tradable`). Observed live. */
MARKET_NOT_TRADABLE: 2069,
/** `Market is in post-only mode ... Only post-only orders are accepted`. Observed live. */
MARKET_POST_ONLY: 2117,
});
/** Classification of a failed execute. */
export type ExecuteFailureKind =
/** 2117: the market accepts only POST_ONLY right now (pre-listing, stock perps on weekends). */
| 'postOnlyMarket'
/** 2069: the market accepts nothing (`not_tradable`). */
| 'marketBlocked'
/** 2067: reduce-only on a resting type. Fix the order type; never resend as is. */
| 'restingReduceOnly'
/** 2064: reduce-only larger than the position (unconfirmed semantics). */
| 'reduceOnlyOversized'
/** 2020: the order to cancel is gone - cancelled earlier or FILLED. Cancel not confirmed. */
| 'orderNotFound'
/** Any other failure envelope: final, not applied, log it whole. */
| 'rejected'
/** HTTP 429: rejected before the matching engine. */
| 'rateLimited'
/** 5xx / timeout / network / unreadable body: outcome unknown. */
| 'transportUnknown'
/** Other 4xx, validation, caller abort: not applied. */
| 'notApplied';
/** What to do next. */
export type ExecuteAdvice =
/** Re-send the same request through the throttle with backoff. */
| 'retry'
/** Do NOT re-send. For a failure envelope the venue answered; for 2069 wait for the next tick. */
| 'do-not-retry'
/** Resting DEFAULT refused with 2117: re-send the SAME order as POST_ONLY with a new nonce (no duplicate: the first was never applied). */
| 'resend-as-post-only'
/** Full close refused with 2064: retry ONCE with the exact position size; alert if refused again. */
| 'retry-exact-position-size'
/** Outcome unknown or cancel unconfirmed: re-read positions / orders before doing anything else. */
| 'reconcile';
/** Context of the failed action, used to refine the advice. */
export interface ExecuteContext {
/** `'place'` (default), `'cancel'` or `'link'`. */
action?: 'place' | 'cancel' | 'link';
/** Order type that was sent (for `place`). */
orderType?: 'default' | 'ioc' | 'fok' | 'post_only';
/** True for a FULL reduce-only close. */
fullClose?: boolean;
/** True when a repeat cannot change the outcome (cancel, full close). */
idempotent?: boolean;
}
/** Normalized failure. */
export interface ExecuteFailure {
readonly kind: ExecuteFailureKind;
/** `error_code` for a failure envelope. */
readonly code: number | undefined;
readonly message: string;
/** `'not-applied'`: the venue did not apply the action. `'unknown'`: it may have. */
readonly outcome: 'not-applied' | 'unknown';
readonly advice: ExecuteAdvice;
/** True for a failure envelope (the sequencer answered). */
readonly rejection: boolean;
}
function messageOf(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);
}
/**
* Interprets an error thrown by `client.execute(...)` (or a raw `NadoRejection`).
*
* - failure envelope -> `outcome: 'not-applied'`; `advice` by code: 2117 on a resting DEFAULT ->
* `resend-as-post-only` (an IOC gets `do-not-retry`: takers cannot exist on such a market and must not
* be rewritten); 2064 on a full close -> `retry-exact-position-size`; 2020 -> `reconcile` (the order
* may have filled); everything else -> `do-not-retry`;
* - HTTP 429 -> `retry` (rejected before matching);
* - 5xx / timeout / network / unparsable body -> `outcome: 'unknown'`; `retry` when idempotent, else `reconcile`;
* - other 4xx / aborts / validation -> `not-applied`, `do-not-retry`.
*
* Unknown errors are `transportUnknown` / `reconcile`: never assume nothing was placed.
*/
export function interpretExecuteError(err: unknown, ctx: ExecuteContext = {}): ExecuteFailure {
const action = ctx.action ?? 'place';
if (err instanceof NadoRejection) {
const base = { code: err.code, message: err.errorText, outcome: 'not-applied' as const, rejection: true };
switch (err.code) {
case NADO_ERROR_CODES.MARKET_POST_ONLY: {
const resting = action === 'place' && ctx.orderType === 'default';
return { ...base, kind: 'postOnlyMarket', advice: resting ? 'resend-as-post-only' : 'do-not-retry' };
}
case NADO_ERROR_CODES.MARKET_NOT_TRADABLE:
return { ...base, kind: 'marketBlocked', advice: 'do-not-retry' };
case NADO_ERROR_CODES.RESTING_REDUCE_ONLY:
return { ...base, kind: 'restingReduceOnly', advice: 'do-not-retry' };
case NADO_ERROR_CODES.REDUCE_ONLY_OVERSIZED:
return {
...base,
kind: 'reduceOnlyOversized',
advice: ctx.fullClose ? 'retry-exact-position-size' : 'do-not-retry',
};
case NADO_ERROR_CODES.ORDER_NOT_FOUND:
return { ...base, kind: 'orderNotFound', advice: 'reconcile' };
default:
return { ...base, kind: 'rejected', advice: 'do-not-retry' };
}
}
const message = messageOf(err);
if (isRateLimitError(err))
return { kind: 'rateLimited', code: undefined, message, outcome: 'not-applied', advice: 'retry', rejection: false };
if (isTransientError(err)) {
return {
kind: 'transportUnknown',
code: undefined,
message,
outcome: 'unknown',
advice: ctx.idempotent ? 'retry' : 'reconcile',
rejection: false,
};
}
if (typeof err === 'object' && err !== null && (err as { name?: unknown }).name === 'AbortError') {
// The caller aborted: the request may or may not have left the socket.
return {
kind: 'transportUnknown',
code: undefined,
message,
outcome: 'unknown',
advice: 'reconcile',
rejection: false,
};
}
if (err instanceof RangeError || err instanceof TypeError) {
return {
kind: 'notApplied',
code: undefined,
message,
outcome: 'not-applied',
advice: 'do-not-retry',
rejection: false,
};
}
const status = (err as { status?: unknown } | null)?.status;
if (typeof status === 'number' && status >= 400 && status <= 499) {
return {
kind: 'notApplied',
code: undefined,
message,
outcome: 'not-applied',
advice: 'do-not-retry',
rejection: false,
};
}
return {
kind: 'transportUnknown',
code: undefined,
message,
outcome: 'unknown',
advice: 'reconcile',
rejection: false,
};
}
/**
* True when a successful `place_order` response carries a usable digest. A "success" without one is
* treated as a rejection: there is no digest to confirm or cancel the order by.
*/
export function hasUsableDigest(data: unknown): data is { digest: `0x${string}` } {
const d = (data as { digest?: unknown } | null)?.digest;
return typeof d === 'string' && /^0x[0-9a-fA-F]{64}$/.test(d);
}