Skip to content
markpaper

src/transport/client.test.ts

v0.2.0 · 10.4 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  createGatewayClient,
  DEFAULT_GATEWAY_TIMEOUT_MS,
  type FetchLike,
  type GatewayAttemptEvent,
  resolveGatewayUrl,
} from './client.js';
import {
  NadoEnvelopeError,
  NadoHttpError,
  NadoNetworkError,
  NadoRejection,
  NadoResponseParseError,
  NadoTimeoutError,
} from './errors.js';
import { createWeightThrottle, type WeightThrottleOptions } from './throttle.js';
import { GATEWAY_URL } from './types.js';

interface Call {
  url: string;
  init: RequestInit;
  body: unknown;
}

function stubFetch(handler: (call: Call, n: number) => Response | Promise<Response> | Error): {
  fetch: FetchLike;
  calls: Call[];
} {
  const calls: Call[] = [];
  const fetch: FetchLike = async (url, init) => {
    const call = { url, init, body: JSON.parse(String(init.body)) };
    calls.push(call);
    const out = await handler(call, calls.length);
    if (out instanceof Error) throw out;
    return out;
  };
  return { fetch, calls };
}

const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const success = (data: unknown) => json({ status: 'success', data, request_type: 'x' });
const THROTTLE_CONFIG: WeightThrottleOptions = {
  queriesPerMinute: 600,
  queryBurst: 20,
  executesPerMinute: 300,
  executeBurst: 40,
  maxConcurrent: 2,
};

describe('resolveGatewayUrl', () => {
  it('defaults to mainnet, accepts overrides and refuses a network mix', () => {
    expect(resolveGatewayUrl()).toBe(GATEWAY_URL.mainnet);
    expect(resolveGatewayUrl({ network: 'testnet' })).toBe(GATEWAY_URL.testnet);
    expect(resolveGatewayUrl({ baseUrl: 'https://proxy.example/v1/' })).toBe('https://proxy.example/v1');
    expect(() => resolveGatewayUrl({ network: 'mainnet', baseUrl: GATEWAY_URL.testnet })).toThrow(/mix/);
    expect(() => resolveGatewayUrl({ network: 'testnet', baseUrl: GATEWAY_URL.mainnet })).toThrow(/mix/);
    expect(() => resolveGatewayUrl({ baseUrl: 'ftp://x' })).toThrow(/invalid/);
    expect(() => resolveGatewayUrl({ baseUrl: 'not a url' })).toThrow(/invalid/);
  });
});

