Skip to content
markpaper

src/transport/interpret.test.ts

v0.2.0 · 5.3 KB

Download file
import { describe, expect, it } from 'vitest';
import { NadoHttpError, NadoNetworkError, NadoRejection, NadoResponseParseError, NadoTimeoutError } from './errors.js';
import { hasUsableDigest, interpretExecuteError, NADO_ERROR_CODES } from './interpret.js';

const rej = (code: number, error = 'text') => new NadoRejection({ code, error, path: 'execute' });
const URL_ = 'https://gateway.example/v1/execute';

describe('NADO_ERROR_CODES', () => {
  it('lists the knowledge-base codes', () => {
    expect(NADO_ERROR_CODES).toEqual({
      ORDER_NOT_FOUND: 2020,
      REDUCE_ONLY_OVERSIZED: 2064,
      RESTING_REDUCE_ONLY: 2067,
      MARKET_NOT_TRADABLE: 2069,
      MARKET_POST_ONLY: 2117,
    });
  });
});

describe('interpretExecuteError: failure envelopes are final', () => {
  it('2117 on a resting DEFAULT -> resend as POST_ONLY; on an IOC -> do not retry (never rewrite a taker)', () => {
    const resting = interpretExecuteError(rej(2117, 'Market is in post-only mode'), {
      action: 'place',
      orderType: 'default',
    });
    expect(resting).toMatchObject({
      kind: 'postOnlyMarket',
      code: 2117,
      outcome: 'not-applied',
      advice: 'resend-as-post-only',
      rejection: true,
    });
    expect(resting.message).toBe('Market is in post-only mode');
    expect(interpretExecuteError(rej(2117), { orderType: 'ioc' }).advice).toBe('do-not-retry');
    expect(interpretExecuteError(rej(2117), { orderType: 'fok' }).advice).toBe('do-not-retry');
    expect(interpretExecuteError(rej(2117), { orderType: 'post_only' }).advice).toBe('do-not-retry');
    expect(interpretExecuteError(rej(2117)).advice).toBe('do-not-retry'); // order type unknown: stay conservative
  });

  it('2069 -> market blocked, do not retry (next tick)', () => {
    expect(interpretExecuteError(rej(2069))).toMatchObject({
      kind: 'marketBlocked',
      advice: 'do-not-retry',
      outcome: 'not-applied',
    });
  });

  it('2067 -> resting reduce-only, do not retry', () => {
    expect(interpretExecuteError(rej(2067))).toMatchObject({ kind: 'restingReduceOnly', advice: 'do-not-retry' });
  });

  it('2064 on a full close -> retry once with the exact position size; otherwise do not retry', () => {
    expect(interpretExecuteError(rej(2064), { fullClose: true })).toMatchObject({
      kind: 'reduceOnlyOversized',
      advice: 'retry-exact-position-size',
    });
    expect(interpretExecuteError(rej(2064), { fullClose: false })).toMatchObject({
      kind: 'reduceOnlyOversized',
      advice: 'do-not-retry',
    });
  });

  it('2020 on a cancel -> order gone (cancelled or filled): reconcile, never confirm the cancel', () => {
    expect(interpretExecuteError(rej(2020, 'OrderNotFound'), { action: 'cancel', idempotent: true })).toMatchObject({
      kind: 'orderNotFound',
      advice: 'reconcile',
      outcome: 'not-applied',
    });
  });

  it('unknown codes -> rejected, do not retry', () => {
    expect(interpretExecuteError(rej(9999, 'new thing'))).toMatchObject({
      kind: 'rejected',
      code: 9999,
      advice: 'do-not-retry',
      rejection: true,
    });
  });
});

describe('interpretExecuteError: transport', () => {
  it('429 -> retry for any action', () => {
    const f = interpretExecuteError(new NadoHttpError({ status: 429, url: URL_ }));
    expect(f).toMatchObject({ kind: 'rateLimited', outcome: 'not-applied', advice: 'retry', rejection: false });
  });

  it('5xx / timeout / network / parse -> unknown outcome; retry only when idempotent', () => {
    for (const err of [
      new NadoHttpError({ status: 502, url: URL_ }),
      new NadoTimeoutError({ timeoutMs: 10_000, url: URL_ }),
      new NadoNetworkError({ url: URL_, cause: 'x' }),
      new NadoResponseParseError({ status: 200, bodyText: 'html', url: URL_, cause: 1 }),
    ]) {
      expect(interpretExecuteError(err)).toMatchObject({
        kind: 'transportUnknown',
        outcome: 'unknown',
        advice: 'reconcile',
      });
      expect(interpretExecuteError(err, { idempotent: true })).toMatchObject({
        kind: 'transportUnknown',
        outcome: 'unknown',
        advice: 'retry',
      });
    }
  });

  it('other 4xx, validation errors -> not applied; aborts and unknown errors -> reconcile', () => {
    expect(interpretExecuteError(new NadoHttpError({ status: 403, url: URL_ }))).toMatchObject({
      kind: 'notApplied',
      advice: 'do-not-retry',
    });
    expect(interpretExecuteError(new RangeError('weight 400 exceeds'))).toMatchObject({
      kind: 'notApplied',
      advice: 'do-not-retry',
    });
    expect(interpretExecuteError(new TypeError('bad body'))).toMatchObject({ kind: 'notApplied' });
    expect(interpretExecuteError({ name: 'AbortError', message: 'aborted' })).toMatchObject({
      kind: 'transportUnknown',
      advice: 'reconcile',
    });
    expect(interpretExecuteError(new Error('something odd'))).toMatchObject({
      kind: 'transportUnknown',
      outcome: 'unknown',
      advice: 'reconcile',
    });
    expect(interpretExecuteError('string error').message).toBe('string error');
  });
});

describe('hasUsableDigest', () => {
  it('requires 0x + 64 hex', () => {
    expect(hasUsableDigest({ digest: `0x${'ab'.repeat(32)}` })).toBe(true);
    expect(hasUsableDigest({ digest: '0xab' })).toBe(false);
    expect(hasUsableDigest({})).toBe(false);
    expect(hasUsableDigest(null)).toBe(false);
  });
});
All files