mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-04 05:28:30 +00:00
A Streamable HTTP server allows one standalone `GET` SSE stream per session and
releases its mapping from the response stream's cancel callback. That callback
never runs when the connection dies at a proxy rather than at the client, so the
server keeps holding a stream nobody is reading while the client knows its stream
is gone. Every reconnect carrying that session id then gets a 409:
SSE stream disconnected: TypeError: terminated
Transport error (may require manual intervention):
Streamable HTTP error: Failed to open SSE stream: Conflict
Transport error (may require manual intervention):
Maximum reconnection attempts (2) exceeded.
Nothing there requires manual intervention. The connection recovers on its own in
a few seconds, because the rebuild the first 409 escalates to sends the
spec-mandated `DELETE`, which drops the server's session along with the stream it
leaked. Two things made a self-healing event read as a fatal one.
`extractSSEErrorMessage` classified status by scanning the message text for
digits, but `StreamableHTTPError` and `SseError` carry the status on `code` and
their messages do not always repeat it. "Failed to open SSE stream: Conflict"
has no digits at all, so a 409 never reached the status branch and fell through
to the terminal `isTransient: false` — the same verdict as a DNS typo. A 5xx
arriving on `code` alone had the same blind spot. The status is now read from
`code` when it is in HTTP range, with the message scan kept as a fallback, and
409 joins 5xx as transient: the stale session it reports is cleared by the
rebuild, with nothing for an operator to do.
The second is volume. Each SDK retry fires `onerror` twice — once with the raw
throw out of `_startOrAuthSse`, once with the `Failed to reconnect SSE stream`
wrapper. Only the wrapper matched the existing suppression, so every doomed retry
logged at error level, and the retries are doomed by construction: nothing about
the same session id can stop conflicting. The first conflict now escalates for
rebuild and the rest are logged as the echo they are, along with the SDK's
out-of-retries announcement when a rebuild is already underway. The non-conflict
path for that announcement is untouched, so an exhausted budget still falls
through to our reconnection everywhere else.
`extractSSEErrorMessage` moves to `errors.ts` alongside `isOAuthAuthenticationError`.
It had no test: `MCPConnection.test.ts` held a hand-copied clone marked "keep in
sync with the actual implementation", so 66 assertions were exercising the copy.
The clone is deleted and the suite now imports the real function, which it turns
out had not drifted.
`MCPConnectionSseConflict.test.ts` drives a real client transport against a real
in-process `StreamableHTTPServerTransport` reproducing the sequence above: the
stream opens, its socket is destroyed underneath the client, and every later
`GET` on that session id conflicts while a rebuilt session gets a healthy stream.
|
||
|---|---|---|
| .. | ||
| src | ||
| types | ||
| .gitignore | ||
| babel.config.cjs | ||
| jest.config.mjs | ||
| jest.setup.cjs | ||
| package.json | ||
| tsconfig-paths-bootstrap.mjs | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| tsconfig.spec.json | ||
| tsdown.config.mjs | ||