describe('createGatewayClient.query', () => {
  it('requires an explicit throttle policy or explicit external throttling', () => {
    expect(() => (createGatewayClient as unknown as () => unknown)()).toThrow(
      'createGatewayClient requires an explicit throttle',
    );
  });

  it('POSTs to /query with a JSON body, no Accept-Encoding, and unwraps data', async () => {
    const { fetch, calls } = stubFetch(() => success({ chain_id: 57073 }));
    const client = createGatewayClient({ fetch, retry: false, throttle: null });
    expect(client.network).toBe('mainnet');
    expect(client.url).toBe(GATEWAY_URL.mainnet);
    const data = await client.query<{ chain_id: number }>({ type: 'contracts' });
    expect(data.chain_id).toBe(57073);
    expect(calls).toHaveLength(1);
    expect(calls[0]?.url).toBe(`${GATEWAY_URL.mainnet}/query`);
    expect(calls[0]?.init.method).toBe('POST');
    const headers = calls[0]?.init.headers as Record<string, string>;
    expect(headers['content-type']).toBe('application/json');
    expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain('accept-encoding');
    expect(calls[0]?.body).toEqual({ type: 'contracts' });
    expect(calls[0]?.init.signal).toBeInstanceOf(AbortSignal);
  });

  it('throws NadoRejection on a failure envelope (bad params) without retrying', async () => {
    const { fetch, calls } = stubFetch(() => json({ status: 'failure', error: 'bad', error_code: 1001 }));
    const client = createGatewayClient({ fetch, retry: { retries: 3, baseDelayMs: 1 }, throttle: null });
    await expect(client.query({ type: 'orders', product_ids: [2] })).rejects.toBeInstanceOf(NadoRejection);
    expect(calls).toHaveLength(1);
  });

  it('retries transient failures (5xx, plain text, network) and returns the first success', async () => {
    const { fetch, calls } = stubFetch((_c, n) => {
      if (n === 1) return json('oops', 502);
      if (n === 2) return new Response('plain text error', { status: 200 });
      if (n === 3) return new TypeError('fetch failed');
      return success({ ok: true });
    });
    const client = createGatewayClient({ fetch, retry: { retries: 3, baseDelayMs: 1, jitter: 0 }, throttle: null });
    await expect(client.query({ type: 'symbols' })).resolves.toEqual({ ok: true });
    expect(calls).toHaveLength(4);
  });

  it('maps failures to typed errors when retries are off', async () => {
    const http = createGatewayClient({ fetch: stubFetch(() => json('x', 403)).fetch, retry: false, throttle: null });
    await expect(http.query({ type: 'symbols' })).rejects.toBeInstanceOf(NadoHttpError);
    await expect(http.query({ type: 'symbols' })).rejects.toMatchObject({ status: 403 });
    const text = createGatewayClient({
      fetch: stubFetch(() => new Response('nope', { status: 200 })).fetch,
      retry: false,
      throttle: null,
    });
    await expect(text.query({ type: 'symbols' })).rejects.toBeInstanceOf(NadoResponseParseError);
    const env = createGatewayClient({ fetch: stubFetch(() => json({ foo: 1 })).fetch, retry: false, throttle: null });
    await expect(env.query({ type: 'symbols' })).rejects.toBeInstanceOf(NadoEnvelopeError);
    const net = createGatewayClient({ fetch: stubFetch(() => new Error('ECONNRESET')).fetch, retry: false, throttle: null });
    await expect(net.query({ type: 'symbols' })).rejects.toBeInstanceOf(NadoNetworkError);
  });

  it('times out with NadoTimeoutError', async () => {
    const fetch: FetchLike = (_u, init) =>
      new Promise((_r, reject) => {
        init.signal?.addEventListener('abort', () => reject(init.signal?.reason));
      });
    const client = createGatewayClient({ fetch, retry: false, timeoutMs: 20, throttle: null });
    await expect(client.query({ type: 'symbols' })).rejects.toBeInstanceOf(NadoTimeoutError);
    expect(DEFAULT_GATEWAY_TIMEOUT_MS).toBe(10_000);
    expect(() => createGatewayClient({ fetch, timeoutMs: 0, throttle: null })).toThrow(RangeError);
    await expect(client.query({ type: 'symbols' }, { timeoutMs: -1 })).rejects.toThrow(RangeError);
  });

  it('validates the body and pays the derived weight into the throttle', async () => {
    const throttle = createWeightThrottle(THROTTLE_CONFIG);
    const client = createGatewayClient({ fetch: stubFetch(() => success({})).fetch, retry: false, throttle });
    await expect(client.query({ type: '' })).rejects.toThrow(TypeError);
    await client.query({ type: 'orders', sender: '0x', product_ids: [2, 8, 18] }, { label: 'orders' });
    expect(throttle.stats().queries.totalWeight).toBe(6);
    await client.query({ type: 'symbols' }, { weight: 10 });
    expect(throttle.stats().queries.totalWeight).toBe(16);
    expect(throttle.drainCounts()).toEqual([
      { label: 'orders', n: 1 },
      { label: 'symbols', n: 1 },
    ]);
  });

  it('reports attempts to onAttempt and swallows handler errors', async () => {
    const events: GatewayAttemptEvent[] = [];
    const client = createGatewayClient({
      fetch: stubFetch((_c, n) => (n === 1 ? json('x', 503) : success(1))).fetch,
      retry: { retries: 1, baseDelayMs: 1, jitter: 0 },
      throttle: null,
      onAttempt: (e) => {
        events.push(e);
        throw new Error('handler');
      },
    });
    await expect(client.query({ type: 'symbols' })).resolves.toBe(1);
    expect(events.map((e) => [e.attempt, e.ok, e.status])).toEqual([
      [0, false, 503],
      [1, true, 200],
    ]);
    expect(events[0]?.path).toBe('query');
    expect(events[0]?.requestType).toBe('symbols');
  });
});

