Skip to content

fix: improve streaming error diagnostics and logger serialization - #1866

Merged
steebchen merged 3 commits into
mainfrom
ariana/marcus-a348
Mar 21, 2026
Merged

steebchen merged 3 commits into
mainfrom
ariana/marcus-a348

Conversation

@steebchen

@steebchen steebchen commented Mar 21, 2026 •

Copy link
Copy Markdown
Member

Summary

  • normalize mid-stream provider failures so terminated upstream reads surface as structured stream errors instead of a generic streaming failure
  • add richer gateway logging for stream read failures, including provider/model/request context and upstream header diagnostics
  • teach the shared logger to serialize Error instances passed to warn/info/debug/trace, so warning-level logs keep stack traces

Testing

  • pnpm vitest run packages/logger/src/logger.spec.ts apps/gateway/src/chat/tools/normalize-streaming-error.spec.ts apps/gateway/src/chat/tools/extract-error-cause.spec.ts
  • pnpm exec tsc -p packages/logger/tsconfig.json --noEmit
  • pnpm exec tsc -p apps/gateway/tsconfig.json --noEmit

Summary by CodeRabbit

  • New Features

    • Error payloads now include a unified finish reason for clearer failure classification.
  • Bug Fixes

    • Streaming errors are normalized and reported with consistent HTTP status, client messages, and richer diagnostics.
    • Stream error events forward a standardized client error shape and improved logging fields.
  • Chores

    • Logging methods accept Error objects as an extra argument and merge them into structured logs.
  • Tests

    • Added tests for streaming error normalization and logging behavior.

Copilot AI review requested due to automatic review settings March 21, 2026 09:01
@coderabbitai

coderabbitai Bot commented Mar 21, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

Adds a streaming error normalization utility and integrates it into gateway chat streaming/error flows, computes and attaches unified finish reasons across upstream failure paths, and updates logger methods to accept Error extras for structured logging. Tests added for normalization and logger behavior.

Changes

