Skip to content

fix: handle TimeoutError and AbortError with proper severity and status codes - #1661

Merged
steebchen merged 4 commits into
mainfrom
fix-timeout-error-logging
Feb 13, 2026
Merged

steebchen merged 4 commits into
mainfrom
fix-timeout-error-logging

Conversation

@steebchen

@steebchen steebchen commented Feb 13, 2026 •

Copy link
Copy Markdown
Member

Summary

  • TimeoutError from AbortSignal.timeout() was reaching the global app.onError handler and being logged at error level as "Unhandled error" with a 500 response. These are expected operational conditions (slow upstream providers), not application bugs.
  • AbortError from client disconnects was similarly misclassified as an unhandled error.
  • All streamSSE calls lacked onError callbacks, causing errors inside streaming callbacks to be silently swallowed by console.error() instead of routed through the structured pino logger.

Changes

  • Gateway & API app.onError: Detect TimeoutError (log at warn, return 504 Gateway Timeout) and AbortError (log at info, return 499 Client Closed Request) before the generic 500 fallback
  • streamSSE onError callbacks: Added to all three streamSSE call sites (main streaming handler, cached stream replay, Anthropic handler) so escaped errors are logged through the structured logger with appropriate severity
  • No changes to the existing timeout handling logic in the chat handler — that code already correctly catches and handles timeouts during fetch and stream reading

Test plan

  • Verify build passes (confirmed locally: tsc --noEmit clean, turbo build succeeds)
  • Verify existing timeout tests still pass (tests require Redis; logic unchanged)
  • Deploy to staging and confirm timeout errors now log at warn level instead of error
  • Confirm client disconnects log at info level and return 499

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Added explicit handling for request timeouts and client-initiated connection aborts, producing appropriate responses and logs for improved reliability.
    • Improved streaming integration with better error handling, per-chunk safeguards, structured message/tool event emission, and stronger resource cleanup for more stable streaming behavior.

…us codes

TimeoutError from AbortSignal.timeout() was reaching the global app.onError
handler and being logged at error level as "Unhandled error" with a 500 response.
These are expected operational conditions (slow upstream providers, client
disconnects), not application bugs.

- Gateway/API app.onError: detect TimeoutError (warn, 504) and AbortError (info, 499)
- Add onError callbacks to all streamSSE calls so errors route through the
  structured logger instead of being silently swallowed by console.error
- Anthropic handler streamSSE gets same treatment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 13, 2026 11:03
@coderabbitai

coderabbitai Bot commented Feb 13, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

Adds explicit TimeoutError (504) and AbortError (499) handling in global error handlers (API and gateway). Reworks the Anthropic streaming path to a consolidated SSE reader with per-chunk JSON guards, structured streaming events (message_start, content_block_*, message_delta), tool_call tracking, and robust reader cleanup and error/abort logging.

Changes

Cohort / File(s) Summary
API & Gateway Error Handling
apps/api/src/index.ts, apps/gateway/src/app.ts
Added explicit branches in global error handlers for TimeoutError (respond 504) and AbortError (respond 499), with request-context logging; preserves existing HTTPException behavior.
Anthropic Streaming Refactor
apps/gateway/src/anthropic/anthropic.ts
Reworked streaming path to use a single streamSSE flow with try/catch/finally around the reader; added per-chunk JSON-parse guards, client-abort vs other-error callbacks, structured SSE events (message_start, content_block_start/delta/stop, message_delta with usage/stop_reason), tool_call index tracking/aggregation, and ensured reader.releaseLock()/cleanup in finally.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Gateway as Gateway
    participant Anthropic as Anthropic
    participant StreamHandler as StreamHandler

    Client->>Gateway: POST /chat (stream)
    Gateway->>Anthropic: Proxy request (fetch stream)
    Anthropic-->>Gateway: Response stream (readable)

    Gateway->>StreamHandler: start streamSSE(reader, callbacks)

    loop for each chunk
        Anthropic-->>StreamHandler: chunk (bytes)
        StreamHandler->>StreamHandler: parse chunk -> JSON (try/catch)
        alt first chunk with id
            StreamHandler->>Client: SSE message_start
        end
        alt text/content block
            StreamHandler->>Client: SSE content_block_start
            StreamHandler->>Client: SSE content_block_delta
        else tool_use block
            StreamHandler->>StreamHandler: track tool_call index/id
            StreamHandler->>Client: SSE content_block_start (tool)
            StreamHandler->>Client: SSE content_block_delta (tool updates)
        end
    end

    alt stream finished
        StreamHandler->>Client: SSE content_block_stop (all)
        StreamHandler->>Client: SSE message_delta (stop_reason, usage)
    else stream error
        StreamHandler->>StreamHandler: log error
        StreamHandler->>Client: HTTP 500 / error SSE
    else client abort
        StreamHandler->>StreamHandler: log AbortError
        StreamHandler->>Client: treat as aborted (499)
    end

    StreamHandler->>StreamHandler: reader.releaseLock() / cleanup (finally)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (10 files):

