Skip to content
markpaper

src/transport/errors.test.ts

v0.2.0 · 6.4 KB

Download file
import { describe, expect, it } from 'vitest';
import { parseEnvelope, unwrapEnvelope } from './envelope.js';
import {
  isRateLimitError,
  isTransientError,
  MAX_ERROR_BODY_CHARS,
  NadoEnvelopeError,
  NadoHttpError,
  NadoNetworkError,
  NadoNetworkMismatchError,
  NadoRejection,
  NadoResponseParseError,
  NadoTimeoutError,
  statusOf,
} from './errors.js';

const URL_ = 'https://gateway.example/v1/query';

describe('parseEnvelope / unwrapEnvelope', () => {
  it('unwraps success and failure envelopes (live shapes)', () => {
    expect(
      parseEnvelope({ status: 'success', data: { digest: '0xabc' }, request_type: 'execute_place_order' }),
    ).toEqual({
      ok: true,
      data: { digest: '0xabc' },
      requestType: 'execute_place_order',
    });
    expect(
      parseEnvelope(
        {
          status: 'failure',
          error: 'Market is in post-only mode. Only post-only orders are accepted',
          error_code: 2117,
        },
        'place_order',
      ),
    ).toEqual({
      ok: false,
      code: 2117,
      error: 'Market is in post-only mode. Only post-only orders are accepted',
      requestType: 'place_order',
    });
    expect(parseEnvelope({ status: 'failure' })).toMatchObject({ ok: false, code: -1, error: 'unknown error' });
    expect(
      parseEnvelope({ status: 'failure', error_code: '2069', error: 'Trading is blocked for this market' }),
    ).toMatchObject({ code: 2069 });
  });

  it('throws NadoEnvelopeError on anything that is not an envelope', () => {
    for (const body of [null, 'ok', 42, [], { data: 1 }, { status: 'weird' }]) {
      expect(() => parseEnvelope(body)).toThrow(NadoEnvelopeError);
    }
  });

  it('unwrapEnvelope returns data or throws a NadoRejection with code, text, path', () => {
    expect(unwrapEnvelope({ status: 'success', data: 7 }, 'query')).toBe(7);
    try {
      unwrapEnvelope({ status: 'failure', error: 'no', error_code: 2067, request_type: 'place_order' }, 'execute');
      expect.unreachable();
    } catch (e) {
      expect(e).toBeInstanceOf(NadoRejection);
      const r = e as NadoRejection;
      expect(r.code).toBe(2067);
      expect(r.errorText).toBe('no');
      expect(r.path).toBe('execute');
      expect(r.requestType).toBe('place_order');
      expect(r.message).toContain('2067');
    }
  });
});

describe('error classes', () => {
  it('carry status, truncate bodies and keep causes', () => {
    const http = new NadoHttpError({
      status: 502,
      bodyText: 'x'.repeat(MAX_ERROR_BODY_CHARS + 10),
      url: URL_,
      requestType: 'symbols',
    });
    expect(http.status).toBe(502);
    expect(http.bodyText.length).toBe(MAX_ERROR_BODY_CHARS + 3);
    expect(http.message).toContain('502');
    const parse = new NadoResponseParseError({
      status: 200,
      bodyText: 'not json',
      url: URL_,
      cause: new SyntaxError('x'),
    });
    expect(parse.transientResponseBody).toBe(true);
    expect(parse.cause).toBeInstanceOf(SyntaxError);
    const timeout = new NadoTimeoutError({ timeoutMs: 10_000, url: URL_, requestType: 'orders' });
    expect(timeout.message).toContain('10000');
    const net = new NadoNetworkError({ url: URL_, cause: new Error('ECONNRESET') });
    expect(net.message).toContain('ECONNRESET');
    const mismatch = new NadoNetworkMismatchError({ expectedChainId: 57073, reportedChainId: 763373 });
    expect(mismatch.message).toContain('763373');
    expect(mismatch.expectedChainId).toBe(57073);
    const env = new NadoEnvelopeError({ body: { a: 1 }, requestType: 'x' });
    expect(env.message).toContain('{"a":1}');
  });
});

describe('statusOf / isRateLimitError', () => {
  it('reads numeric statuses and HTTP nnn in messages', () => {
    expect(statusOf(new NadoHttpError({ status: 429, url: URL_ }))).toBe(429);
    expect(statusOf({ statusCode: 503 })).toBe(503);
    expect(statusOf(new Error('nado orders HTTP 502'))).toBe(502);
    expect(statusOf('x')).toBeUndefined();
    expect(statusOf(null)).toBeUndefined();
  });

  it('429 is a rate limit; a rejection whose text contains 429 is not', () => {
    expect(isRateLimitError(new NadoHttpError({ status: 429, url: URL_ }))).toBe(true);
    expect(isRateLimitError(new Error('too many requests'))).toBe(true);
    expect(isRateLimitError(new Error('rate limit'))).toBe(true);
    expect(isRateLimitError(new Error('oid 14290 rejected'))).toBe(false);
    expect(isRateLimitError(new NadoRejection({ code: 2117, error: 'px 429.5 post only', path: 'execute' }))).toBe(
      false,
    );
    expect(isRateLimitError(new NadoHttpError({ status: 400, bodyText: '429', url: URL_ }))).toBe(false);
  });
});

describe('isTransientError', () => {
  it('true for 429, 408, 5xx, timeout, network, parse and envelope errors', () => {
    expect(isTransientError(new NadoHttpError({ status: 429, url: URL_ }))).toBe(true);
    expect(isTransientError(new NadoHttpError({ status: 408, url: URL_ }))).toBe(true);
    expect(isTransientError(new NadoHttpError({ status: 503, url: URL_ }))).toBe(true);
    expect(isTransientError(new NadoTimeoutError({ timeoutMs: 1, url: URL_ }))).toBe(true);
    expect(isTransientError(new NadoNetworkError({ url: URL_, cause: 'x' }))).toBe(true);
    expect(isTransientError(new NadoResponseParseError({ status: 200, bodyText: '', url: URL_, cause: 1 }))).toBe(true);
    expect(isTransientError(new NadoEnvelopeError({ body: 'plain text' }))).toBe(true);
    expect(isTransientError(new Error('fetch failed'))).toBe(true);
    expect(isTransientError(new Error('wrapped', { cause: new Error('ECONNRESET') }))).toBe(true);
    expect(isTransientError({ name: 'TimeoutError' })).toBe(true);
    expect(isTransientError({ status: 200, message: 'html' })).toBe(true);
    expect(isTransientError('socket hang up')).toBe(true);
  });

  it('false for rejections, other 4xx and aborts', () => {
    expect(isTransientError(new NadoRejection({ code: 2117, error: 'timeout in text', path: 'execute' }))).toBe(false);
    expect(isTransientError(new NadoHttpError({ status: 403, url: URL_ }))).toBe(false);
    expect(isTransientError(new NadoHttpError({ status: 400, url: URL_ }))).toBe(false);
    expect(isTransientError({ name: 'AbortError', message: 'aborted' })).toBe(false);
    expect(isTransientError(new Error('bad address'))).toBe(false);
    expect(isTransientError(null)).toBe(false);
    expect(
      isTransientError(new Error('outer', { cause: new NadoRejection({ code: 1, error: 'network', path: 'query' }) })),
    ).toBe(false);
  });
});
All files