Files
coder/coderd/x/chatd/chaterror/provider_error.go
T
Ethan 2295e9d5be feat: surface upstream provider error details in chat callout (#24546)
Anthropic HTTP 400 responses (e.g. "image exceeds 5 MB maximum") were
collapsed in the chat UI to the generic headline "Anthropic returned an
unexpected error (HTTP 400)." with no actionable detail — the upstream
message survived to the processor log but was dropped before reaching
the client.

Add a new optional `Detail` field on `codersdk.ChatStreamError` that
carries the upstream provider message alongside the existing normalized
headline. The backend extracts `error.message` from
`fantasy.ProviderError.ResponseBody` (the JSON envelope shared by
Anthropic and OpenAI), falls back to the trimmed provider message when
the body is absent or unparseable, and caps the result at 500 runes. The
frontend threads `Detail` through `useChatStore`, `liveStatusModel`, and
`ChatStatusCallout`, rendering it as a muted secondary line inside the
existing `AlertDescription`.

Before:

<img width="1552" height="185" alt="image"
src="https://github.com/user-attachments/assets/524b588e-3cee-4fad-bc15-6bf3aec0899d"
/>

After:

<img width="814" height="173" alt="image"
src="https://github.com/user-attachments/assets/eae82a89-3ac1-4a33-8d18-ef9f77263d89"
/>

## Persistence

`Detail` is **not** persisted — it disappears on refresh. Persisting it
would require a DB change (today `chats.last_error` is a single nullable
`TEXT` column), and the shape of persisted chat errors is worth a more
deliberate rethink — e.g. promoting `last_error` to `JSONB` so we can
also retain structured fields like `kind`, `statusCode`, `provider`, and
`retryable` instead of only the normalized headline string. That's a
bigger design discussion than this PR should carry.

In the meantime, seeing the upstream error reason *immediately on
failure* is already a large UX improvement over the status quo, and this
PR gets us there without prejudicing the eventual persistence design.
Tracking persistence in CODAGT-239.

Closes CODAGT-235
2026-04-22 00:05:27 +10:00

106 lines
2.5 KiB
Go

package chaterror
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"charm.land/fantasy"
)
type providerErrorDetails struct {
detail string
statusCode int
retryAfter time.Duration
}
func extractProviderErrorDetails(err error) providerErrorDetails {
var providerErr *fantasy.ProviderError
if !errors.As(err, &providerErr) {
return providerErrorDetails{}
}
return providerErrorDetails{
detail: providerErrorDetail(providerErr),
statusCode: providerErr.StatusCode,
retryAfter: retryAfterFromHeaders(providerErr.ResponseHeaders),
}
}
func providerErrorDetail(providerErr *fantasy.ProviderError) string {
if detail := providerErrorResponseMessage(providerErr.ResponseBody); detail != "" {
return detail
}
return strings.TrimSpace(providerErr.Message)
}
// providerErrorResponseMessage extracts error.message from the common
// provider error JSON envelope after stripping any dumped HTTP status
// line and headers.
func providerErrorResponseMessage(responseDump []byte) string {
if len(responseDump) == 0 || len(responseDump) > 64*1024 {
return ""
}
body := providerErrorResponseBody(responseDump)
var envelope struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return ""
}
return strings.TrimSpace(envelope.Error.Message)
}
func providerErrorResponseBody(responseDump []byte) []byte {
if _, body, ok := bytes.Cut(responseDump, []byte("\r\n\r\n")); ok {
return body
}
if _, body, ok := bytes.Cut(responseDump, []byte("\n\n")); ok {
return body
}
return responseDump
}
func retryAfterFromHeaders(headers map[string]string) time.Duration {
if len(headers) == 0 {
return 0
}
// Prefer retry-after-ms (OpenAI convention, milliseconds)
// over the standard retry-after (seconds or HTTP-date).
for key, value := range headers {
if strings.EqualFold(key, "retry-after-ms") {
ms, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err == nil && ms > 0 {
return time.Duration(ms * float64(time.Millisecond))
}
}
}
for key, value := range headers {
if strings.EqualFold(key, "retry-after") {
v := strings.TrimSpace(value)
if seconds, err := strconv.ParseFloat(v, 64); err == nil {
if seconds > 0 {
return time.Duration(seconds * float64(time.Second))
}
return 0
}
if retryAt, err := http.ParseTime(v); err == nil {
if d := time.Until(retryAt); d > 0 {
return d
}
}
return 0
}
}
return 0
}