⚔️ apps/api/src/index.ts (content)
⚔️ apps/api/src/routes/user.ts (content)
⚔️ apps/gateway/src/anthropic/anthropic.ts (content)
⚔️ apps/gateway/src/app.ts (content)
⚔️ apps/gateway/src/chat/chat.ts (content)
⚔️ apps/gateway/src/chat/tools/extract-token-usage.ts (content)
⚔️ apps/gateway/src/chat/tools/parse-provider-response.ts (content)
⚔️ apps/gateway/src/lib/rate-limit.spec.ts (content)
⚔️ apps/gateway/src/lib/rate-limit.ts (content)
⚔️ apps/ui/src/components/models/model-comparison.tsx (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main changes: adding proper error handling for TimeoutError and AbortError with appropriate HTTP status codes and logging severity.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-timeout-error-logging
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch fix-timeout-error-logging
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

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.

@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

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/anthropic/anthropic.ts (1)

810-822: ⚠️ Potential issue | 🟡 Minor

determineStopReason returns "end_turn" for undefined input — should return null.

The default case catches undefined and any unrecognized finish reasons, returning "end_turn". This is incorrect when called from the non-streaming path (line 797) where finish_reason may be undefined (e.g., if choices is empty or missing). According to the Anthropic schema (line 139-141), stop_reason is nullable, so null is the appropriate value for unknown/absent reasons.

Proposed fix
 function determineStopReason(
 	finishReason: string | undefined,
 ): "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | null {
 	switch (finishReason) {
 		case "stop":
 			return "end_turn";
 		case "length":
 			return "max_tokens";
 		case "tool_calls":
 			return "tool_use";
 		default:
-			return "end_turn";
+			return finishReason ? "end_turn" : null;
 	}
 }
🤖 Fix all issues with AI agents
In `@apps/gateway/src/anthropic/anthropic.ts`:
- Around line 536-716: The inner try/catch currently wraps JSON.parse(data) plus
all stream.writeSSE calls, which hides write errors; change it so only the parse
is guarded: wrap JSON.parse(data) in its own try/catch that on parse failure
continues the loop, but move all logic that reads from the parsed chunk (the
handling of chunk, choice, delta, contentBlocks, tool calls, finish_reason and
all stream.writeSSE calls) outside that parse-catch so writeSSE failures
propagate to the outer error handler; specifically adjust the block around
JSON.parse(data) and ensure symbols like JSON.parse(data), chunk, choice, delta,
contentBlocks, toolCallBlockIndex, and stream.writeSSE are kept in the same
scope after a successful parse.
🧹 Nitpick comments (2)
apps/api/src/index.ts (1)

109-124: Sending a JSON response to a disconnected client may be futile.

When the client has already disconnected (AbortError), c.json(...) will attempt to write to a closed connection. This is likely harmless (Hono/the runtime should swallow the write error), but it's worth being aware that the 499 response body will never reach anyone. The logging at info level is the real value here.

The 499 as any cast is acceptable given Hono's StatusCode type doesn't include non-standard codes.

apps/gateway/src/app.ts (1)

130-164: Consider extracting shared error-handling logic into a reusable utility.

