ProvenPin Install

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

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.