Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion packages/sdk/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,12 @@ test('getProfile returns null on a 404 (not found)', async () => {
});

test('getProfile throws ApiError carrying the status on a server error', async () => {
// `maxRetries: 0` keeps this about the error mapping — retry behaviour on 5xx
// has its own tests below, and the default would add backoff delay here.
const client = new SignetClient({
baseUrl: 'https://signet.dev',
fetch: mockFetch({}, { ok: false, status: 500 }),
maxRetries: 0,
});
await assert.rejects(
() => client.getProfile('x'),
Expand All @@ -60,7 +63,7 @@ test('getProfile throws NetworkError when the request never reaches the server',
const failing = (async () => {
throw new Error('offline');
}) as unknown as typeof fetch;
const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: failing });
const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: failing, maxRetries: 0 });
await assert.rejects(
() => client.getProfile('x'),
(err: unknown) => err instanceof NetworkError,
Expand Down Expand Up @@ -143,3 +146,104 @@ test('countRegistryEntries defaults to zero when the response is null', async ()
const res = await client.countRegistryEntries();
assert.deepEqual(res, { count: 0 });
});

// ── Timeout + retry ─────────────────────────────────────────────────────────

test('retries a 5xx and returns the result from a later attempt', async () => {
let calls = 0;
const flaky = (async () => {
calls++;
if (calls < 3) return { ok: false, status: 502, json: async () => ({}) } as Response;
return { ok: true, status: 200, json: async () => ({ result: { data: ['aquawolf'] } }) } as Response;
}) as unknown as typeof fetch;

const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: flaky, maxRetries: 3 });
assert.deepEqual(await client.listHandles(), ['aquawolf']);
assert.equal(calls, 3);
});

test('surfaces ApiError once the 5xx retries are exhausted', async () => {
let calls = 0;
const alwaysDown = (async () => {
calls++;
return { ok: false, status: 503, json: async () => ({}) } as Response;
}) as unknown as typeof fetch;

const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: alwaysDown, maxRetries: 1 });
await assert.rejects(
() => client.listHandles(),
(err: unknown) => err instanceof ApiError && err.status === 503,
);
assert.equal(calls, 2, 'initial attempt plus one retry');
});

test('does not retry a 4xx — it is an answer, not a glitch', async () => {
let calls = 0;
const badRequest = (async () => {
calls++;
return { ok: false, status: 400, json: async () => ({}) } as Response;
}) as unknown as typeof fetch;

const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: badRequest, maxRetries: 3 });
await assert.rejects(() => client.listHandles(), (err: unknown) => err instanceof ApiError);
assert.equal(calls, 1);
});

test('does not retry a 404', async () => {
let calls = 0;
const missing = (async () => {
calls++;
return { ok: false, status: 404, json: async () => ({}) } as Response;
}) as unknown as typeof fetch;

const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: missing, maxRetries: 3 });
assert.equal(await client.getProfile('ghost'), null);
assert.equal(calls, 1);
});

test('aborts a stalled request and rejects with NetworkError', async () => {
// Resolves only if aborted — i.e. the client, not the server, ends the call.
const hanging = (async (_url: string | URL | Request, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new DOMException('The operation was aborted', 'AbortError')),
);
})) as unknown as typeof fetch;

const client = new SignetClient({
baseUrl: 'https://signet.dev',
fetch: hanging,
timeoutMs: 20,
maxRetries: 0,
});
await assert.rejects(
() => client.listHandles(),
(err: unknown) => err instanceof NetworkError && /timed out after 20ms/.test((err as Error).message),
);
});

test('retries a transient network failure and then succeeds', async () => {
let calls = 0;
const flaky = (async () => {
calls++;
if (calls === 1) throw new TypeError('fetch failed');
return { ok: true, status: 200, json: async () => ({ result: { data: { count: 7 } } }) } as Response;
}) as unknown as typeof fetch;

const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: flaky, maxRetries: 2 });
assert.deepEqual(await client.countRegistryEntries(), { count: 7 });
assert.equal(calls, 2);
});

