headroom/crates/headroom-proxy/tests/integration_request_id.rs
chopratejas 148ded392a fix: A8 — SSE delta arms, UTF-8 buffer, phase preservation, request-id, 413
Eliminates the Python wire-format hotfix bugs gated on Phase A's
lockdown so the proxy is safe through Phase H's Python retirement.

Bugs retired:
  - P0-7 / P4-44: Codex `phase` field is now explicitly preserved
    through the Responses-API ↔ Chat-Completions round-trip; multi
    text-part rebuild collapses to a single text part (no more
    content doubling).
  - P1-8: Bytes-level SSE event splitter
    `parse_sse_events_from_byte_buffer`; emoji/CJK split across
    chunks survive intact. Buffer is `bytearray`; UTF-8 decode happens
    only AFTER the `\n\n` event terminator is located in bytes.
    Invalid UTF-8 in a *complete* event raises (operator-visible
    diagnostic, not silent corruption).
  - P1-9: `_parse_sse_to_response` handles all delta types per
    Anthropic guide §5.1: `thinking_delta`, `signature_delta`,
    `citations_delta`. Block map keyed by `index` so out-of-order
    events reconstruct correctly. `redacted_thinking.data` preserved.
  - P4-47: Unknown Responses-API item types now log a structured
    `unknown_responses_item_type` warning so operators see new
    Codex item types in flight before they break.
  - P5-57: Rust proxy captures upstream `request-id` (Anthropic) and
    `x-request-id` (OpenAI); surfaced as `headroom-upstream-request-id`
    on the response and as a tracing span field. Distinct from the
    proxy's own `x-request-id`.
  - P5-59: Body-too-large now returns 413 (was 400). Pre-checks
    `Content-Length` and rejects without consuming the body when
    present; chunked uploads still buffer-then-fail with 413.

Configurability (no hardcodes):
  - HEADROOM_SSE_BUFFER_MAX_BYTES (default 1 MiB) — per-event cap.
  - HEADROOM_PROXY_BODY_TOO_LARGE_STATUS (default 413) — operator
    override for body-too-large status.

A7 follow-up: `_DummyAnthropicHandler._retry_request` accepts the
A3 byte-faithful kwargs (`original_body_bytes`, `body_mutated`,
`mutation_reasons`, `request_id`, `forwarder_name`, `path_for_log`)
so the existing 20 backpressure tests stay green against the real
handler signature.

The project-wide grep
  git grep 'errors="ignore"\|errors="replace"' headroom/proxy/handlers/ headroom/ccr/
returns nothing; the single remaining lossy-decode site (response-
body diagnostics, not SSE) routes through `safe_decode_for_logging`
in `headroom/proxy/helpers.py`.

Tests:
  - tests/test_sse_thinking_blocks.py (4 tests)
  - tests/test_sse_utf8_split.py (3 tests)
  - tests/test_proxy_responses_phase_preservation.py (4 tests)
  - crates/headroom-proxy/tests/integration_request_id.rs (2 tests)
  - crates/headroom-proxy/tests/integration_body_size.rs (2 tests)
2026-05-02 10:35:11 -07:00

80 lines
2.6 KiB
Rust

//! PR-A8 / P5-57: capture upstream `request-id` (Anthropic) and
//! `x-request-id` (OpenAI) and forward them in a distinct header so
//! operators can correlate proxy logs without conflating with the
//! proxy's own `x-request-id`.
mod common;
use common::start_proxy;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn upstream_anthropic_request_id_captured() {
let upstream = MockServer::start().await;
let upstream_id = "req_anthropic_xyz_123";
Mock::given(method("POST"))
.and(path("/v1/messages"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("request-id", upstream_id)
.set_body_string(r#"{"ok":true}"#),
)
.mount(&upstream)
.await;
let proxy = start_proxy(&upstream.uri()).await;
let resp = reqwest::Client::new()
.post(format!("{}/v1/messages", proxy.url()))
.header("content-type", "application/json")
.body(r#"{"model":"claude-3-5-sonnet","messages":[]}"#)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// Anthropic's `request-id` header is forwarded verbatim AND
// surfaced under the side-channel header.
let echoed = resp
.headers()
.get("headroom-upstream-request-id")
.and_then(|v| v.to_str().ok());
assert_eq!(echoed, Some(upstream_id));
// The original `request-id` header is also forwarded.
let raw = resp
.headers()
.get("request-id")
.and_then(|v| v.to_str().ok());
assert_eq!(raw, Some(upstream_id));
proxy.shutdown().await;
}
#[tokio::test]
async fn upstream_openai_x_request_id_captured() {
let upstream = MockServer::start().await;
let upstream_id = "req_openai_abc_456";
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("x-request-id", upstream_id)
.set_body_string(r#"{"ok":true}"#),
)
.mount(&upstream)
.await;
let proxy = start_proxy(&upstream.uri()).await;
let resp = reqwest::Client::new()
.post(format!("{}/v1/chat/completions", proxy.url()))
.header("content-type", "application/json")
.body(r#"{"model":"gpt-4o","messages":[]}"#)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let echoed = resp
.headers()
.get("headroom-upstream-request-id")
.and_then(|v| v.to_str().ok());
assert_eq!(echoed, Some(upstream_id));
proxy.shutdown().await;
}