src/errors/edge-cases.test.ts
v0.3.0 · 10.9 KB
// Edge cases found in review: numbers inside exchange texts, wrapper messages that contradict the
// real failure, float requested sizes, positionTpsl `s: '0'`, and ApiRequestError bodies that do not
// prove a rejection.
import { describe, expect, it } from 'vitest';
import { classifyExchangeError, errorKindInfo } from './classify.js';
import { addDecimal, compareDecimal, parseDecimal, toPlainString } from './decimal.js';
import { interpretExchangeError, recommendRetry } from './interpret.js';
import { parseOrderStatus, parseOrderStatuses } from './statuses.js';
import { describeTransportFailure, httpStatusOf, isRateLimitFailure, isTransientFailure } from './transport.js';
const named = (name: string, message: string, extra: object = {}) => Object.assign(new Error(message), { name }, extra);
const api = (response: unknown, message = 'api') => named('ApiRequestError', message, { response });
const http = (status: number | undefined, message: string, cause?: unknown) =>
Object.assign(new Error(message, cause === undefined ? undefined : { cause }), {
name: 'HttpRequestError',
response: status === undefined ? undefined : { status },
});
describe('429 inside exchange texts is not a rate limit', () => {
it.each([
['Post only order would have immediately matched, bbo was [email protected]. asset=5', 'postOnlyWouldCross'],
['Post only order would have immediately matched, bbo was [email protected]. asset=0', 'postOnlyWouldCross'],
['Order has invalid price. asset=429', 'invalidPrice'],
['Insufficient margin to place order. asset=110429', 'insufficientMargin'],
['Order must have minimum value of 10 USDC. asset=10429', 'minNotional'],
['Order could not immediately match against any resting orders. asset=429', 'iocNoMatch'],
['Order was never placed, already canceled, or filled. asset=429', 'orderNotFound'],
['Order has invalid price. px=1.429', 'invalidPrice'],
['value $429 rejected', 'unknown'],
['oid 429.0', 'unknown'],
['cloid 0x0000000000000000000000000000429', 'unknown'],
] as const)('%s -> %s', (text, kind) => {
expect(classifyExchangeError(text)).toBe(kind);
});
it.each(['429', '429 Too Many Requests', 'HTTP 429', 'status=429', 'statusCode: 429', 'failed (429)', 'Rate-limit hit'])(
'still matches a real rate limit: %s',
(text) => {
expect(classifyExchangeError(text)).toBe('rateLimited');
},
);
it('an ApiRequestError whose text carries a price with 429 is not retryable', () => {
const err = api({ status: 'err', response: 'Post only order would have immediately matched, bbo was [email protected]' });
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'not-applied', transient: false, rateLimited: false });
expect(recommendRetry(err, { idempotent: false })).toBe('give-up');
expect(isRateLimitFailure(new Error('bbo was [email protected]'))).toBe(false);
});
it('address-budget rejection: transient and rate limited, consistent with recommendRetry', () => {
const err = api({ status: 'err', response: 'Too many cumulative requests sent (10429 > 10000) for cumulative volume traded $0.' });
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'not-applied', transient: true, rateLimited: true });
expect(recommendRetry(err, { idempotent: false })).toBe('retry');
});
});
describe('typed signals inside the cause chain beat wrapper text', () => {
it('a rate-limit wrapper around a timeout is unknown, not "safe to retry"', () => {
const timeout = http(undefined, 'Request timed out after 10000 ms', named('TimeoutError', 'signal timed out'));
const wrapped = new Error('rate limit backoff exhausted', { cause: timeout });
expect(describeTransportFailure(wrapped)).toMatchObject({ outcome: 'unknown', reason: 'timeout' });
expect(recommendRetry(wrapped, { idempotent: false })).toBe('reconcile');
});
it('a "HTTP 429" wrapper around a real 503 reports 503', () => {
const wrapped = new Error('limiter: HTTP 429 earlier, then failed', { cause: http(503, '503 Service Unavailable') });
expect(httpStatusOf(wrapped)).toBe(503);
expect(describeTransportFailure(wrapped)).toMatchObject({ outcome: 'unknown', reason: 'http-5xx' });
});
it('plain text with both rate-limit and timeout wording is unknown', () => {
expect(describeTransportFailure(new Error('Too Many Requests; retry timed out'))).toMatchObject({ outcome: 'unknown', reason: 'timeout' });
});
it('a wallet failure caused by a network error is still "signing" (nothing was sent)', () => {
const err = named('AbstractWalletError', 'Failed to sign', { cause: new TypeError('fetch failed') });
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'not-applied', reason: 'signing' });
});
it('SDK network error: code deep in the chain', () => {
const err = http(undefined, 'Unknown HTTP request error: fetch failed', Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }));
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'network', transient: true });
});
it('isTransientFailure stays false for a plain 4xx wrapped in "retry" wording', () => {
expect(isTransientFailure(new Error('will not retry', { cause: http(403, '403 Forbidden') }))).toBe(false);
});
});
describe('ApiRequestError bodies that do not prove a rejection', () => {
it('status ok without statuses or a TWAP error -> transport unknown, reconcile', () => {
const err = api({ status: 'ok', response: { type: 'default' } }, 'An unknown error occurred');
expect(describeTransportFailure(err)).toMatchObject({ outcome: 'unknown', reason: 'unrecognized' });
expect(interpretExchangeError(err)).toMatchObject({ type: 'transport', info: { outcome: 'unknown' } });
expect(recommendRetry(err, { idempotent: false })).toBe('reconcile');
});
it('empty statuses -> partial batch with needsReconcile, never "rejected"', () => {
const err = api({ status: 'ok', response: { type: 'order', data: { statuses: [] } } });
const f = interpretExchangeError(err, { expectedCount: 1 });
expect(f).toMatchObject({ type: 'partial', batch: { needsReconcile: true, placedOids: [] } });
expect(recommendRetry(err, { idempotent: true })).toBe('reconcile');
});
it('no body at all -> unknown', () => {
expect(describeTransportFailure(named('ApiRequestError', 'x', { response: undefined }))).toMatchObject({ outcome: 'unknown' });
});
it('status err stays a whole-action rejection', () => {
const err = api({ status: 'err', response: 'Order has invalid price.' });
expect(interpretExchangeError(err)).toMatchObject({ type: 'rejected', kind: 'invalidPrice' });
});
});
describe('overfill check: tolerance, zero and negative requested sizes', () => {
const fill = (totalSz: string) => ({ filled: { oid: 7, totalSz, avgPx: '100' } });
it('a float requested size just below the sent string is not an overfill (tolerance 1e-9)', () => {
const requested = Math.floor(0.29 * 100) / 100; // 0.28 because of 28.999999999999996
expect(requested).toBe(0.28);
const remaining = 0.57 - 0.28; // a planner's remaining size for an order sent as "0.29"
expect(String(remaining)).toBe('0.2899999999999999');
expect(parseOrderStatus(fill('0.29'), remaining).status).toBe('filled');
expect(parseOrderStatus(fill('2.9'), 4.35 - 1.45).status).toBe('filled'); // 2.8999999999999995
});
it('flags a fill more than 1e-9 above the requested size', () => {
expect(parseOrderStatus(fill('0.290000001'), '0.29').status).toBe('filled');
expect(parseOrderStatus(fill('0.2900000011'), '0.29')).toMatchObject({ status: 'invalid', reason: 'overfill' });
expect(parseOrderStatus(fill('0.3'), '0.29')).toMatchObject({ reason: 'overfill' });
});
it("positionTpsl s: '0' tracks the whole position: any fill is legitimate", () => {
expect(parseOrderStatus(fill('12.5'), '0').status).toBe('filled');
expect(parseOrderStatus(fill('12.5'), 0).status).toBe('filled');
expect(parseOrderStatus(fill('12.5'), '0.0').status).toBe('filled');
const res = parseOrderStatuses([fill('1'), fill('2')], { requestedSizes: ['0', '0'] });
expect(res.map((r) => r.status)).toEqual(['filled', 'filled']);
});
it('a negative or unparsable requested size disables the check instead of rejecting every fill', () => {
expect(parseOrderStatus(fill('1'), -1).status).toBe('filled');
expect(parseOrderStatus(fill('1'), 'abc').status).toBe('filled');
expect(parseOrderStatus(fill('1'), Number.NaN).status).toBe('filled');
});
it('large sizes compare exactly (no float rounding)', () => {
expect(parseOrderStatus(fill('123456789.123456789'), '123456789.123456787')).toMatchObject({ reason: 'overfill' });
expect(parseOrderStatus(fill('123456789.1234567891'), '123456789.123456789').status).toBe('filled');
});
});
describe('oids and fills on real-world shapes', () => {
it('rejects oids beyond 2^53 and zero / negative oids', () => {
expect(parseOrderStatus({ resting: { oid: 2 ** 53 } })).toMatchObject({ reason: 'bad-oid' });
expect(parseOrderStatus({ resting: { oid: '9007199254740993' } })).toMatchObject({ reason: 'bad-oid' });
expect(parseOrderStatus({ resting: { oid: 0 } })).toMatchObject({ reason: 'bad-oid' });
expect(parseOrderStatus({ resting: { oid: -5 } })).toMatchObject({ reason: 'bad-oid' });
expect(parseOrderStatus({ resting: { oid: 12.5 } })).toMatchObject({ reason: 'bad-oid' });
expect(parseOrderStatus({ resting: { oid: Number.MAX_SAFE_INTEGER } })).toMatchObject({ status: 'resting' });
});
it('keeps exchange strings for fills with trailing zeros and HIP-3 sized ids', () => {
expect(parseOrderStatus({ filled: { oid: 110001, totalSz: '0.0100', avgPx: '87.00', cloid: '0xABCDEF0123456789ABCDEF0123456789' } })).toEqual({
status: 'filled',
oid: 110001,
totalSz: '0.0100',
avgPx: '87.00',
cloid: '0xabcdef0123456789abcdef0123456789',
});
});
it('a malformed cloid is dropped, not trusted', () => {
expect(parseOrderStatus({ resting: { oid: 1, cloid: '0x1234' } })).toEqual({ status: 'resting', oid: 1 });
});
});
describe('addDecimal', () => {
const d = (x: unknown) => {
const r = parseDecimal(x);
if (!r) throw new Error('unparsable');
return r;
};
it('adds exactly across scales and signs', () => {
expect(toPlainString(addDecimal(d('0.1'), d('0.2')))).toBe('0.3');
expect(toPlainString(addDecimal(d('1'), d('-1.000000001')))).toBe('-0.000000001');
expect(compareDecimal(addDecimal(d('0.29'), { units: 1n, scale: 9 }), d('0.290000001'))).toBe(0);
});
});
describe('experimental flags for unverified exchange error wording', () => {
it('IoC no-match and reduce-only-would-increase texts are not verified live', () => {
expect(errorKindInfo('iocNoMatch').experimental).toBe(true);
expect(errorKindInfo('reduceOnlyWouldIncrease').experimental).toBe(true);
expect(errorKindInfo('postOnlyWouldCross').experimental).toBe(false); // rejection confirmed on mainnet
});
});