test('timeout and retry options default when not supplied', async () => {
let seenSignal: AbortSignal | undefined;
const spy = (async (_url: string | URL | Request, init?: RequestInit) => {
seenSignal = init?.signal ?? undefined;
return { ok: true, status: 200, json: async () => ({ result: { data: [] } }) } as Response;
}) as unknown as typeof fetch;

const client = new SignetClient({ baseUrl: 'https://signet.dev', fetch: spy });
await client.listHandles();
assert.ok(seenSignal instanceof AbortSignal, 'every request carries an abort signal');
assert.equal(seenSignal?.aborted, false);
});
94 changes: 80 additions & 14 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@ export interface SignetClientOptions {
baseUrl?: string;
/** Optional fetch implementation (for tests / non-browser runtimes). */
fetch?: typeof fetch;
/**
* Per-attempt timeout in milliseconds (default 10000). An attempt that takes
* longer is aborted via `AbortController`; once retries are exhausted the
* call rejects with `NetworkError` rather than hanging on a stalled socket.
*/
timeoutMs?: number;
/**
* How many times to retry a failed request (default 2, so up to 3 attempts).
* Retries cover 5xx responses and network/timeout failures, with exponential
* backoff (200ms, 400ms, 800ms …, capped at 5s). A 404 and any other 4xx are
* answers, not glitches, so they are never retried. Set to 0 to disable.
*/
maxRetries?: number;
}

/** Per-attempt timeout, in milliseconds. */
const DEFAULT_TIMEOUT_MS = 10_000;
/** Retries after the initial attempt. */
const DEFAULT_MAX_RETRIES = 2;
/** Ceiling on a single backoff delay, in milliseconds. */
const MAX_BACKOFF_MS = 5_000;

/** An `AbortController`-aborted fetch, across browsers and Node 17+. */
function isAbortError(err: unknown): boolean {
return err instanceof Error && err.name === 'AbortError';
}

/**
Expand All @@ -14,43 +39,84 @@ export interface SignetClientOptions {
* Talks to the tRPC endpoint over its HTTP GET form
* (`/api/trpc/{procedure}?input=…`) so external integrators don't need the
* tRPC client library.
*
* Every request is bounded by `timeoutMs` and retried up to `maxRetries` times
* on 5xx / network failures — see `SignetClientOptions` for the defaults.
*/
export class SignetClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number;
private readonly maxRetries: number;

constructor(options: SignetClientOptions = {}) {
this.baseUrl = (options.baseUrl ?? 'https://signet.dev').replace(/\/$/, '');
this.fetchImpl = options.fetch ?? globalThis.fetch;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
if (!this.fetchImpl) {
throw new Error('[signet] no fetch implementation available; pass options.fetch');
}
}

/** Exponential backoff between attempts: 200ms, 400ms, 800ms …, capped. */
private backoff(attempt: number): Promise<void> {
const delay = Math.min(200 * 2 ** attempt, MAX_BACKOFF_MS);
return new Promise((resolve) => setTimeout(resolve, delay));
}

/**
* Issues a tRPC GET query. Throws a typed error the caller can discriminate:
* `NetworkError` when the request never reached the server, `NotFoundError`
* on a 404, or `ApiError` (carrying the status) on any other non-OK response.
* `NetworkError` when the request never reached the server (including a
* timeout), `NotFoundError` on a 404, or `ApiError` (carrying the status) on
* any other non-OK response. Transient failures are retried first; the error
* that surfaces is the one from the final attempt.
*/
private async query<T>(procedure: string, input: unknown): Promise<T | null> {
const url = `${this.baseUrl}/api/trpc/${procedure}?input=${encodeURIComponent(
JSON.stringify(input),
)}`;

let res: Response;
try {
res = await this.fetchImpl(url, { headers: { accept: 'application/json' } });
} catch (cause) {
throw new NetworkError(`request to ${procedure} failed`, { cause });
}
for (let attempt = 0; ; attempt++) {
const controller = new AbortController();
// The timer covers reading the body too, not just the response headers —
// a server that streams one byte an hour is as stalled as a dead socket.
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
let res: Response;
try {
res = await this.fetchImpl(url, {
headers: { accept: 'application/json' },
signal: controller.signal,
});
} catch (cause) {
if (attempt < this.maxRetries) {
await this.backoff(attempt);
continue;
}
throw new NetworkError(
isAbortError(cause)
? `request to ${procedure} timed out after ${this.timeoutMs}ms`
: `request to ${procedure} failed`,
{ cause },
);
}

if (!res.ok) {
if (res.status === 404) throw new NotFoundError(`${procedure} not found`);
throw new ApiError(`${procedure} failed with status ${res.status}`, res.status);
}
if (!res.ok) {
if (res.status === 404) throw new NotFoundError(`${procedure} not found`);
if (res.status >= 500 && attempt < this.maxRetries) {
await this.backoff(attempt);
continue;
}
throw new ApiError(`${procedure} failed with status ${res.status}`, res.status);
}

const body = (await res.json()) as { result?: { data?: T } };
return body.result?.data ?? null;
const body = (await res.json()) as { result?: { data?: T } };
return body.result?.data ?? null;
} finally {
clearTimeout(timer);
}
}
}

/**
Expand Down