src/errors/classify.test.ts
v0.3.0 · 5.6 KB
import { describe, expect, it } from 'vitest';
import { classifyExchangeError, errorKindInfo, type ExchangeErrorKind } from './classify.js';
const cases: Array<[string, ExchangeErrorKind]> = [
// Strings recorded in the knowledge base.
['Order must have minimum value of $10.', 'minNotional'],
['Order must have minimum value of 10 USDC.', 'minNotional'],
['Order has invalid price.', 'invalidPrice'],
['Insufficient margin to place order.', 'insufficientMargin'],
['perpMarginRejected', 'insufficientMargin'],
['badAloPxRejected', 'postOnlyWouldCross'],
['Post only order would have immediately matched, bbo was [email protected]', 'postOnlyWouldCross'],
['Reduce only order would increase position.', 'reduceOnlyWouldIncrease'],
['Order could not immediately match against any resting orders. asset=0', 'iocNoMatch'],
['Order was never placed, already canceled, or filled.', 'orderNotFound'],
['TWAP was never placed, already canceled, or filled.', 'orderNotFound'],
['Abstraction transition not allowed', 'abstractionAlreadyEnabled'],
['User or API Wallet 0x0000000000000000000000000000000000000000 does not exist.', 'agentNotAuthorized'], // privacy-allow: placeholder address
['Action disabled when unified account is active', 'unifiedAccountActionDisabled'],
['Isolated position does not have sufficient margin available to decrease leverage', 'leverage'],
['Invalid TWAP duration: 1 min(s)', 'invalidTwapDuration'],
['Server busy, please wait and retry', 'retryLater'],
['429 Too Many Requests', 'rateLimited'],
['rate limit exceeded', 'rateLimited'],
// Documentation-only strings (experimental kinds).
['Too many cumulative requests sent (10001 > 10000) for cumulative volume traded $0.', 'addressRateLimit'],
['Price too far from oracle.', 'priceTooFarFromOracle'],
['Order price cannot be more than 95% away from the reference price', 'priceTooFarFromOracle'],
['Price must be divisible by tick size.', 'invalidPrice'],
['Order has invalid size.', 'invalidSize'],
['Order has zero size.', 'invalidSize'],
['Invalid TP/SL price.', 'invalidTriggerPrice'],
['Order would cause position to exceed margin tier limit at current leverage.', 'positionLimit'],
['Order would increase position at open interest cap.', 'openInterestCap'],
['No liquidity available for market order.', 'iocNoMatch'],
['Builder fee has not been approved.', 'builderFee'],
['openInterestCapRejected', 'openInterestCap'],
['selfTradeRejected', 'rejected'],
['Something completely new', 'unknown'],
['', 'unknown'],
];
describe('classifyExchangeError', () => {
it.each(cases)('%s -> %s', (message, kind) => {
expect(classifyExchangeError(message)).toBe(kind);
});
it('does not file an IoC miss under post-only (the common /immediately|post only|alo/ regex does)', () => {
const ioc = 'Order could not immediately match against any resting orders.';
expect(/immediately|post only|alo/i.test(ioc)).toBe(true);
expect(classifyExchangeError(ioc)).toBe('iocNoMatch');
});
it('matches "alo" only as a word', () => {
expect(classifyExchangeError('Along the way something failed')).toBe('unknown');
expect(classifyExchangeError('tif Alo rejected')).toBe('postOnlyWouldCross');
});
it('does not treat 429 inside a longer number as a rate limit', () => {
expect(classifyExchangeError('Unknown oid 14290')).toBe('unknown');
expect(classifyExchangeError('Order 4291 failed')).toBe('unknown');
});
it('does not raise a dead-key alarm on builder "not approved" or unrelated "does not exist"', () => {
expect(classifyExchangeError('Builder fee has not been approved.')).not.toBe('agentNotAuthorized');
expect(classifyExchangeError('Asset does not exist')).toBe('unknown');
});
it('does not read an unrelated cancel failure mentioning "filled" as order gone', () => {
expect(classifyExchangeError('Cancel failed: order partially filled, retry later')).not.toBe('orderNotFound');
});
it('accepts status elements and error objects', () => {
expect(classifyExchangeError({ error: 'Order has invalid price.' })).toBe('invalidPrice');
expect(classifyExchangeError(new Error('Insufficient margin to place order.'))).toBe('insufficientMargin');
expect(classifyExchangeError({ foo: 1 })).toBe('unknown');
expect(classifyExchangeError(null)).toBe('unknown');
expect(classifyExchangeError(undefined)).toBe('unknown');
});
});
describe('errorKindInfo', () => {
it('maps kinds to dispositions from the knowledge base', () => {
expect(errorKindInfo('agentNotAuthorized').disposition).toBe('fatal');
expect(errorKindInfo('abstractionAlreadyEnabled').disposition).toBe('benign');
expect(errorKindInfo('unifiedAccountActionDisabled').disposition).toBe('unsupported');
expect(errorKindInfo('postOnlyWouldCross').disposition).toBe('transient');
expect(errorKindInfo('rateLimited')).toMatchObject({ disposition: 'retryable', holdMs: 10_000 });
expect(errorKindInfo('minNotional')).toMatchObject({ disposition: 'permanent', holdMs: 30_000 });
expect(errorKindInfo('insufficientMargin')).toMatchObject({ disposition: 'permanent', holdMs: 30_000 });
expect(errorKindInfo('orderNotFound').disposition).toBe('noop');
expect(errorKindInfo('reduceOnlyWouldIncrease').disposition).toBe('noop');
expect(errorKindInfo('unknown').disposition).toBe('unknown');
});
it('flags documentation-only kinds as experimental', () => {
for (const k of ['addressRateLimit', 'priceTooFarFromOracle', 'invalidSize', 'openInterestCap', 'builderFee'] as const) {
expect(errorKindInfo(k).experimental).toBe(true);
}
expect(errorKindInfo('minNotional').experimental).toBe(false);
});
});