src/transport/client.test.ts
v0.2.0 · 10.4 KB
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);
});
});