This block is nearly identical to apps/api/src/index.ts lines 92–124. If these two services evolve together, a shared helper (e.g., in a @llmgateway/shared or @llmgateway/errors package) returning { status, body, logLevel } would keep them in sync.

Not urgent — fine to defer given these are separate apps.

Comment thread apps/gateway/src/anthropic/anthropic.ts

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 error handling for timeout and client disconnection scenarios in the LLM Gateway. Previously, TimeoutError from AbortSignal.timeout() and AbortError from client disconnects were reaching the global error handler and being logged at error level as "Unhandled error" with 500 status codes, despite being expected operational conditions rather than application bugs.

Changes:

  • Added TimeoutError and AbortError detection in gateway and API app.onError handlers with appropriate log levels (warn/info) and HTTP status codes (504/499)
  • Added onError callbacks to all three streamSSE call sites to route escaped streaming errors through the structured pino logger instead of console.error()
  • Preserved existing timeout handling logic in the chat handler - no changes to the catch blocks that already handle timeouts correctly

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
apps/gateway/src/app.ts Added TimeoutError (504) and AbortError (499) handlers before the generic 500 fallback in the global error handler
apps/api/src/index.ts Added TimeoutError (504) and AbortError (499) handlers before the generic 500 fallback in the global error handler
apps/gateway/src/chat/chat.ts Added onError callbacks to streamSSE for cached stream replay and main streaming handler to log errors with appropriate severity
apps/gateway/src/anthropic/anthropic.ts Added onError callback to streamSSE for Anthropic handler to log client disconnections at info level
Comments suppressed due to low confidence (1)

apps/gateway/src/chat/chat.ts:1218

  • This use of variable 'usedProvider' always evaluates to true.
	if (!routingMetadata && usedProvider && usedProvider !== "llmgateway") {

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

data: "[DONE]",
id: String(eventId++),
});
doneSent = true;

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

The value assigned to doneSent here is unused.

Suggested change
doneSent = true;

Copilot uses AI. Check for mistakes.
// Send final usage chunk before [DONE] if we have any usage data
if (
finalPromptTokens !== null ||
finalCompletionTokens !== null ||

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

Variable 'finalCompletionTokens' cannot be of type null, but it is compared to an expression of type null.

Copilot uses AI. Check for mistakes.
if (
finalPromptTokens !== null ||
finalCompletionTokens !== null ||
finalTotalTokens !== null

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

Variable 'finalTotalTokens' cannot be of type null, but it is compared to an expression of type null.

Copilot uses AI. Check for mistakes.
);
// For cancelled requests, determine if we should include token counts for billing
const shouldIncludeTokensForBilling =
!canceled || (canceled && billCancelledRequests);

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

This use of variable 'canceled' always evaluates to true.

Suggested change
!canceled || (canceled && billCancelledRequests);
!canceled || billCancelledRequests;

Copilot uses AI. Check for mistakes.
steebchen and others added 3 commits February 13, 2026 12:36
The actual root cause: after a successful fetch() in the non-streaming path,
res.json() and res.text() can still throw TimeoutError if the abort signal
fires during body consumption. These calls were outside the fetch try-catch,
so the error propagated uncaught to app.onError.

Wrap both body-read sites (error path res.text and success path res.json)
with try-catch that detects TimeoutError and returns a proper 504 response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The body-read timeout catch blocks were returning 504 without calling
insertLog, so these requests vanished from the database. Now they log
with finishReason: "upstream_error", hasError: true, and errorDetails
containing statusCode (the actual HTTP status from headers),
statusText: "TimeoutError", matching the existing fetch-level timeout
logging pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…JSON.parse

The inner try-catch was wrapping both JSON.parse(data) and all stream.writeSSE
calls, silently swallowing write errors. Now only JSON.parse is guarded (with
continue on parse failure), so writeSSE failures propagate to the outer error
handler where they become HTTPException 500s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@steebchen
steebchen enabled auto-merge February 13, 2026 14:51

@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: 2

🤖 Fix all issues with AI agents
In `@apps/gateway/src/anthropic/anthropic.ts`:
- Around line 720-737: The catch currently wraps every error into a new
HTTPException which hides original AbortError/TimeoutError from the onError
callback; update the catch in the streaming handler so that if the caught error
is an AbortError (and optionally a TimeoutError) you re-throw the original error
instead of throwing HTTPException, otherwise wrap/throw HTTPException as before;
adjust the block around reader.read() / stream.writeSSE() and the catch that
throws HTTPException (referencing reader.read(), stream.writeSSE(),
HTTPException, onError, AbortError, TimeoutError) so client disconnects reach
the onError branch and trigger the info-level logging.
- Around line 728-738: Update the onError handler passed to streamSSE to match
the expected signature (error: Error, stream: SSEStreamingApi) by changing the
async callback from (error) => { ... } to (error, stream) => { ... }; inside the
handler preserve existing logic (check error.name === "AbortError" and log via
logger.info with message/path, otherwise logger.error) and, if needed, use the
supplied stream parameter to perform any stream-specific cleanup or logging for
the Anthropic streaming request (reference the existing onError callback in
anthropic.ts).
🧹 Nitpick comments (1)
apps/gateway/src/anthropic/anthropic.ts (1)

536-536: let chunk: any — consider a minimal structural type.

The coding guidelines state: "Never use any or as any type assertions in TypeScript code unless absolutely necessary." Since chunk is the result of JSON.parse on an OpenAI-format SSE payload, a lightweight interface (even a partial one covering .id, .model, .choices, .usage) would improve safety without much overhead. Low priority given the rest of the file also uses any in several places.

Comment on lines +720 to +737
} catch (error) {
throw new HTTPException(500, {
message: `Streaming error: ${error instanceof Error ? error.message : String(error)}`,
});
} finally {
reader.releaseLock();
}
} catch (error) {
throw new HTTPException(500, {
message: `Streaming error: ${error instanceof Error ? error.message : String(error)}`,
});
} finally {
reader.releaseLock();
}
});
},
async (error) => {
if (error.name === "AbortError") {
logger.info("Anthropic streaming request aborted by client", {
message: error.message,
path: c.req.path,
});
} else {
logger.error("Anthropic streaming error (escaped handler)", error);
}
},

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 | 🟠 Major

Bug: catch wraps all errors (including AbortError) in HTTPException, making the onError AbortError branch dead code.

Every reader.read() and stream.writeSSE() call lives inside the try block (lines 506–719). When a client disconnects, the resulting AbortError is caught at line 720 and re-thrown as HTTPException(500). The onError callback then sees error.name === "HTTPException", never "AbortError", so the info-level logging path on line 729 is unreachable. This defeats the PR's stated goal of logging client disconnects at info level for the Anthropic handler.

Re-throw AbortError (and optionally TimeoutError) so they reach onError with their original identity:

Proposed fix
 			} catch (error) {
+				if (
+					error instanceof Error &&
+					(error.name === "AbortError" || error.name === "TimeoutError")
+				) {
+					throw error;
+				}
 				throw new HTTPException(500, {
 					message: `Streaming error: ${error instanceof Error ? error.message : String(error)}`,
 				});
 			} finally {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
throw new HTTPException(500, {
message: `Streaming error: ${error instanceof Error ? error.message : String(error)}`,
});
} finally {
reader.releaseLock();
}
} catch (error) {
throw new HTTPException(500, {
message: `Streaming error: ${error instanceof Error ? error.message : String(error)}`,
});
} finally {
reader.releaseLock();
}
});
},
async (error) => {
if (error.name === "AbortError") {
logger.info("Anthropic streaming request aborted by client", {
message: error.message,
path: c.req.path,
});
} else {
logger.error("Anthropic streaming error (escaped handler)", error);
}
},
} catch (error) {
if (
error instanceof Error &&
(error.name === "AbortError" || error.name === "TimeoutError")
) {
throw error;
}
throw new HTTPException(500, {
message: `Streaming error: ${error instanceof Error ? error.message : String(error)}`,
});
} finally {
reader.releaseLock();
}
},
async (error) => {
if (error.name === "AbortError") {
logger.info("Anthropic streaming request aborted by client", {
message: error.message,
path: c.req.path,
});
} else {
logger.error("Anthropic streaming error (escaped handler)", error);
}
},
🤖 Prompt for AI Agents
In `@apps/gateway/src/anthropic/anthropic.ts` around lines 720 - 737, The catch
currently wraps every error into a new HTTPException which hides original
AbortError/TimeoutError from the onError callback; update the catch in the
streaming handler so that if the caught error is an AbortError (and optionally a
TimeoutError) you re-throw the original error instead of throwing HTTPException,
otherwise wrap/throw HTTPException as before; adjust the block around
reader.read() / stream.writeSSE() and the catch that throws HTTPException
(referencing reader.read(), stream.writeSSE(), HTTPException, onError,
AbortError, TimeoutError) so client disconnects reach the onError branch and
trigger the info-level logging.

