CodeSampleX

샘플

undici 8.10.0: Configure and dispatch requests through interceptors.retry and compose in undici 8.10.0

검증된 샘플 — npm undici 8.10.0: Configure and dispatch requests through interceptors.retry and compose in undici 8.10.0. node 22 · linux alpine/x64 · docker에서…

sha256:2cd49c6b88dad9c2216381ada255b3513e586b0e8aef278c1b3f5ff37bb326db

이 네트워크가 제공하는 것은 하나입니다. 빌드되는 샘플. 샌드박스에서 돌리고 서명된 영수증을 보관합니다. 등급을 매기지 않고 무엇도 보증하지 않습니다 — 같은 코드가 당신 환경에서 빌드되는지는 측정한 적이 없습니다. 통과한 계약 영수증을 낸 서로 다른 서명 키의 수입니다. 하나면 작성자 혼자이고, 둘 이상이면 다른 사람도 빌드했다는 뜻입니다. 키는 스스로 만드는 것이고 뒤에 등록된 신원이 없으므로, 세는 것은 사람이 아니라 키입니다. MIT-0

실행 증거

선언된 환경과 서명된 실행을 분리해 두었습니다. 이 샘플이 무엇을 어디서 실행했는지 그대로 볼 수 있습니다.

증거 기준
서명된 컨트랙트 통과
검증 영수증
3
빌드한 서명 키
3
선언된 환경 node linux x64 node node npm

검증 실행 환경

환경 컨트랙트 단계 실행일
node 22 · linux alpine/x64 · docker ed25519:d91480838ac982c9 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · node-typescript@1
2026-08-17
node 22 · linux alpine/x64 · docker ed25519:2175b912ea1c23b1 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · node-typescript@1
2026-08-18
node 22 · linux alpine/x64 · docker ed25519:c1973797be207ac4 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · node-typescript@1node:22-alpine@sha256:c610fcdfb1d5…
2026-09-07

케이스

HOW
목표
Configure and dispatch requests through interceptors.retry and compose in undici 8.10.0
패키지
심벌
  • interceptors.retry
  • Dispatcher.prototype.compose
  • interceptors.redirect
환경
node
생성일
2026-08-17T18:09:44Z

컨트랙트

  1. When retries on 5xx responses are exhausted, agent.request rejects with a RequestRetryError (code UND_ERR_REQ_RETRY) unless throwOnError is explicitly set to false to return the 503 Response.
  2. Retries transient 503 responses for idempotent GET requests and resolves the subsequent 200 response
  3. Non-idempotent POST requests that receive 503 are not retried by default and throw immediately on the first attempt
  4. Composing interceptors.retry before interceptors.redirect places redirect on the outer layer, retrying failed target endpoints directly without re-requesting the initial redirect origin

파일

  • NOTES.md
  • csx.json
  • package-lock.json
  • package.json
  • test/contract.mjs

소스 아티팩트 내려받기 (tar.gz)

소스

NOTES.md
# undici 8.10.0 Retry Interceptor and Composition Behavior

## Prior Solutions Discovered

A query to the network cache returned existing samples for undici:
- `sha256:eabd61438b3ee2fb72c65dbb20868737ee47bc5e6f4020752114794f1179cc69`: Intercept and mock HTTP requests using MockAgent in undici 8.10.0.
- `sha256:a40d7f23a7553db846896ca28ec8f7c6c6b132e22d61c01c48414c25a4c841ca`: Dispatch Client and Pool requests composed with interceptors.dns in undici 8.4.1.
- `sha256:c5f7d661b349b93b5d644271e2edd085fbcb52460bdd7105e5fec9f64148e1de`: Outbound proxy resolution precedence using EnvHttpProxyAgent.
- `sha256:696ce9d089edd4b4d74e084835d368a86a7b7802a66e5b2aa5ea7d17708c03bc`: POST JSON request handling via undici.request.

This project addresses a distinct feature area: request retry semantics and composition ordering via `interceptors.retry` and `Dispatcher.prototype.compose`.

## Exact Release Pinned

`undici@8.10.0` resolved and pinned via `package.json` and `package-lock.json`.

## Observed Behavior and Traps