Cohort / File(s) Summary
Streaming Error Normalization
apps/gateway/src/chat/tools/normalize-streaming-error.ts, apps/gateway/src/chat/tools/normalize-streaming-error.spec.ts
New normalizeStreamingError() with exported types that classifies streaming failures (detects upstream termination vs generic read errors) and returns structured client-facing and log-ready objects; Vitest tests cover upstream termination and generic read errors.
Gateway Chat Streaming & Error Paths
apps/gateway/src/chat/chat.ts
Replaced ad-hoc upstream-read error handling with normalizeStreamingError(...); emits richer structured logs (including unifiedFinishReason) on multiple upstream failure paths; forwards normalized SSE event: "error" payloads to clients; computes/attaches unifiedFinishReason for various error branches (timeouts, fetch errors, non-OK responses, empty responses, streaming and non-streaming reads).
Logger API & Tests
packages/logger/src/index.ts, packages/logger/src/logger.spec.ts
Logging methods (trace, debug, info, warn) accept `object

Sequence Diagram

sequenceDiagram
    participant Client
    participant Gateway as chat.ts
    participant Upstream as Provider
    participant Normalizer as normalize-streaming-error
    participant Logger
    participant Responder as SSE Response

    Client->>Gateway: request stream
    Gateway->>Upstream: open/read upstream stream
    Upstream-->>Gateway: stream chunk / error
    Gateway->>Normalizer: normalizeStreamingError(error, phase, provider, model, bufferSnapshot...)
    Normalizer-->>Gateway: NormalizedStreamingError { client, log, statusCode, details }
    Gateway->>Logger: logger.error("Error reading upstream stream", NormalizedStreamingError.log)
    Gateway->>Responder: send SSE event:"error" with normalized.client (+ unified_finish_reason)
    Responder-->>Client: SSE error event
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: adding error normalization for streaming failures and updating logger serialization to handle Error instances.

✏️ 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 ariana/marcus-a348

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copilot AI 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.

Pull request overview

This PR improves observability and client-facing diagnostics for streaming failures in the gateway by (1) normalizing mid-stream upstream/provider read errors into a structured error payload and (2) enhancing logger behavior so Error instances passed to non-error log levels retain stack/context.

Changes:

  • Add normalizeStreamingError() utility + tests to classify upstream stream terminations and produce consistent client/log shapes.
  • Expand gateway streaming read failure logging with upstream response/header context and normalized diagnostics, and forward structured SSE error events to clients.
  • Update the shared logger to map Error extras passed to trace/debug/info/warn into err for proper serialization; add unit test.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/logger/src/index.ts Adds mergeOptionalArg() and extends trace/debug/info/warn to accept Error and serialize via err.
packages/logger/src/logger.spec.ts Adds coverage ensuring warn() maps an Error extra into { err }.
apps/gateway/src/chat/tools/normalize-streaming-error.ts New normalization helper producing structured client SSE error payload + log diagnostics.
apps/gateway/src/chat/tools/normalize-streaming-error.spec.ts Tests terminated-stream classification vs generic streaming errors.
apps/gateway/src/chat/chat.ts Uses normalization helper for stream read failures; improves error logging context and SSE error payload structure.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +113 to +117
? "Upstream Stream Terminated"
: "Streaming Read Error";
const message = terminated
? "Upstream stream terminated unexpectedly before completion"
: `Streaming error: ${rawMessage}`;

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

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

phase is part of the options/details but it isn’t used to shape statusText/message (e.g. an upstream_connect failure that matches the termination heuristics would still be labeled "Upstream Stream Terminated"). Consider either (a) restricting the termination classification to phase === "upstream_read", or (b) branching statusText/message on phase so connect vs read failures are described accurately.

Suggested change
? "Upstream Stream Terminated"
: "Streaming Read Error";
const message = terminated
? "Upstream stream terminated unexpectedly before completion"
: `Streaming error: ${rawMessage}`;
? phase === "upstream_connect"
? "Upstream Connection Terminated"
: "Upstream Stream Terminated"
: phase === "upstream_connect"
? "Upstream Connection Error"
: "Streaming Read Error";
const message = terminated
? phase === "upstream_connect"
? "Upstream connection terminated unexpectedly before completion"
: "Upstream stream terminated unexpectedly before completion"
: phase === "upstream_connect"
? `Streaming connection error: ${rawMessage}`
: `Streaming error: ${rawMessage}`;

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
apps/gateway/src/chat/tools/normalize-streaming-error.ts (1)

52-79: Consider extracting error code retrieval into extractErrorCause or a shared utility.

Both getErrorCode here and extractErrorCause in the sibling module traverse the error cause chain up to 5 levels deep. This creates a minor duplication of the traversal logic.

♻️ Optional: Unify cause chain traversal

You could potentially extend extractErrorCause to also return error codes, or create a shared helper that both functions use. However, keeping them separate is also reasonable given their different purposes (code extraction vs. human-readable cause string).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/chat/tools/normalize-streaming-error.ts` around lines 52 -
79, The getErrorCode function duplicates cause-chain traversal logic found in
extractErrorCause; refactor by extracting the traversal into a shared helper
(e.g., traverseErrorCauses) or extend extractErrorCause to optionally return a
code, then have getErrorCode call that helper to locate a string-valued code up
to the same depth (5) instead of reimplementing the loop; update getErrorCode to
delegate to the shared utility and remove the inline traversal so both modules
reuse the same cause-chain logic.
packages/logger/src/logger.spec.ts (1)

50-69: Test correctly validates Error serialization, but uses as any cast.

The test properly verifies that Error instances passed to warn() are serialized under an err field. The as any cast on line 56 is used to access the private logger property for stubbing, which is a common pattern in tests. Consider exposing a test hook or using dependency injection if this pattern becomes prevalent.

