undici HTTP client with p-retry backoff, tested with MockAgent: working versions (tested 2026-10-15)
A JSON client on undici.request wrapped in p-retry that retries 429, 5xx and network errors but aborts on other 4xx, tested offline with undici MockAgent, disableNetConnect and assertNoPendingInterceptors.
passed5 passed · 0 failed2026-10-15 15:05 UTC- Status
- passed
- Last passed
- 2026-10-15 15:05 UTC
- Tests
- 5 / 5
- Language
- TypeScript
- Runtime
- node v22.23.3
pinned versions4
undici@8.12.1p-retry@8.0.1tsx@4.23.15@types/node@26.6.5
run it
npm install --no-audit --no-fund --ignore-scripts=false npm test
test environment
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38755782594
The code
Exactly the files that passed. Your agent can pull them with get_recipe("undici-mockagent-p-retry") (install), or see them on GitHub.
package.json
{
"name": "undici-mockagent-p-retry",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"undici": "8.12.1",
"p-retry": "8.0.1"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "26.6.5"
}
}
src/client.ts
import { request, type Dispatcher } from "undici";
import pRetry, { AbortError } from "p-retry";
export class HttpError extends Error {
constructor(public status: number, public body: string) { super(`HTTP ${status}`); }
}
export type ClientOptions = { baseUrl: string; dispatcher?: Dispatcher; retries?: number; minTimeout?: number };
/** A JSON API client on undici.request with p-retry: retries 429/5xx and network errors, never other 4xx. */
export function createClient({ baseUrl, dispatcher, retries = 3, minTimeout = 10 }: ClientOptions) {
const attempts: number[] = [];
async function getJson<T>(path: string): Promise<T> {
return pRetry(async (attempt) => {
attempts.push(attempt);
const res = await request(new URL(path, baseUrl), { method: "GET", dispatcher, headers: { accept: "application/json" } });
const text = await res.body.text();
if (res.statusCode >= 200 && res.statusCode < 300) return JSON.parse(text) as T;
const err = new HttpError(res.statusCode, text);
// AbortError stops retrying immediately: a 404 will not get better by asking again.
if (res.statusCode !== 429 && res.statusCode < 500) throw new AbortError(err);
throw err;
}, { retries, minTimeout, factor: 2, randomize: false });
}
return { getJson, attempts };
}
test/client.test.ts
import { test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { MockAgent } from "undici";
import { createClient, HttpError } from "../src/client.ts";
const BASE = "https://api.example.test";
let agent: MockAgent;
beforeEach(() => {
agent = new MockAgent();
agent.disableNetConnect(); // any unmocked request fails instead of reaching the network
});
afterEach(async () => {
agent.assertNoPendingInterceptors();
await agent.close();
});
test("returns parsed JSON on the first try", async () => {
agent.get(BASE).intercept({ path: "/users/1", method: "GET" }).reply(200, { id: 1, name: "Ada" }, { headers: { "content-type": "application/json" } });
const c = createClient({ baseUrl: BASE, dispatcher: agent });
assert.deepEqual(await c.getJson("/users/1"), { id: 1, name: "Ada" });
assert.deepEqual(c.attempts, [1]);
});
test("retries 503 and 429 then succeeds", async () => {
const pool = agent.get(BASE);
pool.intercept({ path: "/flaky" }).reply(503, "down");
pool.intercept({ path: "/flaky" }).reply(429, "slow down");
pool.intercept({ path: "/flaky" }).reply(200, { ok: true });
const c = createClient({ baseUrl: BASE, dispatcher: agent });
assert.deepEqual(await c.getJson("/flaky"), { ok: true });
assert.deepEqual(c.attempts, [1, 2, 3]);
});
test("does not retry a 404", async () => {
agent.get(BASE).intercept({ path: "/missing" }).reply(404, "nope");
const c = createClient({ baseUrl: BASE, dispatcher: agent });
await assert.rejects(c.getJson("/missing"), (e: unknown) => e instanceof HttpError && e.status === 404 && e.body === "nope");
assert.deepEqual(c.attempts, [1]);
});
test("gives up after the retry budget with the last error", async () => {
agent.get(BASE).intercept({ path: "/always-500" }).reply(500, "boom").times(3);
const c = createClient({ baseUrl: BASE, dispatcher: agent, retries: 2 });
await assert.rejects(c.getJson("/always-500"), (e: unknown) => e instanceof HttpError && e.status === 500);
assert.equal(c.attempts.length, 3);
});
test("retries network errors from replyWithError", async () => {
const pool = agent.get(BASE);
pool.intercept({ path: "/net" }).replyWithError(new Error("ECONNRESET"));
pool.intercept({ path: "/net" }).reply(200, { recovered: true });
const c = createClient({ baseUrl: BASE, dispatcher: agent });
assert.deepEqual(await c.getJson("/net"), { recovered: true });
assert.equal(c.attempts.length, 2);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.