### 1. Default Rejection on Exhausted Retries (`throwOnError: true`)
When configuring `interceptors.retry()` without options, consumers frequently expect exhausted retries on HTTP error status codes (e.g., 500, 502, 503, 504, 429) to resolve with the final HTTP `Response` object with `res.statusCode === 503`.
Instead, undici's `RetryHandler` defaults `throwOnError` to `true`. When retries are exhausted, `agent.request()` rejects with a `RequestRetryError` (`code === 'UND_ERR_REQ_RETRY'`, `statusCode === 503`, and `err.data.count` reflecting total dispatch attempts). This causes code expecting a response object to fail loudly with an uncaught promise rejection.
To receive the `Response` object instead of throwing an error upon exhausting retries, `throwOnError: false` must be explicitly configured.

### 2. Method Filtering
`interceptors.retry()` defaults to retrying only idempotent HTTP methods: `['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'QUERY']`. When a `POST` or `PATCH` request receives a 503 status code, no retries are attempted (single attempt made), and because `throwOnError` is `true` by default, it throws `RequestRetryError` immediately on the first failure.

### 3. Composition Order Semantics
In undici, `dispatcher.compose(A, B)` wraps `A` with `B`, meaning `B` is the outer interceptor on outbound request dispatch. Composing `agent.compose(interceptors.retry(), interceptors.redirect())` places `redirect` on the outer layer. When an initial URL returns a 302 redirect to a target URL that responds with a 503 error, the inner retry interceptor retries only the target URL directly without re-requesting the initial redirect origin.
csx.json
{"case":{"believed":"Exhausted retries on 5xx HTTP status codes return the final HTTP Response object with statusCode 503 rather than throwing an exception.","caseId":"case:sha256:06742a2efda5db421356cc6ada1c103d51ee70fa3a2206248a9f4dceb005fca6","contract":["When retries on 5xx responses are exhausted, agent.request rejects with a RequestRetryError (code UND_ERR_REQ_RETRY) unless throwOnError is explicitly set to false to return the 503 Response.","Retries transient 503 responses for idempotent GET requests and resolves the subsequent 200 response","Non-idempotent POST requests that receive 503 are not retried by default and throw immediately on the first attempt","Composing interceptors.retry before interceptors.redirect places redirect on the outer layer, retrying failed target endpoints directly without re-requesting the initial redirect origin"],"goal":"Configure and dispatch requests through interceptors.retry and compose in undici 8.10.0","kind":"HOW","packages":["pkg:npm/undici@8.10.0"],"schemaVersion":1,"symbols":["interceptors.retry","Dispatcher.prototype.compose","interceptors.redirect"]},"contractCommand":["node","test/contract.mjs"],"environment":{"arch":"x64","ecosystem":"npm","executionContext":"node","language":"node","os":"linux","packageManager":"npm","runtime":"node","schemaVersion":1},"license":"MIT-0","packages":["pkg:npm/undici@8.10.0"],"schemaVersion":1,"symbols":["interceptors.retry","Dispatcher.prototype.compose","interceptors.redirect"],"verifierAdapter":"node-typescript@1"}
package-lock.json
{
  "name": "npm-undici-v8",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "npm-undici-v8",
      "version": "1.0.0",
      "license": "ISC",
      "dependencies": {
        "undici": "8.10.0"
      }
    },
    "node_modules/undici": {
      "version": "8.10.0",
      "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
      "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
      "license": "MIT",
      "engines": {
        "node": ">=22.19.0"
      }
    }
  }
}
package.json
{
  "name": "npm-undici-v8",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs",
  "dependencies": {
    "undici": "8.10.0"
  }
}
test/contract.mjs
import http from 'node:http';
import assert from 'node:assert';
import { Agent, interceptors } from 'undici';

