Skip to content
markpaper

src/errors/classify.test.ts

v0.3.0 · 5.6 KB

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