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

  1. Detect StreamEvent.Err == "wsrelay: stream closed" in your consumer and retry the request, since output is truncated.
  2. Persist partial output if idempotency matters, then re-issue with the accumulated conversation so the model can resume.
  3. Increase wsrelay session deadlines (internal/wsrelay/session.go) to exceed your longest expected stream duration.
  4. 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

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


AI-assisted analysis of router-for-me/CLIProxyAPI@78f0c4079e (2026-08-15). Data as JSON: /api/errors/ce2addd41663fe44. Report an issue: GitHub.