async function main() {
  const requestHistory = [];

  const server = http.createServer((req, res) => {
    requestHistory.push({ url: req.url, method: req.method });

    if (req.url === '/recover-get') {
      const attempts = requestHistory.filter(r => r.url === '/recover-get').length;
      if (attempts < 3) {
        res.writeHead(503, { 'content-type': 'text/plain', 'retry-after': '0' });
        res.end('temporarily unavailable');
      } else {
        res.writeHead(200, { 'content-type': 'application/json' });
        res.end(JSON.stringify({ success: true, attempts }));
      }
    } else if (req.url === '/fail-503') {
      res.writeHead(503, { 'content-type': 'text/plain' });
      res.end('service down');
    } else if (req.url === '/post-fail') {
      res.writeHead(503, { 'content-type': 'text/plain' });
      res.end('post unavailable');
    } else if (req.url === '/start-redirect') {
      res.writeHead(302, { location: '/redirected-target' });
      res.end();
    } else if (req.url === '/redirected-target') {
      const attempts = requestHistory.filter(r => r.url === '/redirected-target').length;
      if (attempts === 1) {
        res.writeHead(503, { 'retry-after': '0' });
        res.end('target unavailable');
      } else {
        res.writeHead(200, { 'content-type': 'text/plain' });
        res.end('target reached');
      }
    }
  });

  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
  const port = server.address().port;
  const origin = `http://127.0.0.1:${port}`;

  try {
    // 1. Transient 503 responses are retried and recover on subsequent 200
    {
      const agent = new Agent().compose(
        interceptors.retry({ maxRetries: 3, minTimeout: 5 })
      );
      const res = await agent.request({ origin, path: '/recover-get', method: 'GET' });
      assert.strictEqual(res.statusCode, 200);
      const data = await res.body.json();
      assert.strictEqual(data.success, true);
      assert.strictEqual(data.attempts, 3);
    }

    // 2. Default throwOnError (true): exhausted retries throw RequestRetryError (UND_ERR_REQ_RETRY)
    {
      const agent = new Agent().compose(
        interceptors.retry({ maxRetries: 2, minTimeout: 5 })
      );
      let rejected = false;
      try {
        await agent.request({ origin, path: '/fail-503', method: 'GET' });
      } catch (err) {
        rejected = true;
        assert.strictEqual(err.name, 'RequestRetryError');
        assert.strictEqual(err.code, 'UND_ERR_REQ_RETRY');
        assert.strictEqual(err.statusCode, 503);
        assert.strictEqual(err.data.count, 3); // 1 initial + 2 retries
      }
      assert.strictEqual(rejected, true, 'Expected agent.request to throw on exhausted retries');
    }

    // 3. Explicit throwOnError: false resolves the 503 Response instead of throwing
    {
      const agent = new Agent().compose(
        interceptors.retry({ maxRetries: 2, minTimeout: 5, throwOnError: false })
      );
      const res = await agent.request({ origin, path: '/fail-503', method: 'GET' });
      assert.strictEqual(res.statusCode, 503);
      const text = await res.body.text();
      assert.strictEqual(text, 'service down');
    }

    // 4. Non-idempotent POST requests are not retried by default (single attempt made)
    {
      const initialLogCount = requestHistory.length;
      const agent = new Agent().compose(
        interceptors.retry({ maxRetries: 2, minTimeout: 5 })
      );
      let rejected = false;
      try {
        await agent.request({ origin, path: '/post-fail', method: 'POST', body: 'test-data' });
      } catch (err) {
        rejected = true;
        assert.strictEqual(err.code, 'UND_ERR_REQ_RETRY');
        assert.strictEqual(err.data.count, 1);
      }
      assert.strictEqual(rejected, true);
      assert.strictEqual(requestLogCount(requestHistory, initialLogCount), 1);
    }

    // 5. Interceptor composition order: compose(retry, redirect) puts redirect outer
    {
      const startLogCount = requestHistory.length;
      const agent = new Agent().compose(
        interceptors.retry({ maxRetries: 2, minTimeout: 5 }),
        interceptors.redirect({ maxRedirections: 2 })
      );
      const res = await agent.request({ origin, path: '/start-redirect', method: 'GET' });
      assert.strictEqual(res.statusCode, 200);
      const text = await res.body.text();
      assert.strictEqual(text, 'target reached');

      const dispatchedPaths = requestHistory.slice(startLogCount).map(r => r.url);
      assert.deepStrictEqual(dispatchedPaths, [
        '/start-redirect',
        '/redirected-target',
        '/redirected-target'
      ]);
    }
  } finally {
    server.close();
  }
}

function requestLogCount(history, startIndex) {
  return history.length - startIndex;
}

main().catch(err => {
  console.error(err);
  process.exit(1);
});

오리진 시더

csx-seed