Skip to content

Feature: add idle timeout to SSE stream reader in StreamableHTTPClientTransport #1883

@DrDexter6000

Description

@DrDexter6000

I ran into a problem where the SSE stream in StreamableHTTPClientTransport hangs indefinitely when the server starts streaming a response but then stops sending data (half-open TCP connection, server crashes mid-stream, proxy stalls, etc).

What happens

_handleSseStream reads from the SSE stream in a loop:

while (true) {
  const { value: event, done } = await reader.read();  // blocks forever if stream stalls
  if (done) break;
  // process event...
}

If the connection goes quiet (server stalls, proxy drops, network hiccup), reader.read() blocks indefinitely. There's no idle timeout, no chunk timeout, no timer of any kind.

On my setup (OpenCode Desktop via oh-my-openagent, hitting Chinese LLM providers like GLM/Qwen), this happens multiple times per day. The agent GUI shows "thinking" and never recovers — I've seen hangs over 10 minutes before giving up and restarting.

Why the caller's AbortSignal doesn't help

The caller might pass an AbortSignal (e.g. a 120s prompt timeout), but send() uses its own internal _abortController.signal for the fetch call:

async send(message, options) {
  const init = {
    signal: this._abortController?.signal  // internal signal, ignores caller's timeout
  };
  const response = await fetch(this._url, init);
  // ... if response is text/event-stream, hands off to _handleSseStream
}

The caller's signal never reaches _handleSseStream or its reader.

What I'd suggest

Add an optional idle timeout to _handleSseStream. If no SSE chunk arrives within the timeout, cancel the reader and let the reconnection logic handle it. Something like:

const SSE_IDLE_TIMEOUT_MS = options.idleTimeoutMs ?? 60_000;
let idleTimer;
const resetIdleTimer = () => {
  clearTimeout(idleTimer);
  idleTimer = setTimeout(() => {
    reader.cancel(new Error(`SSE idle timeout after ${SSE_IDLE_TIMEOUT_MS}ms`));
  }, SSE_IDLE_TIMEOUT_MS);
};
resetIdleTimer();
while (true) {
  const { value: event, done } = await reader.read();
  if (done) { clearTimeout(idleTimer); break; }
  resetIdleTimer();
  // ... process event
}

This would also need cleanup in the catch block.

My workaround

I'm currently patching _handleSseStream in my local oh-my-openagent/dist/index.js with essentially the code above (60s idle timeout). It works — the agent recovers within ~60s instead of hanging forever. But it'd be much better to have this upstream.

Thanks for the great work on this SDK!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions