Repository navigation
fix(cc-compatible): restore upstream SSE and correct stream/combo timeout behavior #1257
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
0c8c770
9a282a4
be03076
fa55c77
05453b9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -792,30 +792,32 @@ When you no longer need OmniRoute, we provide two quick scripts for a clean remo | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| For most deployments, you only need: | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| | Variable | Default | Purpose | | ||||||||||||||||||||||
| | ------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | ||||||||||||||||||||||
| | `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream fetch, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | | ||||||||||||||||||||||
| | `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | | ||||||||||||||||||||||
| | Variable | Default | Purpose | | ||||||||||||||||||||||
| | ------------------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | ||||||||||||||||||||||
| | `REQUEST_TIMEOUT_MS` | `600000` | Shared baseline for upstream response-start timeout, hidden Undici timeouts, TLS fingerprint requests, and API bridge request/proxy timeouts | | ||||||||||||||||||||||
| | `STREAM_IDLE_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Maximum gap between streaming chunks before OmniRoute aborts the SSE stream | | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Backward compatibility is preserved: existing `FETCH_TIMEOUT_MS`, `API_BRIDGE_PROXY_TIMEOUT_MS`, and other per-layer timeout vars still work and override the shared baseline. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| For Claude Code-compatible upstreams (`anthropic-compatible-cc-*`), OmniRoute also derives the outbound `X-Stainless-Timeout` header from the resolved fetch timeout so provider-side read timeouts stay aligned with your env configuration. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Advanced overrides are available if you need finer control: | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| | Variable | Default | Purpose | | ||||||||||||||||||||||
| | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- | | ||||||||||||||||||||||
| | `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Total upstream request timeout used by the main fetch abort signal | | ||||||||||||||||||||||
| | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | ||||||||||||||||||||||
| | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | ||||||||||||||||||||||
| | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | | ||||||||||||||||||||||
| | `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Undici idle keep-alive socket timeout | | ||||||||||||||||||||||
| | `TLS_CLIENT_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Timeout for TLS fingerprint requests made through `wreq-js` | | ||||||||||||||||||||||
| | `API_BRIDGE_PROXY_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` or `30000` | Timeout for `/v1` proxy forwarding from API port to dashboard port | | ||||||||||||||||||||||
| | `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | Incoming request timeout on the API bridge server | | ||||||||||||||||||||||
| | `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server | | ||||||||||||||||||||||
| | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | ||||||||||||||||||||||
| | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | | ||||||||||||||||||||||
| | Variable | Default | Purpose | | ||||||||||||||||||||||
| | -------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------ | | ||||||||||||||||||||||
| | `FETCH_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` | Upstream response-start timeout used until response headers arrive | | ||||||||||||||||||||||
| | `FETCH_HEADERS_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit for receiving upstream response headers | | ||||||||||||||||||||||
| | `FETCH_BODY_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Undici time limit between upstream body chunks (`0` disables it) | | ||||||||||||||||||||||
| | `FETCH_CONNECT_TIMEOUT_MS` | `30000` | Undici TCP connect timeout | | ||||||||||||||||||||||
| | `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Undici idle keep-alive socket timeout | | ||||||||||||||||||||||
| | `TLS_CLIENT_TIMEOUT_MS` | inherits `FETCH_TIMEOUT_MS` | Timeout for TLS fingerprint requests made through `wreq-js` | | ||||||||||||||||||||||
| | `API_BRIDGE_PROXY_TIMEOUT_MS` | inherits `REQUEST_TIMEOUT_MS` or `30000` | Timeout for `/v1` proxy forwarding from API port to dashboard port | | ||||||||||||||||||||||
| | `API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS` | `max(API_BRIDGE_PROXY_TIMEOUT_MS, 300000)` | Incoming request timeout on the API bridge server | | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). | ||||||||||||||||||||||
| | `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server | | ||||||||||||||||||||||
| | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | ||||||||||||||||||||||
| | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | | ||||||||||||||||||||||
|
||||||||||||||||||||||
| For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). | |
| | `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server | | |
| | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | |
| | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | | |
| | `API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS` | `60000` | Incoming header timeout on the API bridge server | | |
| | `API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS` | `5000` | Keep-alive timeout on the API bridge server | | |
| | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Socket inactivity timeout on the API bridge server (`0` disables it) | | |
| For streaming requests, `FETCH_TIMEOUT_MS` only covers connection setup / waiting for the first upstream response. Once the stream is active, OmniRoute will only abort on an actual stall (`STREAM_IDLE_TIMEOUT_MS`) or Undici body inactivity (`FETCH_BODY_TIMEOUT_MS`). |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -109,19 +109,23 @@ export function applyConfiguredUserAgent( | |||||||
| export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal { | ||||||||
| const controller = new AbortController(); | ||||||||
|
|
||||||||
| const abortBoth = () => { | ||||||||
| const abortFrom = (source: AbortSignal) => { | ||||||||
| if (!controller.signal.aborted) { | ||||||||
| controller.abort(); | ||||||||
| controller.abort(source.reason); | ||||||||
| } | ||||||||
| }; | ||||||||
|
|
||||||||
| if (primary.aborted || secondary.aborted) { | ||||||||
| abortBoth(); | ||||||||
| if (primary.aborted) { | ||||||||
| abortFrom(primary); | ||||||||
| return controller.signal; | ||||||||
| } | ||||||||
| if (secondary.aborted) { | ||||||||
| abortFrom(secondary); | ||||||||
| return controller.signal; | ||||||||
| } | ||||||||
|
|
||||||||
| primary.addEventListener("abort", abortBoth, { once: true }); | ||||||||
| secondary.addEventListener("abort", abortBoth, { once: true }); | ||||||||
| primary.addEventListener("abort", () => abortFrom(primary), { once: true }); | ||||||||
| secondary.addEventListener("abort", () => abortFrom(secondary), { once: true }); | ||||||||
|
Comment on lines
+140
to
+141
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This implementation of |
||||||||
| return controller.signal; | ||||||||
| } | ||||||||
|
|
||||||||
|
|
@@ -231,6 +235,9 @@ export class BaseExecutor { | |||||||
|
|
||||||||
| // Intra-URL retry config: retry same URL before falling back to next node | ||||||||
| static readonly RETRY_CONFIG = { maxAttempts: 2, delayMs: 2000 }; | ||||||||
| // Timeout for receiving the initial upstream response headers. Once the response | ||||||||
| // starts streaming, STREAM_IDLE_TIMEOUT_MS / Undici bodyTimeout handle stalls. | ||||||||
| static FETCH_START_TIMEOUT_MS = FETCH_TIMEOUT_MS; | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The use of a
To fix this, |
||||||||
|
|
||||||||
| // Override in subclass for provider-specific refresh | ||||||||
| async refreshCredentials(credentials: ProviderCredentials, log: ExecutorLog | null) { | ||||||||
|
|
@@ -313,12 +320,26 @@ export class BaseExecutor { | |||||||
| const transformedBody = await this.transformRequest(model, body, stream, activeCredentials); | ||||||||
|
|
||||||||
| try { | ||||||||
| // Apply timeout to all requests. Non-streaming requests need this to prevent | ||||||||
| // stalled connections. Streaming requests also need it for the initial fetch() call | ||||||||
| // to prevent hanging on unresponsive providers (e.g. 300s TCP default timeout — #769). | ||||||||
| // Stream idle detection (STREAM_IDLE_TIMEOUT_MS) handles stalls after data starts flowing. | ||||||||
| const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); | ||||||||
| const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; | ||||||||
| // Only enforce the timeout while waiting for the initial fetch() response. | ||||||||
| // Once headers arrive, active streams must not be cut off by total elapsed time; | ||||||||
| // post-start stalls are handled separately by STREAM_IDLE_TIMEOUT_MS / bodyTimeout. | ||||||||
| const fetchStartTimeoutMs = BaseExecutor.FETCH_START_TIMEOUT_MS; | ||||||||
| const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null; | ||||||||
| let timeoutId: ReturnType<typeof setTimeout> | null = null; | ||||||||
| if (timeoutController) { | ||||||||
| timeoutId = setTimeout(() => { | ||||||||
| const timeoutError = new Error( | ||||||||
| `Fetch timeout after ${fetchStartTimeoutMs}ms on ${url}` | ||||||||
| ); | ||||||||
| timeoutError.name = "TimeoutError"; | ||||||||
| timeoutController.abort(timeoutError); | ||||||||
| }, fetchStartTimeoutMs); | ||||||||
|
||||||||
| }, fetchStartTimeoutMs); | |
| }, fetchStartTimeoutMs); | |
| timeoutId.unref?.(); |
Copilot
AI
Apr 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Timeout logging uses BaseExecutor.FETCH_START_TIMEOUT_MS in the warning message, but the actual timeout duration used for this request is read into fetchStartTimeoutMs earlier. If FETCH_START_TIMEOUT_MS is changed at runtime (e.g., in tests or future dynamic config), the log can report the wrong value. Consider capturing the effective timeout outside the try/catch and using that captured value in the log message.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The insertion of explanatory text and blank lines within the Markdown table breaks its rendering. Markdown tables must be contiguous blocks of text. Additionally, the new rows are missing backticks for variable names, which is inconsistent with the rest of the document.
The suggestion below restores the table's integrity and formatting, moving the explanatory text after the table.