mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user