src/errors/sdk.ts
v0.3.0 · 8 KB
// Recognition of @nktkas/hyperliquid error objects without importing the SDK.
//
// Errors are recognized by `name` and shape, never by `instanceof`: SDK 0.27.1 did not export
// ApiRequestError from the package root, and even with 0.33.3 (where it is exported) two copies of
// the SDK in one dependency tree make `instanceof` fail. The cause chain is walked so an SDK error
// wrapped by a retry layer or a logger is still found.
import {
parseCancelStatuses,
parseOrderStatuses,
placedOids,
type CancelStatusResult,
type OrderStatusParseOptions,
type OrderStatusResult,
} from './statuses.js';
const MAX_CAUSE_DEPTH = 6;
/** `err` followed by up to 6 levels of `.cause`; a cycle stops the walk. */
export function causeChain(err: unknown): unknown[] {
const nodes: unknown[] = [];
let cur: unknown = err;
while (nodes.length <= MAX_CAUSE_DEPTH && cur !== null && cur !== undefined && !nodes.includes(cur)) {
nodes.push(cur);
if (typeof cur !== 'object') break;
cur = (cur as { cause?: unknown }).cause;
}
return nodes;
}
/** Visits `err` and up to 6 levels of `.cause`; returns the first node the predicate accepts. */
export function findInCauseChain(err: unknown, predicate: (node: unknown) => boolean): unknown {
return causeChain(err).find(predicate);
}
function hasName(node: unknown, name: string): boolean {
return typeof node === 'object' && node !== null && (node as { name?: unknown }).name === name;
}
/** Shape of an SDK `ApiRequestError` (0.27.1 and 0.33.3). */
export interface ApiRequestErrorLike {
readonly name: 'ApiRequestError';
readonly message: string;
/** Full raw /exchange body. */
readonly response: unknown;
}
/** Finds an `ApiRequestError` (by name) in `err` or its cause chain. */
export function findApiRequestError(err: unknown): ApiRequestErrorLike | undefined {
return findInCauseChain(err, (n) => hasName(n, 'ApiRequestError') && 'response' in (n as object)) as
| ApiRequestErrorLike
| undefined;
}
/** The exchange body carried by an `ApiRequestError`. */
export interface ExchangeErrorBody {
/** `'err'` when the whole action was rejected; `'ok'` for per-element errors (batch or TWAP). */
readonly status: string;
readonly response: unknown;
}
/**
* Returns the /exchange body of an SDK `ApiRequestError`, or `null` for any other error.
*
* - `{ status: 'err', response: '<text>' }`: the whole action was rejected, nothing applied;
* - `{ status: 'ok', response: { type, data: { statuses } } }`: a batch with at least one `{error}`
* element - the NEIGHBOURS MAY HAVE BEEN PLACED (see {@link extractPartialBatch});
* - `{ status: 'ok', response: { data: { status: { error } } } }`: a rejected TWAP.
*/
export function exchangeErrorBody(err: unknown): ExchangeErrorBody | null {
const api = findApiRequestError(err);
const body = api?.response as { status?: unknown; response?: unknown } | undefined;
if (!body || typeof body !== 'object' || typeof body.status !== 'string') return null;
return { status: body.status, response: body.response };
}
/** Per-element results salvaged from a batch the SDK threw on. */
export type PartialBatch =
| {
readonly kind: 'order';
/** `response.type` of the body (`'order'`, `'batchModify'`...). */
readonly responseType: string;
readonly results: readonly OrderStatusResult[];
/** Oids that are live or filled despite the thrown error: record them, they are real orders. */
readonly placedOids: readonly number[];
readonly needsReconcile: boolean;
}
| {
readonly kind: 'cancel';
readonly responseType: string;
readonly results: readonly CancelStatusResult[];
readonly needsReconcile: boolean;
};
/**
* Recovers per-order results from a partially failed batch.
*
* The SDK throws `ApiRequestError` when ANY `statuses[i]` is `{ error }`, even though the other orders
* of the batch were placed; their oids live only in `err.response.response.data.statuses` (unchanged
* from 0.27.1 to 0.33.3). Treating "the SDK threw" as "nothing was placed" loses track of live orders
* and can orphan a TP/SL pair. Returns `null` when `err` is not such a batch error
* (transport failure, `status: 'err'` rejection, TWAP rejection, body without a `statuses` array). A
* `statuses` array that is empty or comes with an unexpected outer status still yields a batch, with
* `needsReconcile: true`.
*
* Bodies with `response.type === 'cancel'` are parsed as cancel statuses, everything else as orders.
*/
export function extractPartialBatch(err: unknown, opts: OrderStatusParseOptions = {}): PartialBatch | null {
// Read the raw body rather than exchangeErrorBody(): the SDK's bulk check ignores the outer
// `status`, so a batch body without one still throws and still carries live oids.
const body = findApiRequestError(err)?.response as { status?: unknown; response?: unknown } | undefined;
if (!body || typeof body !== 'object' || body.status === 'err') return null;
const response = body.response as { type?: unknown; data?: { statuses?: unknown } } | undefined;
const statuses = response?.data?.statuses;
if (!Array.isArray(statuses)) return null;
const responseType = typeof response?.type === 'string' ? response.type : '';
// Once a `statuses` array is present the body is a batch, whatever else is odd about it. An empty
// array or an unexpected outer status must NOT fall back to "whole action rejected" (that reads as
// "nothing applied"): it is returned as a batch that needs reconciling.
const odd = body.status !== 'ok' || statuses.length === 0;
if (responseType === 'cancel') {
const results = parseCancelStatuses(statuses, opts.expectedCount === undefined ? {} : { expectedCount: opts.expectedCount });
const countMismatch = opts.expectedCount !== undefined && statuses.length !== opts.expectedCount;
const needsReconcile = odd || countMismatch || results.some((r) => r.status === 'invalid');
return { kind: 'cancel', responseType, results, needsReconcile };
}
const expected = opts.expectedCount ?? opts.requestedSizes?.length;
const results = parseOrderStatuses(statuses, opts);
const countMismatch = expected !== undefined && statuses.length !== expected;
return {
kind: 'order',
responseType,
results,
placedOids: placedOids(results),
needsReconcile: odd || countMismatch || results.some((r) => r.status === 'invalid'),
};
}
/**
* `true` for an SDK `ValidationError` (0.33.3): parameters failed schema validation BEFORE the lock
* and nonce, so nothing was signed or sent and no nonce was used.
*/
export function isValidationError(err: unknown): boolean {
return findInCauseChain(err, (n) => hasName(n, 'ValidationError')) !== undefined;
}
/** `true` for an SDK `AbstractWalletError` (signing or address lookup failed; nothing was sent). */
export function isWalletError(err: unknown): boolean {
return findInCauseChain(err, (n) => hasName(n, 'AbstractWalletError')) !== undefined;
}
/**
* `true` when a browser wallet user declined the signature: EIP-1193 `code === 4001` or a message
* matching `/user (denied|rejected)/i`, anywhere in the cause chain.
*/
export function isUserRejectedSignature(err: unknown): boolean {
return (
findInCauseChain(err, (n) => {
if (typeof n !== 'object' || n === null) return typeof n === 'string' && /user (denied|rejected)/i.test(n);
const o = n as { code?: unknown; message?: unknown };
return o.code === 4001 || (typeof o.message === 'string' && /user (denied|rejected)/i.test(o.message));
}) !== undefined
);
}
/**
* Calls `fn` and always returns a promise, turning a synchronous throw into a rejection.
*
* `ExchangeClient.order/cancel/...` in SDK 0.33.3 are not `async`: parameter validation runs before
* the promise exists, so a `ValidationError` is thrown synchronously and
* `ex.order(params).catch(handler)` never sees it. `invokeAsync(() => ex.order(params)).catch(handler)`
* does.
*/
export function invokeAsync<T>(fn: () => T | PromiseLike<T>): Promise<Awaited<T>> {
try {
return Promise.resolve(fn()) as Promise<Awaited<T>>;
} catch (err) {
return Promise.reject(err);
}
}