Skip to content

HTTP stream append retry after 429 reuses a consumed fetch body #4628

Closed

Description

@rpinckne

On unpatched @workflow/world-vercel@5.0.1, a confirmed HTTP 429 on a stream append fails with UND_ERR_REQ_CONTENT_LENGTH_MISMATCH during automatic retry. A healthy append of the same bytes succeeds. The fixture receives the first rejected request, but the adapter's retry fails before a second complete request reaches it.

Reproduction

Tested with Node 24.21.0, pnpm 11.28.4 and the package's native undici@7.30.0. Start in an empty directory:

printf '{"private":true,"type":"module"}\n' > package.json
pnpm add --save-exact @workflow/world-vercel@5.0.1 @opentelemetry/api@1.9.0
env -i PATH="$PATH" node --unhandled-rejections=strict repro.mjs

Save this complete script as repro.mjs:

import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

const require = createRequire(path.resolve('package.json'));
const { createStreamer } = await import(
  pathToFileURL(require.resolve('@workflow/world-vercel')).href
);
const payload = new TextEncoder().encode('Synthetic caf\u00e9 \ud83d\ude00 bytes.');
const attempts = new Map();
const received = [];
const committed = new Map();
const server = createServer((req, res) => {
  const name = new URL(req.url, 'http://localhost').pathname.split('/').at(-1);
  const attempt = (attempts.get(name) ?? 0) + 1;
  attempts.set(name, attempt);
  req.on('error', () => {});
  const pieces = [];
  req.on('data', piece => pieces.push(piece));
  req.on('end', () => {
    const bytes = Buffer.concat(pieces);
    received.push({ name, attempt, bytes: bytes.length });
    if (name === 'strm_retry' && attempt === 1) return;
    committed.set(name, [...(committed.get(name) ?? []), bytes]);
    res.writeHead(200).end('ok');
  });
  // Reject before consuming the body. This request is never committed.
  if (name === 'strm_retry' && attempt === 1) {
    res.writeHead(429, { 'Retry-After': '0.001' }).end('rate limited');
  }
});

const originalEnv = { ...process.env };
try {
  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
  process.env.VERCEL_WORKFLOW_SERVER_URL = `http://127.0.0.1:${server.address().port}`;
  process.env.WORKFLOW_NODE_HTTP = '0';
  const streamer = createStreamer();
  await streamer.streams.write('wrun_example', 'strm_control', payload);
  assert.deepEqual(committed.get('strm_control'), [Buffer.from(payload)]);
  let failure;
  try {
    await streamer.streams.write('wrun_example', 'strm_retry', payload);
  } catch (error) {
    failure = error;
  }
  const causes = [];
  const seen = new Set();
  for (let error = failure; error && !seen.has(error); error = error.cause) {
    seen.add(error);
    causes.push({ name: error.name, code: error.code });
  }
  console.log(JSON.stringify({ received, attempts: Object.fromEntries(attempts), causes }));
  // Fails on unpatched 5.0.1 with UND_ERR_REQ_CONTENT_LENGTH_MISMATCH.
  assert.equal(failure, undefined, 'Confirmed 429 should retry a complete body');
  assert.equal(attempts.get('strm_retry'), 2);
  assert.deepEqual(committed.get('strm_retry'), [Buffer.from(payload)]);
  console.log('PASS: healthy control and exactly one complete append after 429');
} finally {
  for (const key of ['VERCEL_WORKFLOW_SERVER_URL', 'WORKFLOW_NODE_HTTP']) {
    if (originalEnv[key] === undefined) delete process.env[key];
    else process.env[key] = originalEnv[key];
  }
  server.closeAllConnections();
  await new Promise(resolve => server.close(resolve));
}

Expected and actual

The first loopback request is a healthy control and persists the exact 27-byte payload. The second stream's first request receives 429 immediately and is explicitly excluded from the fixture's commit path. A successful retry should send the complete payload and append it once.

On unpatched 5.0.1, the final expected-behavior assertion fails. The printed cause chain contains StreamError, TypeError, then RequestContentLengthMismatchError with code UND_ERR_REQ_CONTENT_LENGTH_MISMATCH. Only one request reaches the retry stream. Node's fetch body is one-shot, and the automatic retry dispatcher reuses it after consumption.

The identical script passes against a local source fix: two requests reach the retry stream, both carry 27 bytes, and only the accepted second request commits. The script uses a scrubbed environment and sends requests only to its owned loopback server. It does not establish hosted behavior or identify the initiating event of a live incident.

Related work and possible native fix

Undici 8 proposal #3438 is open and describes the same one-shot fetch-body mechanism for the events transport. Its buffering discussion is useful context for the append path.

Writer dedupe #2731 closed unmerged, and stream convergence #4592 is an open documentation draft. They address safe replay after an unknown append outcome. This witness isolates a confirmed 429 refusal with no fixture commit; it does not require backend dedupe to recover that refusal.

Build a fresh request from immutable bytes after a confirmed 429, or supply a replayable byte body at the transport seam. Preserve bounded Retry-After handling and surface ambiguous network/5xx outcomes without replay. Idempotent stream close retains its separate retry policy.

Activity

  1. mmarufov commented on Oct 5, 2026

    @mmarufov
    Contributor

    Hey! would be happy to work on this

  2. added a commit that references this issue on Oct 13, 2026
    02993a2
  3. added a commit that references this issue on Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions