Skip to content
markpaper

src/errors/edge-cases.test.ts

v0.3.0 · 10.9 KB

Download file
// 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
  });
});
All files