As per coding guidelines: "Never use any or as any in TypeScript unless absolutely necessary" - in test code for accessing private members, this is a reasonable exception.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/logger/src/logger.spec.ts` around lines 50 - 69, The test uses an
`as any` cast to access the private `logger` property on the object returned by
createLogger to stub `customLogger.logger`; replace the cast by adding a small
test-only access point or injection: either add an optional parameter to
createLogger to inject a backing logger for tests or expose a test-only getter
(e.g., getInternalLogger(instance)) that returns the private `logger`, then
update the spec to call that accessor instead of using `as any`; keep references
to createLogger, customLogger.logger and the warn call so the test still asserts
that warn(serializes Error under err, message) correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/gateway/src/chat/tools/normalize-streaming-error.ts`:
- Around line 52-79: The getErrorCode function duplicates cause-chain traversal
logic found in extractErrorCause; refactor by extracting the traversal into a
shared helper (e.g., traverseErrorCauses) or extend extractErrorCause to
optionally return a code, then have getErrorCode call that helper to locate a
string-valued code up to the same depth (5) instead of reimplementing the loop;
update getErrorCode to delegate to the shared utility and remove the inline
traversal so both modules reuse the same cause-chain logic.

In `@packages/logger/src/logger.spec.ts`:
- Around line 50-69: The test uses an `as any` cast to access the private
`logger` property on the object returned by createLogger to stub
`customLogger.logger`; replace the cast by adding a small test-only access point
or injection: either add an optional parameter to createLogger to inject a
backing logger for tests or expose a test-only getter (e.g.,
getInternalLogger(instance)) that returns the private `logger`, then update the
spec to call that accessor instead of using `as any`; keep references to
createLogger, customLogger.logger and the warn call so the test still asserts
that warn(serializes Error under err, message) correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: aab63360-1236-4f54-b5d5-cd7d6cf11de6

📥 Commits

Reviewing files that changed from the base of the PR and between 66f0932 and 1df4014.

📒 Files selected for processing (5)
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/chat/tools/normalize-streaming-error.spec.ts
  • apps/gateway/src/chat/tools/normalize-streaming-error.ts
  • packages/logger/src/index.ts
  • packages/logger/src/logger.spec.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/gateway/src/chat/chat.ts`:
- Around line 127-164: In getUnifiedFinishReasonForError, remove the early
return when errorType is falsy so the function will fall through to the existing
getUnifiedFinishReason and HTTP status mapping logic; keep the existing
special-case branches (errorType === "invalid_request_error",
"upstream_timeout", "request_canceled"/"canceled") intact, call
getUnifiedFinishReason(errorType, undefined) next, and only if that still yields
UNKNOWN use the status checks (401/403 => GATEWAY_ERROR, 4xx => CLIENT_ERROR,
5xx => UPSTREAM_ERROR) before returning UNKNOWN—this ensures missing error.type
will allow the status-based fallback to run.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ba3bd408-b8e0-47fd-9e7c-55feaa80c28d

📥 Commits

Reviewing files that changed from the base of the PR and between 1df4014 and fb58c2f.

📒 Files selected for processing (1)
  • apps/gateway/src/chat/chat.ts

Comment thread apps/gateway/src/chat/chat.ts Outdated
Comment on lines +127 to +164
function getUnifiedFinishReasonForError(
errorType: string | undefined,
status?: number,
) {
if (!errorType) {
return UnifiedFinishReason.UNKNOWN;
}

if (errorType === "invalid_request_error") {
return UnifiedFinishReason.CLIENT_ERROR;
}

if (errorType === "upstream_timeout") {
return UnifiedFinishReason.UPSTREAM_ERROR;
}

if (errorType === "request_canceled" || errorType === "canceled") {
return UnifiedFinishReason.CANCELED;
}

const unifiedFinishReason = getUnifiedFinishReason(errorType, undefined);
if (unifiedFinishReason !== UnifiedFinishReason.UNKNOWN) {
return unifiedFinishReason;
}

if (status !== undefined) {
if (status === 401 || status === 403) {
return UnifiedFinishReason.GATEWAY_ERROR;
}
if (status >= 400 && status < 500) {
return UnifiedFinishReason.CLIENT_ERROR;
}
if (status >= 500) {
return UnifiedFinishReason.UPSTREAM_ERROR;
}
}

return UnifiedFinishReason.UNKNOWN;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Let the status fallback run when error.type is missing.

The early return on Line 131 makes the status mapping on Line 152 unreachable. Any passthrough payload that has an error object but omits error.type will therefore still get unified_finish_reason: "unknown" in the branches at Line 3232 and Line 5937, even though the HTTP status is already available.

🛠️ Suggested fix
 function getUnifiedFinishReasonForError(
 	errorType: string | undefined,
 	status?: number,
 ) {
-	if (!errorType) {
-		return UnifiedFinishReason.UNKNOWN;
-	}
-
 	if (errorType === "invalid_request_error") {
 		return UnifiedFinishReason.CLIENT_ERROR;
 	}
@@
-	const unifiedFinishReason = getUnifiedFinishReason(errorType, undefined);
-	if (unifiedFinishReason !== UnifiedFinishReason.UNKNOWN) {
-		return unifiedFinishReason;
+	if (errorType) {
+		const unifiedFinishReason = getUnifiedFinishReason(errorType, undefined);
+		if (unifiedFinishReason !== UnifiedFinishReason.UNKNOWN) {
+			return unifiedFinishReason;
+		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/chat/chat.ts` around lines 127 - 164, In
getUnifiedFinishReasonForError, remove the early return when errorType is falsy
so the function will fall through to the existing getUnifiedFinishReason and
HTTP status mapping logic; keep the existing special-case branches (errorType
=== "invalid_request_error", "upstream_timeout", "request_canceled"/"canceled")
intact, call getUnifiedFinishReason(errorType, undefined) next, and only if that
still yields UNKNOWN use the status checks (401/403 => GATEWAY_ERROR, 4xx =>
CLIENT_ERROR, 5xx => UPSTREAM_ERROR) before returning UNKNOWN—this ensures
missing error.type will allow the status-based fallback to run.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/gateway/src/chat/chat.ts (1)

