anthropics / anthropics/anthropic-sdk-typescript
Retrying a request with a single-use stream body silently sends a 0-byte body and reports success
- Dominant language
- TypeScript
- Stars
- 2.1k
- Forks
- 403
- Avg merge
- 1d 21h
- Merged PRs (30d)
- 8
Description
### Describe the bug
When a request body is a single-use async iterable (or ReadableStream) and the first attempt fails with a retryable error (500/429/408/409/connection failure), the retry re-wraps the same exhausted iterator: the second attempt goes on the wire with a **0-byte body**, and the caller's promise **resolves successfully**. The payload is silently gone — no error, no warning.
In `buildBody` (`src/client.ts`), an async-iterable body is wrapped via `ReadableStreamFrom(options.body)` per attempt, but `options.body` itself is the caller's single-use iterator: attempt 1 drains it, and the retry gets an empty stream.
### Steps to reproduce
Self-contained script against `@anthropic-ai/sdk@0.121.0` — a loopback server fails the first POST with 500 and accepts the second; the client streams a 1024-byte body with `maxRetries: 1`:
repro (plain node, no dependencies beyond the SDK)
```js
// repro: node repro.mjs — loopback only, no external traffic
import http from 'node:http';
import { Anthropic } from '@anthropic-ai/sdk';
let n = 0;
const bodies = [];
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
n++;
bodies.push(Buffer.concat(chunks).toString('utf8'));
const body = n === 1 ? JSON.stringify({ error: { type: 'api_error' } }) : JSON.stringify({ id: 'msg_ok', type: 'message' });
res.writeHead(n === 1 ? 500 : 200, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
res.end(body);
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const client = new Anthropic({ apiKey: 'repro-local', baseURL: `http://127.0.0.1:${server.address().port}`, maxRetries: 1 });
async function* bodyIter() { yield 'A'.repeat(512); yield 'A'.repeat(512); } // 1024 bytes, single-use
const res = await client.post('/v1/anything', { body: bodyIter(), headers: { 'content-type': 'application/octet-stream' } });
console.log('attempts:', n, '| body lengths sent:', bodies.map((b) => Buffer.byteLength(b)));
console.log('caller sees success:', res && res.id === 'msg_ok');
server.close();
```
Output:
```
attempts: 2 | body lengths sent: [ 1024, 0 ]
caller sees success: true
```
### Expected behavior
Either the retry carries the same body as the original, or the retry is refused with a clear error when the body source is single-use. A silently-empty request that reports success is the worst of the three outcomes — the server may act on the truncated request (e.g. create an empty object) while the application believes its full payload was delivered.
### Environment
- `@anthropic-ai/sdk` 0.121.0
- Node 24 (also applies wherever streaming bodies are supported)
### Suggested fix
Buffer stream bodies up to a size cap for retry (as JSON bodies effectively are), or track that the body source is single-use and fail the retry loudly instead of sending an empty one.
Contributor guide
Research direction
Start with buildBody in src/client.ts and reproduce the supplied loopback-server script using a single-use async iterator and maxRetries: 1. Trace how the request body is rebuilt after the first failed attempt, then verify that the completed behavior either preserves the payload or reports a clear retry error instead of accepting an empty body.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- api
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 70/100