Commit 8369b7f
committed
fix(proxy/streaming): tolerate malformed upstream SSE events
## Description
Three parsers read upstream-controlled SSE bodies with `json.loads` and then
reach into the result without checking its shape:
`_parse_sse_usage`, `_parse_sse_usage_from_buffer` and `_parse_sse_to_response`.
A valid-JSON value of the wrong shape passes the `json.JSONDecodeError` guard
and then raises. Three distinct ways:
- a non-object event (`["x"]`, `"str"`, `42`) makes `.get` raise `AttributeError`
- an explicit `"key": null` bypasses a `.get(key, {})` default, because the
default applies only to a *missing* key, not a present-and-null one
- an unhashable `index` (a list or dict) raises `TypeError: unhashable type`
when used to key the block map
In `_parse_sse_to_response` the raise escapes into `_finalize_stream_response`
and tears down a stream the client is already reading. Several call sites reach
it from a bare `finally`, so there is no handler above it at all.
**This is a robustness invariant, not a bug report about any specific upstream.**
I am not claiming Anthropic emits these shapes; I have no evidence of that. The
Anthropic handler serves any Anthropic-shaped upstream (`--backend
anthropic|bedrock|openrouter|anyllm|litellm-<provider>`,
`ANTHROPIC_TARGET_API_URL`), so Bedrock, OpenRouter, LiteLLM, vLLM and Vertex
all flow through these parsers. A parser on a proxy's critical streaming path
should not raise on a well-formed-JSON frame of unexpected shape, whatever the
source. The guard costs one `isinstance`; being wrong costs the user's session.
**Scope: frame shape only.** Value validity — a token count that is a string,
`Infinity`, or a >4300-digit integer literal — is a separate defect class that
reaches the same parsers and is deliberately left for a follow-up.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- **headroom/proxy/handlers/streaming.py**
- Add three shared normalizers — `_sse_dict`, `_sse_str`, `_sse_index` — so
every upstream-derived container is shape-checked with one idiom instead of
a mix of inline `isinstance` and `.get(..., {})` defaults.
- `_sse_index` rejects `bool` as well as non-`int`, so `true` cannot silently
alias block index `1`, and unhashable values cannot reach the block map.
- Guard the positions the earlier pass missed: `content_block_delta.delta`,
`message_delta.delta`, `message_delta.usage` (`dict.update` on a non-mapping
raises — the `message_start` twin was already guarded, this sibling was not),
the `index` on all three `content_block_*` events, and the string
accumulators for `text` / `partial_json` / `thinking`.
- Guard `_parse_sse_usage`, which had no shape checks at all — not on the
top-level event, nor on the anthropic, openai or gemini branches.
- Guard the gemini `usageMetadata` branch in `_parse_sse_usage_from_buffer`,
which used a truthiness check where its two sibling branches used
`isinstance`.
- Stop the non-standard-block copy-through from seeding the parser's own
scratch keys (`_partial_json`, `thinking_buffer`) via `_BLOCK_SCRATCH_KEYS`,
and tolerate a non-list `citations` that a copy-through already placed.
- **tests/test_streaming_sse_malformed_events.py** — new file, 408 cases.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`, `ruff format`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added
The sweep is enumerated, not randomized: the failure surface is finite, so the
cases can be exact and no `hypothesis` dependency is needed. This matches the
existing precedent in `tests/test_toin_observation_only.py`.
Happy-path fixtures are transcribed from real `api.anthropic.com` streams
rather than hand-written, which corrected four wrong assumptions in the
previous fixtures: `message_delta.usage` repeats the *full* usage block (not
just `output_tokens`); `message_start.message` carries `stop_details: null`
alongside the other two nulls; `usage.cache_creation` is always present as a
nested object and is what `_extract_anthropic_cache_ttl_metrics` reads; and a
`tool_use` block carries a non-standard `caller` field.
### Test Output
```text
$ pytest tests/test_streaming_sse_malformed_events.py
408 passed
# same file against origin/main's parsers
191 failed, 217 passed
# regression sweep: all 22 test files touching SSE parsing
710 passed, 5 skipped
$ mypy headroom --ignore-missing-imports
Success: no issues found in 509 source files
```
## Real Behavior Proof
- **Environment:** macOS 15 (Darwin 25.4.0, arm64), Python 3.13.7, Rust 1.95.0
per `rust-toolchain.toml`, `uv sync --extra dev`.
- **Real upstream capture:** three live streaming `POST /v1/messages` calls to
`api.anthropic.com` (`claude-haiku-4-5`) — a text stream, a `tool_use` stream
and an extended-thinking stream — captured as raw wire bytes and used to build
the happy-path fixtures. This is what surfaced the four fixture errors above.
- **Differential:** the 408-case file was run against `origin/main`'s parsers via
`git show origin/main:...` (not `git stash`, which silently no-ops once the fix
is committed and produces a false pass). 191 cases fail there and pass here.
- **Exhaustive shape fuzz:** 700 generated cases crossing every upstream-derived
container position in the three parsers with `None`, `"str"`, `42`, `3.5`,
`True`, `False`, `["x"]`, `[]`, `{}`, `{"k":"v"}`. Zero raises after the change;
before it, the positions listed under Changes Made all raise.
- **Per-position legitimacy check:** each injection position in the sweep was
confirmed to produce at least one genuine failure against `origin/main`, so no
case passes vacuously. This caught four delta-shaped tests that were injecting
their fault before any content block had opened, where the parser skips the
body and the guard is never reached.
- **Not tested:**
- **No evidence these shapes occur in the wild.** The fault shapes are derived
from the parsers' own unguarded access positions, not observed in real
upstream traffic. I searched the tracker for this crash signature and both
function names and found no report. The claim is survivability, not incidence.
- The real API cannot be made to emit malformed frames on demand, so the fault
injection is synthetic; only the happy-path fixtures come from live traffic.
- No live third-party gateway (Bedrock/OpenRouter/LiteLLM/Vertex) exercised,
despite those being the motivating case.
- No WebSocket path, no Windows, not run against a live Claude Code session.
- Value-validity faults are out of scope and still raise; see Additional Notes.
## Additional Notes
Two findings left deliberately unfixed, both pre-existing and both outside the
frame-shape scope of this change:
1. **Value validity.** `_extract_anthropic_cache_ttl_metrics` calls `int()` on an
upstream value with no `try`; `_usage_int` does not catch `OverflowError`, and
`json.loads` accepts bare `Infinity`; `except json.JSONDecodeError` is too
narrow for a >4300-digit integer literal (plain `ValueError`) or deep nesting
(`RecursionError`); and the anthropic and gemini branches export non-`int`
token values out of a `dict[str, int]`-annotated function.
2. **Reconstruction fidelity.** `content_block_start` copies through unknown
fields only for non-standard block types, so the real `caller` field on a
`tool_use` block is dropped on reconstruction.
`test_real_tool_use_stream_reconstructs_tool_input` pins the current
behaviour rather than asserting the desired one.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation — n/a, no
user-facing surface changed
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`
No new dependencies added.1 parent cb8f4b6 commit 8369b7f
2 files changed
Lines changed: 710 additions & 31 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
33 | 76 | | |
34 | 77 | | |
35 | 78 | | |
| |||
179 | 222 | | |
180 | 223 | | |
181 | 224 | | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
182 | 228 | | |
183 | 229 | | |
184 | 230 | | |
| |||
187 | 233 | | |
188 | 234 | | |
189 | 235 | | |
190 | | - | |
191 | | - | |
| 236 | + | |
192 | 237 | | |
193 | 238 | | |
194 | 239 | | |
| |||
204 | 249 | | |
205 | 250 | | |
206 | 251 | | |
207 | | - | |
| 252 | + | |
208 | 253 | | |
209 | 254 | | |
210 | 255 | | |
211 | 256 | | |
212 | 257 | | |
213 | | - | |
| 258 | + | |
214 | 259 | | |
215 | 260 | | |
216 | 261 | | |
217 | 262 | | |
218 | | - | |
| 263 | + | |
219 | 264 | | |
220 | 265 | | |
221 | 266 | | |
222 | 267 | | |
223 | 268 | | |
224 | | - | |
| 269 | + | |
225 | 270 | | |
226 | 271 | | |
227 | 272 | | |
| |||
275 | 320 | | |
276 | 321 | | |
277 | 322 | | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
278 | 331 | | |
279 | 332 | | |
280 | 333 | | |
281 | | - | |
282 | | - | |
| 334 | + | |
283 | 335 | | |
284 | 336 | | |
285 | 337 | | |
| |||
299 | 351 | | |
300 | 352 | | |
301 | 353 | | |
302 | | - | |
| 354 | + | |
303 | 355 | | |
304 | 356 | | |
305 | 357 | | |
| |||
339 | 391 | | |
340 | 392 | | |
341 | 393 | | |
342 | | - | |
| 394 | + | |
343 | 395 | | |
344 | 396 | | |
345 | 397 | | |
| |||
402 | 454 | | |
403 | 455 | | |
404 | 456 | | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
405 | 465 | | |
406 | 466 | | |
407 | 467 | | |
408 | | - | |
| 468 | + | |
409 | 469 | | |
410 | 470 | | |
411 | 471 | | |
412 | 472 | | |
413 | 473 | | |
414 | 474 | | |
415 | | - | |
416 | | - | |
| 475 | + | |
417 | 476 | | |
418 | 477 | | |
419 | | - | |
420 | | - | |
| 478 | + | |
| 479 | + | |
421 | 480 | | |
422 | 481 | | |
423 | 482 | | |
424 | 483 | | |
425 | 484 | | |
426 | 485 | | |
427 | | - | |
| 486 | + | |
428 | 487 | | |
429 | 488 | | |
430 | 489 | | |
| |||
433 | 492 | | |
434 | 493 | | |
435 | 494 | | |
436 | | - | |
| 495 | + | |
437 | 496 | | |
438 | 497 | | |
439 | 498 | | |
| |||
450 | 509 | | |
451 | 510 | | |
452 | 511 | | |
453 | | - | |
| 512 | + | |
454 | 513 | | |
455 | 514 | | |
456 | 515 | | |
457 | 516 | | |
458 | 517 | | |
459 | 518 | | |
460 | | - | |
| 519 | + | |
461 | 520 | | |
462 | 521 | | |
463 | | - | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
464 | 525 | | |
465 | 526 | | |
466 | | - | |
| 527 | + | |
467 | 528 | | |
468 | 529 | | |
469 | | - | |
470 | | - | |
| 530 | + | |
| 531 | + | |
471 | 532 | | |
472 | 533 | | |
473 | 534 | | |
474 | 535 | | |
475 | | - | |
476 | | - | |
477 | | - | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
478 | 539 | | |
479 | 540 | | |
480 | 541 | | |
| |||
485 | 546 | | |
486 | 547 | | |
487 | 548 | | |
488 | | - | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
489 | 555 | | |
490 | 556 | | |
491 | 557 | | |
492 | 558 | | |
493 | 559 | | |
494 | | - | |
| 560 | + | |
495 | 561 | | |
496 | 562 | | |
497 | 563 | | |
| |||
525 | 591 | | |
526 | 592 | | |
527 | 593 | | |
528 | | - | |
| 594 | + | |
529 | 595 | | |
530 | 596 | | |
531 | 597 | | |
532 | 598 | | |
533 | | - | |
534 | | - | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
535 | 603 | | |
536 | 604 | | |
537 | 605 | | |
| |||
0 commit comments