4348-4416: ⚠️ Potential issue | 🔴 Critical

Remove unreachable ternary operator on client.type.

The condition at line 4384–4386 is always true and produces dead code:

normalizedStreamingError.client.type === "gateway_error" ? "gateway_error" : "upstream_error"

The NormalizedStreamingError.client.type is a literal type "gateway_error" (per the interface definition), and the function always returns that value. The ternary operator should be replaced with the literal string "gateway_error":

unifiedFinishReason: getUnifiedFinishReason("gateway_error", usedProvider)

If the intent was to pass different finish reasons based on error classification, the normalizeStreamingError function should be modified to return a discriminated union or the error type detection should happen separately.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/chat/chat.ts` around lines 4348 - 4416, The ternary checking
normalizedStreamingError.client.type is unreachable because client.type is
always the literal "gateway_error"; replace the expression in the
getUnifiedFinishReason call inside the error handling block so it passes the
literal "gateway_error" (i.e., unifiedFinishReason:
getUnifiedFinishReason("gateway_error", usedProvider)), or if you actually need
different finish reasons, change normalizeStreamingError to return a
discriminated union (e.g., client.type = "gateway_error" | "upstream_error") and
update call-sites accordingly; locate the call in the error logging/streaming
block where unifiedFinishReason is computed and make the corresponding change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@apps/gateway/src/chat/chat.ts`:
- Around line 4348-4416: The ternary checking
normalizedStreamingError.client.type is unreachable because client.type is
always the literal "gateway_error"; replace the expression in the
getUnifiedFinishReason call inside the error handling block so it passes the
literal "gateway_error" (i.e., unifiedFinishReason:
getUnifiedFinishReason("gateway_error", usedProvider)), or if you actually need
different finish reasons, change normalizeStreamingError to return a
discriminated union (e.g., client.type = "gateway_error" | "upstream_error") and
update call-sites accordingly; locate the call in the error logging/streaming
block where unifiedFinishReason is computed and make the corresponding change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ed2e8d88-6fcd-4d93-bc09-66d48efdeae4

📥 Commits

Reviewing files that changed from the base of the PR and between fb58c2f and 50a84b0.

📒 Files selected for processing (1)
  • apps/gateway/src/chat/chat.ts

@steebchen
steebchen added this pull request to the merge queue Mar 21, 2026
Merged via the queue into main with commit d8310a6 Mar 21, 2026
13 of 14 checks passed
@steebchen
steebchen deleted the ariana/marcus-a348 branch March 21, 2026 09:38
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.

2 participants