describe('createGatewayClient.execute', () => {
  const placeBody = { place_order: { product_id: 2, order: {}, signature: '0x' } };

  it('POSTs to /execute and returns the digest data', async () => {
    const { fetch, calls } = stubFetch(() => success({ digest: `0x${'a'.repeat(64)}` }));
    const client = createGatewayClient({ fetch, retry: false, throttle: null });
    const data = await client.execute<{ digest: string }>(placeBody);
    expect(data.digest).toBe(`0x${'a'.repeat(64)}`);
    expect(calls[0]?.url).toBe(`${GATEWAY_URL.mainnet}/execute`);
    await expect(client.execute({})).rejects.toThrow(TypeError);
    await expect(client.execute({ a: {}, b: {} })).rejects.toThrow(TypeError);
  });

  it('a failure envelope is a final NadoRejection: never retried', async () => {
    const { fetch, calls } = stubFetch(() =>
      json({ status: 'failure', error: 'Market is in post-only mode', error_code: 2117 }),
    );
    const client = createGatewayClient({ fetch, retry: { retries: 3, baseDelayMs: 1 }, throttle: null });
    await expect(client.execute(placeBody, { idempotent: true })).rejects.toMatchObject({
      code: 2117,
      path: 'execute',
    });
    expect(calls).toHaveLength(1);
  });

  it('non-idempotent executes retry only on 429; idempotent ones also on 5xx/timeouts', async () => {
    const open = stubFetch((_c, n) => (n === 1 ? json('x', 429) : n === 2 ? json('x', 503) : success({})));
    const client = createGatewayClient({
      fetch: open.fetch,
      retry: { retries: 3, baseDelayMs: 1, jitter: 0 },
      throttle: null,
    });
    await expect(client.execute(placeBody)).rejects.toBeInstanceOf(NadoHttpError); // 429 retried, 503 final
    expect(open.calls).toHaveLength(2);

    const cancel = stubFetch((_c, n) => (n === 1 ? json('x', 503) : success({ cancelled_orders: [] })));
    const client2 = createGatewayClient({
      fetch: cancel.fetch,
      retry: { retries: 3, baseDelayMs: 1, jitter: 0 },
      throttle: null,
    });
    await expect(client2.execute({ cancel_orders: {} }, { idempotent: true })).resolves.toEqual({
      cancelled_orders: [],
    });
    expect(cancel.calls).toHaveLength(2);
  });

  it('pays execute weight into the execute bucket (place 1, link_signer 30)', async () => {
    const throttle = createWeightThrottle(THROTTLE_CONFIG);
    const client = createGatewayClient({ fetch: stubFetch(() => success({})).fetch, retry: false, throttle });
    await client.execute(placeBody);
    await client.execute({ link_signer: {} });
    expect(throttle.stats().executes.totalWeight).toBe(31);
    expect(throttle.stats().queries.totalWeight).toBe(0);
  });

  it('works without a throttle', async () => {
    const client = createGatewayClient({ fetch: stubFetch(() => success(5)).fetch, retry: false, throttle: null });
    expect(client.throttle).toBeNull();
    await expect(client.query({ type: 'contracts' })).resolves.toBe(5);
  });
});
All files