Comment on lines +728 to +738
async (error) => {
if (error.name === "AbortError") {
logger.info("Anthropic streaming request aborted by client", {
message: error.message,
path: c.req.path,
});
} else {
logger.error("Anthropic streaming error (escaped handler)", error);
}
},
);

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check Hono version in apps/gateway
cd apps/gateway
cat package.json | jq '.dependencies.hono // .devDependencies.hono'

Repository: theopenco/llmgateway

Length of output: 73


🏁 Script executed:

#!/bin/bash
# Find and examine streamSSE in Hono types
fd -e "\.d\.ts$" node_modules/hono | xargs grep -l "streamSSE" | head -5

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

#!/bin/bash
# Look for the streamSSE function signature
rg "streamSSE" node_modules/hono --type ts --type tsx -A 10 | head -50

Repository: theopenco/llmgateway

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Find streaming-related files in hono
find node_modules/hono -name "*stream*" -type f | head -20

Repository: theopenco/llmgateway

Length of output: 116


🏁 Script executed:

#!/bin/bash
# Search for streamSSE signature - try different approach
rg "streamSSE" node_modules/hono -A 15 | head -60

Repository: theopenco/llmgateway

Length of output: 121


🏁 Script executed:

#!/bin/bash
# Check the actual helper index to understand exports
find node_modules/hono -path "*/helper/*" -name "*.js" -o -name "*.d.ts" | grep -i stream

Repository: theopenco/llmgateway

Length of output: 116


🏁 Script executed:

#!/bin/bash
# Find where streamSSE is imported and used
rg "streamSSE" apps/gateway/src --type ts -B 5 -A 15

Repository: theopenco/llmgateway

Length of output: 9327


🏁 Script executed:

#!/bin/bash
# Check the full context around the code in question
sed -n '715,745p' apps/gateway/src/anthropic/anthropic.ts

Repository: theopenco/llmgateway

Length of output: 751


🌐 Web query:

Hono streamSSE onError callback signature version 4.11.7 API

💡 Result:

In Hono v4.11.7, streamSSE takes an optional third argument onError with this signature:

streamSSE(
  c: Context,
  cb: (stream: SSEStreamingApi) => Promise<void>,
  onError?: (e: Error, stream: SSEStreamingApi) => Promise<void>
): Response

[1]


Fix onError callback signature to accept both error and stream parameters.

Hono v4.11.7's streamSSE accepts a third positional onError callback with signature (error: Error, stream: SSEStreamingApi) => Promise<void>, but the current callback only accepts the error parameter. Update to async (error, stream) => { ... } to match the expected signature.

🤖 Prompt for AI Agents
In `@apps/gateway/src/anthropic/anthropic.ts` around lines 728 - 738, Update the
onError handler passed to streamSSE to match the expected signature (error:
Error, stream: SSEStreamingApi) by changing the async callback from (error) => {
... } to (error, stream) => { ... }; inside the handler preserve existing logic
(check error.name === "AbortError" and log via logger.info with message/path,
otherwise logger.error) and, if needed, use the supplied stream parameter to
perform any stream-specific cleanup or logging for the Anthropic streaming
request (reference the existing onError callback in anthropic.ts).

@steebchen
steebchen added this pull request to the merge queue Feb 13, 2026
Merged via the queue into main with commit 514ecf7 Feb 13, 2026
13 checks passed
@steebchen
steebchen deleted the fix-timeout-error-logging branch February 13, 2026 15:03
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