fix(coderd/x/chatd): handle truncated provider streams (#25074)

coder/fantasy now fails closed when Anthropic or OpenAI Responses
streams close before their provider terminal events instead of yielding
a successful finish.

This bumps the fantasy replacement to coder/fantasy#33 and teaches chat
error classification to treat those failures as retryable timeout errors
with explicit stream-closed messages.

<img width="875" height="311" alt="image"
src="https://github.com/user-attachments/assets/69c6f7b5-c885-46d2-a88b-b7a2b111bd55"
/>
This commit is contained in:
Ethan
2026-05-08 15:52:42 +10:00
committed by GitHub
parent de9cdca77e
commit b6dbc5614c
9 changed files with 215 additions and 15 deletions
+58
View File
@@ -32,6 +32,11 @@ type responsesAPIDiagnosticMatch struct {
detail string
}
type streamIncompleteMatch struct {
pattern string
provider string
}
// responsesAPIDiagnosticMatches maps provider error fragments to safe
// diagnostics. Details must not include provider item IDs because they are
// returned to clients and used by operators for grepping.
@@ -46,6 +51,20 @@ var responsesAPIDiagnosticMatches = []responsesAPIDiagnosticMatch{
},
}
// streamIncompleteMatches maps provider stream-truncation errors from
// fantasy to clearer user-facing messages before broad EOF handling
// classifies them as generic transport timeouts.
var streamIncompleteMatches = []streamIncompleteMatch{
{
pattern: "anthropic stream closed before message_stop",
provider: "anthropic",
},
{
pattern: "openai responses stream closed before terminal event",
provider: "openai",
},
}
// WithProvider returns a copy of the classification using an explicit
// provider hint. Explicit provider hints are trusted over provider names
// heuristically parsed from the error text.
@@ -137,6 +156,15 @@ func Classify(err error) ClassifiedError {
})
}
if classified, ok := streamIncompleteClassification(
lower,
provider,
statusCode,
structured,
); ok {
return classified
}
deadline := errors.Is(err, context.DeadlineExceeded) || strings.Contains(lower, "context deadline exceeded")
overloadedMatch := statusCode == 529 || containsAny(lower, overloadedPatterns...)
authStrong := statusCode == 401 || containsAny(lower, authStrongPatterns...)
@@ -218,6 +246,36 @@ func Classify(err error) ClassifiedError {
})
}
func streamIncompleteClassification(
lowerMessage string,
provider string,
statusCode int,
structured providerErrorDetails,
) (ClassifiedError, bool) {
for _, match := range streamIncompleteMatches {
if !strings.Contains(lowerMessage, match.pattern) {
continue
}
if provider == "" {
provider = match.provider
}
return normalizeClassification(ClassifiedError{
Message: streamIncompleteMessage(provider),
Detail: structured.detail,
Kind: codersdk.ChatErrorKindTimeout,
Provider: provider,
Retryable: true,
StatusCode: statusCode,
RetryAfter: structured.retryAfter,
}), true
}
return ClassifiedError{}, false
}
func streamIncompleteMessage(provider string) string {
return providerSubject(provider) + " stream closed unexpectedly before the response completed."
}
func responsesAPIDiagnostic(lowerMessage, detail string) (string, bool) {
lowerDetail := strings.ToLower(detail)
for _, match := range responsesAPIDiagnosticMatches {
+29
View File
@@ -2,6 +2,7 @@ package chaterror_test
import (
"context"
"io"
"net/http"
"strings"
"testing"
@@ -45,6 +46,34 @@ func TestClassify(t *testing.T) {
StatusCode: 0,
},
},
{
name: "AnthropicMissingMessageStop",
err: xerrors.Errorf(
"anthropic stream closed before message_stop: %w",
io.EOF,
),
want: chaterror.ClassifiedError{
Message: "Anthropic stream closed unexpectedly before the response completed.",
Kind: codersdk.ChatErrorKindTimeout,
Provider: "anthropic",
Retryable: true,
StatusCode: 0,
},
},
{
name: "OpenAIResponsesMissingTerminalEvent",
err: xerrors.Errorf(
"openai responses stream closed before terminal event: %w",
io.EOF,
),
want: chaterror.ClassifiedError{
Message: "OpenAI stream closed unexpectedly before the response completed.",
Kind: codersdk.ChatErrorKindTimeout,
Provider: "openai",
Retryable: true,
StatusCode: 0,
},
},
{
name: "AuthBeatsConfig",
err: xerrors.New("authentication failed: invalid model"),
+4
View File
@@ -63,6 +63,10 @@ func terminalMessage(classified ClassifiedError) string {
// codes (surfaced separately in the payload) and remediation
// guidance (not actionable while auto-retrying).
func retryMessage(classified ClassifiedError) string {
if classified.Retryable && classified.Message != "" {
return classified.Message
}
subject := providerSubject(classified.Provider)
switch classified.Kind {
case codersdk.ChatErrorKindOverloaded:
+20
View File
@@ -1,6 +1,7 @@
package chaterror_test
import (
"io"
"testing"
"time"
@@ -47,6 +48,25 @@ func TestTerminalErrorPayloadNilForEmptyClassification(t *testing.T) {
require.Nil(t, chaterror.TerminalErrorPayload(chaterror.ClassifiedError{}))
}
func TestStreamRetryPayloadPreservesRetryableMessage(t *testing.T) {
t.Parallel()
delay := 3 * time.Second
classified := chaterror.Classify(xerrors.Errorf(
"anthropic stream closed before message_stop: %w",
io.EOF,
))
payload := chaterror.StreamRetryPayload(2, delay, classified)
require.NotNil(t, payload)
require.Equal(t,
"Anthropic stream closed unexpectedly before the response completed.",
payload.Error,
)
require.Equal(t, codersdk.ChatErrorKindTimeout, payload.Kind)
require.Equal(t, "anthropic", payload.Provider)
}
func TestStreamRetryPayloadUsesNormalizedClassification(t *testing.T) {
t.Parallel()
@@ -1,6 +1,7 @@
package chatprovider_test
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
@@ -13,6 +14,8 @@ import (
fantasyopenai "charm.land/fantasy/providers/openai"
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
fantasyvercel "charm.land/fantasy/providers/vercel"
"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"
"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -1080,8 +1083,12 @@ func TestModelFromConfig_BedrockStreamingHeaders(t *testing.T) {
ReadError: err,
}
w.Header().Set("Content-Type", "application/vnd.amazon.eventstream")
w.WriteHeader(http.StatusOK)
if err := writeBedrockAnthropicStream(w,
`{"type":"message_start","message":{}}`,
`{"type":"message_stop"}`,
); err != nil {
t.Errorf("write bedrock stream: %v", err)
}
}))
defer server.Close()
@@ -1130,6 +1137,47 @@ func TestModelFromConfig_BedrockStreamingHeaders(t *testing.T) {
require.Contains(t, got.Body, `"anthropic_version":"bedrock-2023-05-31"`)
}
func writeBedrockAnthropicStream(w http.ResponseWriter, events ...string) error {
w.Header().Set("Content-Type", "application/vnd.amazon.eventstream")
w.WriteHeader(http.StatusOK)
encoder := eventstream.NewEncoder()
for _, event := range events {
payload, err := json.Marshal(map[string]string{
"bytes": base64.StdEncoding.EncodeToString([]byte(event)),
})
if err != nil {
return err
}
err = encoder.Encode(w, eventstream.Message{
Headers: eventstream.Headers{
{
Name: eventstreamapi.MessageTypeHeader,
Value: eventstream.StringValue(eventstreamapi.EventMessageType),
},
{
Name: eventstreamapi.EventTypeHeader,
Value: eventstream.StringValue("chunk"),
},
{
Name: eventstreamapi.ContentTypeHeader,
Value: eventstream.StringValue("application/json"),
},
},
Payload: payload,
})
if err != nil {
return err
}
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
return nil
}
func bedrockNonStreamingResponse() map[string]any {
return map[string]any{
"id": "msg_01Test",
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"sync/atomic"
"testing"
"time"
@@ -27,6 +28,22 @@ func TestIsRetryableDelegatesToClassification(t *testing.T) {
{name: "Nil", err: nil, retryable: false},
{name: "RetryableExplicitStatus429", err: xerrors.New("received status 429 from upstream"), retryable: true},
{name: "RetryableTimeout", err: xerrors.New("service unavailable"), retryable: true},
{
name: "RetryableAnthropicMissingMessageStop",
err: xerrors.Errorf(
"anthropic stream closed before message_stop: %w",
io.EOF,
),
retryable: true,
},
{
name: "RetryableOpenAIResponsesMissingTerminalEvent",
err: xerrors.Errorf(
"openai responses stream closed before terminal event: %w",
io.EOF,
),
retryable: true,
},
{name: "NonRetryableAuth", err: xerrors.New("invalid api key"), retryable: false},
{name: "NonRetryableGeneric", err: xerrors.New("boom"), retryable: false},
}