Skip to content

fix: propagate upstream HTTP errors in streaming responses instead of silent 200 - #11

Merged
Haannbboo merged 1 commit into
mainfrom
fix/proxy-upstream-error-propagation
May 23, 2026
Merged

Haannbboo merged 1 commit into
mainfrom
fix/proxy-upstream-error-propagation

Conversation

@Haannbboo

Copy link
Copy Markdown
Owner

Summary

  • Fix proxy masking upstream 401/5xx as 200 in streaming mode
  • Add test coverage for the error propagation path

Why / Context

When Hermes (or any client) sends a streaming request with an invalid API key, the proxy forwarded the request to OpenRouter, got a 401, but returned it as a StreamingResponse with status 200. The 401 error body was embedded as stream data. The client saw empty content and retried endlessly instead of getting a proper 401.

How It Works

Instead of immediately creating a StreamingResponse from an async generator that calls client.stream(), the new _forward_stream_or_error() function opens the upstream connection first, checks the response status, and:

  • 2xx: wraps the response in a StreamingResponse with a relay generator
  • 4xx/5xx: reads the error body and returns a JSONResponse with the correct status code

The relay generator manages the httpx.AsyncClient lifecycle directly (client created in the outer scope, closed in the generator's finally block).

Manual QA

  • Invalid OpenRouter key → proxy returns 401 with error body (was 200 with embedded error)
  • Valid OpenRouter key → streaming works as before (tested with openrouter/owl-alpha)
  • Hermes headless works end-to-end

Testing

  • uv run python -m pytest -q: 481 passed (was 478, +1 new error-path test + 2 already accounted for)

Risk Areas

  • Privacy/secrets: no — error bodies from upstream are forwarded as-is
  • Cost/token accounting: no
  • Provider adapter/model normalization: no
  • Streaming/tool calls: yes — modifies streaming response path to pre-check upstream status
  • Schema/migration/backfill: no
  • Frontend/backend route parity: no

Review

  • Independent code review completed before commit: yes
  • Must-fix review findings resolved: yes (added test for non-2xx error path)
  • Standards checked against AGENTS.md and .agents/commands/llm-tracker.md: yes

Known Limitations / Follow-ups

  • The httpx client handle lives in the relay generator's finally block; a client disconnect before the generator is consumed could leak the handle. This is a pre-existing pattern (old code had the same issue).

@coderabbitai

coderabbitai Bot commented May 23, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Streaming requests now surface upstream HTTP errors as proper non-streaming error responses and reliably record latency/stream usage
    • Token counting/aggregation updated to normalize totals for one client source so reports and ordering reflect corrected effective token totals
  • Documentation

    • Added a “Memory Context” block to the agents guide
  • Tests

    • Added tests covering upstream streaming errors and opencode token-correction behavior

Walkthrough

The proxy streaming handler now explicitly detects upstream HTTP errors, parses error bodies as JSON/text, and returns synchronous JSONResponses for errors; successful SSE streams are relayed while extracting token usage and recording latency/TTFT. OpenCode token totals are normalized (use max of stored total and derived sum) in OTLP ingestion, upsert logic, and reporting SQL expressions. Tests are added/updated and a claude-mem-context block is inserted into AGENTS.md.

Changes

Streaming Error Handling, Usage Normalization, Tests, Docs

Layer / File(s) Summary
OpenCode OTLP derivation
src/otlp.py
Compute derived_total and set total_tokens = max(int(total), derived_total) when both present; otherwise use derived_total.
Effective total_tokens helper & upsert
src/database/usage.py
Add helper to compute effective total_tokens for Opencode (max(stored total, prompt+completion+reasoning)) and apply it inside upsert_daily_aggregate for update and insert paths.
SQL expression for effective totals & effective pricing
src/database/usage.py
Introduce a SQL aggregation expression for effective total tokens and use it to label daily token columns and compute average effective price metrics.
Aggregation ordering and selection
src/database/usage.py
Switch summarize_usage_by_source, summarize_usage_by_provider, summarize_usage_daily, and aggregate_daily_by_dimension to sort/select by effective total tokens.
DB summarization test
tests/test_database.py
Add test seeding an Opencode UsageDaily row and asserting summarize_usage_daily returns the seeded cached_tokens and total_tokens.
OTLP unit test
tests/test_otlp.py
Add test verifying _extract_opencode_fields includes cached_token_count in prompt_tokens and total_tokens.
Streaming error handler implementation
src/proxy.py
Add _forward_stream_or_error to detect upstream status ≥ 400, read/parse error body (JSON or text) and return JSONResponse; otherwise relay text/event-stream SSE bytes, parse data: lines (skip [DONE]), extract usage, track TTFT, and call record_usage after completion.
Forward integration
src/proxy.py
Update forward() to call _forward_stream_or_error for streaming requests rather than the previous wrapper.
Proxy tests: mocks & error-case
tests/test_proxy.py
Update FakeAsyncClient to build_request() + async send(), extend fake stream response with aiter_bytes()/aread(), and add test_streaming_forward_returns_upstream_error asserting upstream 401 JSON is returned unchanged.
Agents doc insertion
AGENTS.md
Insert claude-mem-context “Memory Context” block with recent items and Next Steps.

Sequence Diagram

sequenceDiagram
  participant Client
  participant forward as forward()
  participant handler as _forward_stream_or_error()
  participant Upstream
  participant Recorder as record_usage()

  Client->>forward: send streaming request
  forward->>handler: invoke _forward_stream_or_error
  handler->>Upstream: build_request/send(stream=True)
  Upstream-->>handler: response(status, headers, body)

  alt status >= 400
    handler->>handler: read error body (aread)
    handler->>handler: parse JSON or decode text
    handler-->>Client: return JSONResponse(status, error_body)
    handler->>Recorder: record usage/latency metadata
  else status < 400
    loop for each SSE chunk
      handler->>handler: relay bytes to Client
      handler->>handler: parse SSE `data:` lines
      handler->>handler: extract_stream_usage updates usage fields
      handler->>handler: track TTFT (first token time)
    end
    handler->>Recorder: record_usage(final usage, latency, TTFT)
    handler-->>Client: StreamingResponse(text/event-stream)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Haannbboo/llm-tracker#9: Related OTLP/OpenCode token normalization changes overlapping how cached/derived token counts are handled.

Poem

🐰 A rabbit by the proxy gate,
Watches streams arrive and wait,
If errors shout with JSON cries,
I hand them back — no streaming lies.
When tokens trickle, I keep score,
Recording footsteps, bytes, and more.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing the proxy to propagate upstream HTTP errors (401/5xx) in streaming responses instead of masking them as 200.
Description check ✅ Passed The description is directly related to the changeset, providing context on the bug, how it was fixed, testing performed, and risk assessment across multiple domains.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/proxy-upstream-error-propagation

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/proxy.py`:
- Around line 151-160: The early return on upstream errors skips recording
usage, so failed streaming attempts (4xx/5xx) aren't logged; modify the error
branch that reads error_body and returns a JSONResponse to call
record_usage(...) with the same arguments used in the successful/non-streaming
path (include status_code and measured latency/context) before closing the
client and returning; ensure you still await client.aclose(), compute/propagate
the same status and timing values, and then return JSONResponse(error_content,
status_code=upstream.status_code) so failed stream setups are recorded the same
way as non-streaming responses.
- Around line 147-153: The httpx AsyncClient is leaked on exceptions in
_forward_stream_or_error because client.aclose() is only called on the
happy/failure paths after await upstream.aread() or after the relay; wrap the
client lifetime so it's always closed — either use "async with
httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client:" around the
send/aread/relay logic or surround the client/send/aread calls with try/finally
and call await client.aclose() in the finally block; ensure this covers the
calls to client.send(...), upstream.aread(), and any downstream relay so client
and upstream are always closed even on errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e5696a5-4587-45d9-9ced-ab133fa9d8b1

📥 Commits

Reviewing files that changed from the base of the PR and between c0c62fd and 9ecc050.

📒 Files selected for processing (2)
  • src/proxy.py
  • tests/test_proxy.py

Comment thread src/proxy.py
Comment thread src/proxy.py
@Haannbboo
Haannbboo force-pushed the fix/proxy-upstream-error-propagation branch from 6ea1e74 to 9ecc050 Compare May 23, 2026 01:48

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/proxy.py (1)

155-157: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle aread() failures without leaking the client.

If upstream.aread() raises after the status code is already known, this branch exits before client.aclose() and before returning the upstream 4xx/5xx, so the proxy both leaks the httpx client and turns the upstream failure into a local 500.

Suggested fix
     if upstream.status_code >= 400:
-        error_body = await upstream.aread()
-        await client.aclose()
+        error_body = b""
+        try:
+            error_body = await upstream.aread()
+        except httpx.HTTPError:
+            pass
+        finally:
+            await client.aclose()
         latency_ms = int((time.monotonic() - started_at) * 1000)

As per coding guidelines "Treat streaming, tool-call partial chunks, retry, timeout, and idempotency behavior as high-risk review areas. Review for correctness and edge cases."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/proxy.py` around lines 155 - 157, The current branch calls await
upstream.aread() then client.aclose(), but if aread() raises we leak client and
convert the upstream error into a 500; wrap the aread() call in a
try/except/finally (or try/finally) so client.aclose() is always awaited
regardless of aread() outcome, and propagate or handle the original exception
appropriately; specifically, modify the block around upstream.aread() and
client.aclose() to ensure client.aclose() runs in a finally clause and that you
either return the upstream status with its body when aread() succeeds (using
upstream.aread() result) or re-raise/forward the aread() exception after closing
the client so the error is not masked.
🧹 Nitpick comments (1)
tests/test_proxy.py (1)

505-525: ⚡ Quick win

Assert the new error-path usage logging too.

This test no-ops record_usage, so it won't catch regressions in the new failed-stream logging path. Capture the call and assert the basics (status, endpoint, ttft_ms) alongside the response assertions.

Suggested assertion update
-    monkeypatch.setattr(proxy_module, "record_usage", lambda **fields: None)
+    usage = {}
+    monkeypatch.setattr(
+        proxy_module, "record_usage", lambda **fields: usage.update(fields)
+    )
@@
     assert response.status_code == 401
     assert (
         response.body
         == b'{"error":{"message":"Missing Authentication header","code":401}}'
     )
+    assert usage["status"] == 401
+    assert usage["endpoint"] == "/v1/chat/completions"
+    assert usage["ttft_ms"] is None

As per coding guidelines "Treat streaming, tool-call partial chunks, retry, timeout, and idempotency behavior as high-risk review areas. Review for correctness and edge cases."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_proxy.py` around lines 505 - 525, The test currently no-ops
record_usage so it won't detect the new failed-stream logging; change the
monkeypatch for proxy_module.record_usage to a small capture function (e.g.,
append kwargs to a list) before calling proxy_module.forward, then after the
response assert that one usage record was captured and its basic fields match
the failure: check status == 401, endpoint == "/v1/chat/completions" (or
equivalent), and that ttft_ms exists/is a number; keep the existing
response.status_code and body assertions intact and reference
proxy_module.forward and proxy_module.record_usage to locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@AGENTS.md`:
- Around line 162-244: The committed claude-mem-context block in AGENTS.md
contains private session/memory data and must be removed: delete the entire
<claude-mem-context>...</claude-mem-context> section (the block with session IDs
and observations) and move any non-public details to a gitignored local file
such as AGENTS.local.md or .claude/*.local.md; update the commit (amend or new
commit) so the public file no longer contains that block and ensure repository
guidance (the lines describing keeping private data out) remains intact.

---

Duplicate comments:
In `@src/proxy.py`:
- Around line 155-157: The current branch calls await upstream.aread() then
client.aclose(), but if aread() raises we leak client and convert the upstream
error into a 500; wrap the aread() call in a try/except/finally (or try/finally)
so client.aclose() is always awaited regardless of aread() outcome, and
propagate or handle the original exception appropriately; specifically, modify
the block around upstream.aread() and client.aclose() to ensure client.aclose()
runs in a finally clause and that you either return the upstream status with its
body when aread() succeeds (using upstream.aread() result) or re-raise/forward
the aread() exception after closing the client so the error is not masked.

---

Nitpick comments:
In `@tests/test_proxy.py`:
- Around line 505-525: The test currently no-ops record_usage so it won't detect
the new failed-stream logging; change the monkeypatch for
proxy_module.record_usage to a small capture function (e.g., append kwargs to a
list) before calling proxy_module.forward, then after the response assert that
one usage record was captured and its basic fields match the failure: check
status == 401, endpoint == "/v1/chat/completions" (or equivalent), and that
ttft_ms exists/is a number; keep the existing response.status_code and body
assertions intact and reference proxy_module.forward and
proxy_module.record_usage to locate the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2dc322ed-dabc-4c4d-b31d-027c3e535b94

📥 Commits

Reviewing files that changed from the base of the PR and between 9ecc050 and 6ea1e74.

📒 Files selected for processing (7)
  • AGENTS.md
  • src/database/usage.py
  • src/otlp.py
  • src/proxy.py
  • tests/test_database.py
  • tests/test_otlp.py
  • tests/test_proxy.py

Comment thread AGENTS.md Outdated
@Haannbboo

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Haannbboo
Haannbboo merged commit 814b3cb into main May 23, 2026
7 checks passed
@Haannbboo
Haannbboo deleted the fix/proxy-upstream-error-propagation branch May 23, 2026 02:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant