Repository navigation
Description
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.mjsSave 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
mmarufov commentedon Oct 5, 2026
Hey! would be happy to work on this
[world-vercel] Fix HTTP stream append retries after 429 (vercel#4828)
Backport #4828: [world-vercel] Fix HTTP stream append retries after 4…