router-for-me/CLIProxyAPI · error
wsrelay: stream closed
Error message
wsrelay: stream closed
What it means
While streaming a relayed response, internal/wsrelay/http.go iterates respCh and forwards StreamStart/StreamChunk events. If respCh closes before the stream completes (no terminal event), it emits a StreamEvent with `Err: wsrelay: stream closed`, telling the consumer the SSE/stream body was truncated by the relay websocket shutting down rather than ending normally. Unlike error 41 this occurs in the streaming path, so partial data may already have been consumed.
Source
Thrown at internal/wsrelay/http.go:145
send := func(ev StreamEvent) bool {
if ctx == nil {
out <- ev
return true
}
select {
case <-ctx.Done():
return false
case out <- ev:
return true
}
}
for {
select {
case <-ctx.Done():
return
case msg, ok := <-respCh:
if !ok {
_ = send(StreamEvent{Err: errors.New("wsrelay: stream closed")})
return
}
switch msg.Type {
case MessageTypeStreamStart:
resp := decodeResponse(msg.Payload)
if okSend := send(StreamEvent{Type: MessageTypeStreamStart, Status: resp.Status, Headers: resp.Headers}); !okSend {
return
}
case MessageTypeStreamChunk:
chunk := decodeChunk(msg.Payload)
if okSend := send(StreamEvent{Type: MessageTypeStreamChunk, Payload: chunk}); !okSend {
return
}
case MessageTypeStreamEnd:
_ = send(StreamEvent{Type: MessageTypeStreamEnd})
return
case MessageTypeError:
_ = send(StreamEvent{Type: MessageTypeError, Err: decodeError(msg.Payload)})View on GitHub (pinned to 78f0c4079e)
Solutions
- Detect StreamEvent.Err == "wsrelay: stream closed" in your consumer and retry the request, since output is truncated.
- Persist partial output if idempotency matters, then re-issue with the accumulated conversation so the model can resume.
- Increase wsrelay session deadlines (internal/wsrelay/session.go) to exceed your longest expected stream duration.
- Ensure the relay peer sends MessageTypeError on upstream failure instead of closing, so clients can distinguish errors from truncation.
Defensive patterns
Strategy: retry
Type guard
func isStreamClosedEvent(ev StreamEvent) bool {
return ev.Err != nil && strings.Contains(ev.Err.Error(), "wsrelay: stream closed")
} Try / catch
for ev := range stream {
if isStreamClosedEvent(ev) {
// output truncated: retry with accumulated transcript so far
savePartial(ev)
retryWithTranscript()
return
}
consume(ev)
} Prevention
- Persist partial streamed output as it arrives so a closed stream is recoverable, not lost.
- Keep relay session deadlines larger than maximum stream duration.
- Ensure relay peers emit MessageTypeError instead of closing on upstream failure.
When it happens
Trigger: Streaming relay request where the websocket closes mid-body: relay peer restart, session deadline reached during a long stream, network drop, or upstream aborting the stream and the peer closing instead of sending MessageTypeError.
Common situations: Long completions relayed over wsrelay that exceed session deadlines; mobile/unstable clients; relay behind aggressive LB idle timeouts that do not honor in-flight chunks; Codex executor streams that die mid-generation.
Related errors
- wsrelay: connection closed during response
- count must be a positive integer
- invalid_realtime_client_secret
- invalid_expires_after
- Codex live multipart body requires an sdp field
AI-assisted analysis of router-for-me/CLIProxyAPI@78f0c4079e (2026-08-15).
Data as JSON: /api/errors/ce2addd41663fe44.
Report an issue: GitHub.