Skip to content
markpaper

src/errors/interpret.test.ts

v0.3.0 · 4.7 KB

Download file
import { describe, expect, it } from 'vitest';
import { interpretExchangeError, recommendRetry } from './interpret.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('interpretExchangeError', () => {
  it('partial batch -> placed oids recovered', () => {
    const f = interpretExchangeError(
      api({ status: 'ok', response: { type: 'order', data: { statuses: [{ resting: { oid: 9 } }, { error: 'Order must have minimum value of $10.' }] } } }),
    );
    expect(f.type).toBe('partial');
    if (f.type !== 'partial' || f.batch.kind !== 'order') throw new Error('expected order batch');
    expect(f.batch.placedOids).toEqual([9]);
    expect(f.batch.results[1]).toMatchObject({ kind: 'minNotional' });
  });

  it('whole-action rejection -> rejected with kind', () => {
    const f = interpretExchangeError(
      api({ status: 'err', response: 'User or API Wallet 0x0000000000000000000000000000000000000000 does not exist.' }), // privacy-allow: placeholder address
    );
    expect(f).toMatchObject({ type: 'rejected', kind: 'agentNotAuthorized' });
    const dex = interpretExchangeError(api({ status: 'err', response: 'Abstraction transition not allowed' }));
    expect(dex).toMatchObject({ type: 'rejected', kind: 'abstractionAlreadyEnabled' });
  });

  it('TWAP inner error -> rejected', () => {
    const f = interpretExchangeError(
      api({ status: 'ok', response: { type: 'twapOrder', data: { status: { error: 'Invalid TWAP duration: 1 min(s)' } } } }),
    );
    expect(f).toMatchObject({ type: 'rejected', kind: 'invalidTwapDuration', message: 'Invalid TWAP duration: 1 min(s)' });
  });

  it('falls back to the error message for a non-string rejection body', () => {
    const f = interpretExchangeError(api({ status: 'err', response: { code: 1 } }, 'An unknown error occurred'));
    expect(f).toMatchObject({ type: 'rejected', message: 'An unknown error occurred', kind: 'unknown' });
  });

  it('validation, signing, transport', () => {
    expect(interpretExchangeError(named('ValidationError', 'Invalid length'))).toEqual({ type: 'validation', message: 'Invalid length' });
    expect(
      interpretExchangeError(named('AbstractWalletError', 'Failed to sign', { cause: { code: 4001, message: 'User rejected' } })),
    ).toMatchObject({ type: 'signing', userRejected: true });
    expect(interpretExchangeError(http(503, '503'))).toMatchObject({ type: 'transport', info: { outcome: 'unknown', httpStatus: 503 } });
    expect(interpretExchangeError(new Error('???'))).toMatchObject({ type: 'transport', info: { outcome: 'unknown' } });
  });
});

describe('recommendRetry', () => {
  it('429 is safe to retry even for an opening order', () => {
    expect(recommendRetry(http(429, '429'), { idempotent: false })).toBe('retry');
  });

  it('5xx / timeout: retry only idempotent actions, otherwise reconcile', () => {
    const timeout = http(undefined, 'Request timed out after 10000 ms', named('TimeoutError', 'timed out'));
    expect(recommendRetry(http(500, '500'), { idempotent: true })).toBe('retry');
    expect(recommendRetry(http(500, '500'), { idempotent: false })).toBe('reconcile');
    expect(recommendRetry(timeout, { idempotent: false })).toBe('reconcile');
    expect(recommendRetry(timeout, { idempotent: true })).toBe('retry');
  });

  it('other 4xx, validation and plain rejections: give up', () => {
    expect(recommendRetry(http(400, '400'), { idempotent: true })).toBe('give-up');
    expect(recommendRetry(named('ValidationError', 'x'), { idempotent: true })).toBe('give-up');
    expect(recommendRetry(api({ status: 'err', response: 'Insufficient margin' }), { idempotent: true })).toBe('give-up');
  });

  it('exchange "and retry" and rate-limit rejections are retryable', () => {
    expect(recommendRetry(api({ status: 'err', response: 'Busy, please wait and retry' }), { idempotent: false })).toBe('retry');
  });

  it('partial batches, aborts and unrecognized errors: reconcile', () => {
    const partial = api({ status: 'ok', response: { type: 'order', data: { statuses: [{ error: 'x' }] } } });
    expect(recommendRetry(partial, { idempotent: true })).toBe('reconcile');
    expect(recommendRetry(http(undefined, 'Request aborted', named('AbortError', 'aborted')), { idempotent: true })).toBe('reconcile');
    expect(recommendRetry(new Error('weird'), { idempotent: true })).toBe('reconcile');
  });
});
All files