src/signer/errors.ts
v0.2.1 · 3.7 KB
// Classification of exchange / sidecar errors (knowledge base: orders.md §9-10, rate-limits.md §1).
//
// Codes observed live (2026-08-20..22):
// 23000 "Too Many Requests!: L1Address ratelimit reached ... 40 requests per 60 second is allowed"
// - the write window; should not happen with a working limiter (it means something is not
// counted: leverage? cancels? a second process on the address?). The Python SDK raises it as
// an exception, so the sidecar returns it as HTTP 502 with the text: match the CODE IN THE TEXT.
// 21706 base_amount below min_base_amount (skip opens/partials before sending; bump a full close);
// 21734 "too far from the mark" - structural, remember the order key for minutes.
// Every other code is unobserved; texts arrive as the SDK's `err` string.
export type SignerErrorKind =
| 'rate_limited'
| 'below_min_base_amount'
| 'far_from_mark'
| 'timeout'
| 'unauthorized'
| 'bad_request'
| 'unknown';
export interface SignerErrorInfo {
kind: SignerErrorKind;
/** Exchange error code found in the text, when any. */
code: number | undefined;
/** Parsed from a 23000 text: the limit the exchange states (read it from the answer, not from a constant). */
rateLimit: { requests: number; windowSec: number } | undefined;
/** Trimmed original text. */
message: string;
}
export const ERROR_CODE_RATE_LIMITED = 23000;
export const ERROR_CODE_BELOW_MIN_BASE_AMOUNT = 21706;
export const ERROR_CODE_FAR_FROM_MARK = 21734;
const CODE_JSON_RE = /"code"\s*:\s*(\d{4,6})\b/;
const CODE_BARE_RE = /\b(?:code|error)\s*[=:]?\s*(\d{4,6})\b/i;
const KNOWN_CODE_RE = /\b(23000|21706|21734)\b/;
const RATE_TEXT_RE = /(\d+)\s+requests?\s+per\s+(\d+)\s+second/i;
/** Extracts a numeric error code from an error text (`{"code":23000,...}`, `code=21706`, or a bare known code). */
export function extractErrorCode(text: string): number | undefined {
const m = CODE_JSON_RE.exec(text) ?? CODE_BARE_RE.exec(text) ?? KNOWN_CODE_RE.exec(text);
return m ? Number(m[1]) : undefined;
}
/**
* Classifies an error text (and optionally the sidecar's HTTP status) into a {@link SignerErrorKind}.
* Matching is by code in the text, then by wording; the HTTP status only distinguishes the sidecar's
* own 400 / 401 / 504 answers.
*/
export function interpretSignerError(text: string | undefined | null, httpStatus?: number): SignerErrorInfo {
const message = (text ?? '').trim();
const code = extractErrorCode(message);
const rate = RATE_TEXT_RE.exec(message);
const rateLimit = rate ? { requests: Number(rate[1]), windowSec: Number(rate[2]) } : undefined;
let kind: SignerErrorKind = 'unknown';
if (code === ERROR_CODE_RATE_LIMITED || /ratelimit reached|too many requests/i.test(message) || rateLimit)
kind = 'rate_limited';
else if (code === ERROR_CODE_BELOW_MIN_BASE_AMOUNT) kind = 'below_min_base_amount';
else if (code === ERROR_CODE_FAR_FROM_MARK || /too far from the mark/i.test(message)) kind = 'far_from_mark';
else if (httpStatus === 504 || /^timeout$/i.test(message)) kind = 'timeout';
else if (httpStatus === 401 || /^unauthorized$/i.test(message)) kind = 'unauthorized';
else if (httpStatus === 400 || /^bad request/i.test(message)) kind = 'bad_request';
return { kind, code, rateLimit, message };
}
/** True when the order key should be remembered as "far from mark" (code 21734). */
export function isFarFromMarkError(text: string | undefined | null): boolean {
return interpretSignerError(text).kind === 'far_from_mark';
}
/** True when the exchange reported the write window (code 23000): the local limiter under-counts something. */
export function isExchangeRateLimitError(text: string | undefined | null): boolean {
return interpretSignerError(text).kind === 'rate_limited';
}