Thanks to visit codestin.com
Credit goes to github.com

Skip to content
140 changes: 139 additions & 1 deletion coderd/x/chatd/chaterror/classify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1431,12 +1431,144 @@ func TestClassify_FallsBackToProviderMessageForDetail(t *testing.T) {
" image exceeds 5 MB maximum ",
400,
nil,
testProviderResponseDump("not-json"),
))

require.Equal(t, "image exceeds 5 MB maximum", classified.Detail)
}

func TestClassify_AnthropicPlainTextBudgetBody(t *testing.T) {
t.Parallel()

// aibridge returns its budget error as a plain-text 403. The Anthropic
// adapter's Message is the SDK transport string without the body, so
// the budget text appears only in the dumped ResponseBody. Classify
// must still report a usage limit, not auth.
classified := chaterror.Classify(testProviderError(
`POST "https://api.example.com/v1/messages": 403 Forbidden`,
403,
nil,
[]byte("HTTP/1.1 403 Forbidden\r\n"+
"Content-Type: text/plain; charset=utf-8\r\n"+
"X-Content-Type-Options: nosniff\r\n"+
"\r\n"+
"AI budget of US$10.00 exceeded. Please contact an administrator for more details.\n"),
))

require.Equal(t, codersdk.ChatErrorKindUsageLimit, classified.Kind)
require.False(t, classified.Retryable)
require.Equal(t,
"AI budget of US$10.00 exceeded. Please contact an administrator for more details.",
classified.Detail)
}

func TestClassify_ProviderResponseDumps(t *testing.T) {
t.Parallel()

tests := []struct {
name string
message string
status int
dump []byte
wantDetail string
wantKind codersdk.ChatErrorKind
wantRetryable bool
}{
{
// Proxies and load balancers return HTML error pages with a
// text/html Content-Type. The text/plain gate keeps them out
// of the user-facing detail, so detail falls back to the
// provider message.
name: "SkipsHTMLBody",
message: "upstream failed",
status: 502,
dump: testPlainDump("text/html", "<html><body>502 Bad Gateway</body></html>"),
wantDetail: "upstream failed",
wantKind: codersdk.ChatErrorKindTimeout,
wantRetryable: true,
},
{
name: "SkipsWhitespaceOnlyBody",
message: "upstream failed",
status: 400,
dump: testPlainDump("text/plain", " \n\t\n"),
wantDetail: "upstream failed",
wantKind: codersdk.ChatErrorKindGeneric,
wantRetryable: false,
},
{
name: "PlainTextBodyFirstLineOnly",
status: 400,
dump: testPlainDump("text/plain", "first line of the error\nsecond line\nthird line\n"),
wantDetail: "first line of the error",
wantKind: codersdk.ChatErrorKindGeneric,
wantRetryable: false,
},
{
// Valid JSON without an extractable message must not leak raw
// JSON into the user-facing detail, even when served as
// text/plain.
name: "JSONBodyWithoutMessage",
message: "upstream failed",
status: 400,
dump: testPlainDump("text/plain", `{"type":"error"}`),
wantDetail: "upstream failed",
wantKind: codersdk.ChatErrorKindGeneric,
wantRetryable: false,
},
{
// A plain-text body feeds the same pattern matching as a JSON
// message, so "quota" beats the 503 timeout signal.
name: "PlainTextQuotaBodyOn503",
status: 503,
dump: testPlainDump("text/plain", "quota exceeded for this key\n"),
wantDetail: "quota exceeded for this key",
wantKind: codersdk.ChatErrorKindUsageLimit,
wantRetryable: false,
},
{
// A dump of a chunked response keeps the chunk framing.
// Parsing the dump as an HTTP response removes it, so the
// detail is the joined body, not a hex chunk-size line.
name: "DechunksPlainTextBody",
status: 403,
dump: []byte("HTTP/1.1 403 Forbidden\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"Transfer-Encoding: chunked\r\n" +
"\r\n" +
"16\r\nAI budget of US$10.00 \r\n" +
"9\r\nexceeded.\r\n" +
"0\r\n\r\n"),
wantDetail: "AI budget of US$10.00 exceeded.",
wantKind: codersdk.ChatErrorKindUsageLimit,
wantRetryable: false,
},
{
// Fantasy's Google adapter stores a raw message (not an HTTP
// dump) in ResponseBody. A blank line inside it is not a
// header/body separator: detail must fall back to the full
// trimmed Message, not the second paragraph.
name: "GoogleRawMessageWithBlankLine",
message: "google: model overloaded",
status: 500,
dump: []byte("model overloaded\n\nplease try again later"),
wantDetail: "google: model overloaded",
wantKind: codersdk.ChatErrorKindOverloaded,
wantRetryable: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

classified := chaterror.Classify(testProviderError(tt.message, tt.status, nil, tt.dump))
require.Equal(t, tt.wantDetail, classified.Detail)
require.Equal(t, tt.wantKind, classified.Kind)
require.Equal(t, tt.wantRetryable, classified.Retryable)
})
}
}

func TestClassify_UnwrapsTransportWrapperInMessageFallback(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -1507,6 +1639,12 @@ func testProviderError(
}
}

func testPlainDump(contentType, body string) []byte {
return []byte("HTTP/1.1 400 Bad Request\r\n" +
"Content-Type: " + contentType + "\r\n" +
"\r\n" + body)
}

func testProviderResponseDump(body string) []byte {
return []byte(`HTTP/1.1 400 Bad Request
Content-Type: application/json
Expand Down
43 changes: 34 additions & 9 deletions coderd/x/chatd/chaterror/provider_error.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package chaterror

import (
"bufio"
"bytes"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"regexp"
"strconv"
Expand Down Expand Up @@ -53,13 +56,24 @@ func providerErrorDetail(providerErr *fantasy.ProviderError) string {
// and headers. It understands both the top-level `{"message":...}` shape
// used by many providers and the nested `{"error":{"message":...}}`
// envelope. When the extracted message is itself an SDK-formatted transport
// error wrapper, the clean inner provider message is returned.
// error wrapper, the clean inner provider message is returned. For
// non-JSON text/plain bodies (e.g. aibridge's budget errors) it returns
// the trimmed first line of the body; the result surfaces in the
// user-facing detail, so other content types (proxy HTML, opaque bodies)
// and valid JSON without an extractable message yield nothing.
func providerErrorResponseMessage(responseDump []byte) string {
if len(responseDump) == 0 || len(responseDump) > 64*1024 {
return ""
}
body := providerErrorResponseBody(responseDump)
return unwrapTransportErrorMessage(jsonErrorMessage(body))
body, textPlain := readResponseDump(responseDump)
if msg := unwrapTransportErrorMessage(jsonErrorMessage(body)); msg != "" {
return msg
}
if !textPlain || json.Valid(body) {
return ""
}
line, _, _ := strings.Cut(strings.TrimSpace(string(body)), "\n")
return strings.TrimSpace(line)
}

// unwrapTransportErrorMessage extracts the clean provider message from an
Expand Down Expand Up @@ -115,14 +129,25 @@ func jsonErrorMessage(body []byte) string {
return strings.TrimSpace(env.Message)
}

func providerErrorResponseBody(responseDump []byte) []byte {
if _, body, ok := bytes.Cut(responseDump, []byte("\r\n\r\n")); ok {
return body
// readResponseDump parses a dumped HTTP response into its body, removing
// the status line, headers, and any chunk framing, and reports whether the
// response declares Content-Type text/plain (ignoring media-type
// parameters such as charset). Payloads that do not parse as an HTTP
// response, such as the raw message fantasy's Google adapter stores in
// ResponseBody, are returned whole.
func readResponseDump(responseDump []byte) (body []byte, textPlain bool) {
resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(responseDump)), nil)
if err != nil {
return responseDump, false
}
if _, body, ok := bytes.Cut(responseDump, []byte("\n\n")); ok {
return body
defer resp.Body.Close()
// The caller already bounds dumps at 64KB.
body, err = io.ReadAll(resp.Body)
if err != nil {
return responseDump, false
}
return responseDump
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
return body, err == nil && mediaType == "text/plain"
}

func retryAfterFromHeaders(headers map[string]string) time.Duration {
Expand Down
Loading