Add internal structured provider error parsing
This commit is contained in:
173
internal/llm/provider_http_error.go
Normal file
173
internal/llm/provider_http_error.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
maxProviderErrorResponseBytes int64 = 64 << 10
|
||||
maxProviderErrorIdentifierRunes = 256
|
||||
maxProviderErrorMessageRunes = 4096
|
||||
)
|
||||
|
||||
// ProviderHTTPError describes a non-success response from an LLM provider.
|
||||
type ProviderHTTPError struct {
|
||||
statusCode int
|
||||
providerCode string
|
||||
providerType string
|
||||
providerMessage string
|
||||
}
|
||||
|
||||
func (e *ProviderHTTPError) StatusCode() int {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
return e.statusCode
|
||||
}
|
||||
|
||||
func (e *ProviderHTTPError) ProviderCode() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.providerCode
|
||||
}
|
||||
|
||||
func (e *ProviderHTTPError) ProviderType() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.providerType
|
||||
}
|
||||
|
||||
func (e *ProviderHTTPError) ProviderMessage() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.providerMessage
|
||||
}
|
||||
|
||||
func (e *ProviderHTTPError) Error() string {
|
||||
if e == nil || e.statusCode == 0 {
|
||||
return ErrUnexpectedStatus.Error()
|
||||
}
|
||||
return fmt.Sprintf("%s: status=%d", ErrUnexpectedStatus, e.statusCode)
|
||||
}
|
||||
|
||||
func (e *ProviderHTTPError) GoString() string {
|
||||
return e.Error()
|
||||
}
|
||||
|
||||
func (e *ProviderHTTPError) Unwrap() error {
|
||||
return ErrUnexpectedStatus
|
||||
}
|
||||
|
||||
type providerErrorDetails struct {
|
||||
providerCode string
|
||||
providerType string
|
||||
providerMessage string
|
||||
}
|
||||
|
||||
func newProviderHTTPError(statusCode int, details providerErrorDetails) *ProviderHTTPError {
|
||||
return &ProviderHTTPError{
|
||||
statusCode: statusCode,
|
||||
providerCode: details.providerCode,
|
||||
providerType: details.providerType,
|
||||
providerMessage: details.providerMessage,
|
||||
}
|
||||
}
|
||||
|
||||
func parseProviderErrorEnvelope(body []byte) providerErrorDetails {
|
||||
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
||||
decoder.UseNumber()
|
||||
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := decoder.Decode(&envelope); err != nil {
|
||||
return providerErrorDetails{}
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return providerErrorDetails{}
|
||||
}
|
||||
|
||||
rawError, ok := envelope["error"]
|
||||
if !ok {
|
||||
return providerErrorDetails{}
|
||||
}
|
||||
var providerError map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rawError, &providerError); err != nil || providerError == nil {
|
||||
return providerErrorDetails{}
|
||||
}
|
||||
|
||||
var details providerErrorDetails
|
||||
if raw, ok := providerError["message"]; ok {
|
||||
var value string
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
details.providerMessage = normalizeProviderErrorMessage(value)
|
||||
}
|
||||
}
|
||||
if raw, ok := providerError["type"]; ok {
|
||||
var value string
|
||||
if json.Unmarshal(raw, &value) == nil {
|
||||
details.providerType = normalizeProviderErrorIdentifier(value)
|
||||
}
|
||||
}
|
||||
if raw, ok := providerError["code"]; ok {
|
||||
var value any
|
||||
fieldDecoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
fieldDecoder.UseNumber()
|
||||
if fieldDecoder.Decode(&value) == nil {
|
||||
switch value := value.(type) {
|
||||
case string:
|
||||
details.providerCode = normalizeProviderErrorIdentifier(value)
|
||||
case json.Number:
|
||||
details.providerCode = normalizeProviderErrorIdentifier(value.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
func normalizeProviderErrorIdentifier(value string) string {
|
||||
normalized := normalizeProviderErrorText(value)
|
||||
if len([]rune(normalized)) > maxProviderErrorIdentifierRunes {
|
||||
return ""
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeProviderErrorMessage(value string) string {
|
||||
normalized := normalizeProviderErrorText(value)
|
||||
runes := []rune(normalized)
|
||||
if len(runes) <= maxProviderErrorMessageRunes {
|
||||
return normalized
|
||||
}
|
||||
return string(runes[:maxProviderErrorMessageRunes-1]) + "…"
|
||||
}
|
||||
|
||||
func normalizeProviderErrorText(value string) string {
|
||||
value = strings.ToValidUTF8(value, "<22>")
|
||||
|
||||
var result strings.Builder
|
||||
result.Grow(len(value))
|
||||
separatorPending := false
|
||||
for _, r := range value {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) || unicode.In(r, unicode.Cf) {
|
||||
if result.Len() > 0 {
|
||||
separatorPending = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if separatorPending {
|
||||
result.WriteByte(' ')
|
||||
separatorPending = false
|
||||
}
|
||||
result.WriteRune(r)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
138
internal/llm/provider_http_error_test.go
Normal file
138
internal/llm/provider_http_error_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestProviderHTTPErrorEnvelopeParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want providerErrorDetails
|
||||
}{
|
||||
{
|
||||
name: "all supported string fields",
|
||||
body: `{"error":{"message":"diagnostic","type":"invalid_request_error","code":"unsupported_parameter"}}`,
|
||||
want: providerErrorDetails{providerMessage: "diagnostic", providerType: "invalid_request_error", providerCode: "unsupported_parameter"},
|
||||
},
|
||||
{
|
||||
name: "integer code",
|
||||
body: `{"error":{"code":17}}`,
|
||||
want: providerErrorDetails{providerCode: "17"},
|
||||
},
|
||||
{
|
||||
name: "fractional code",
|
||||
body: `{"error":{"code":1.25}}`,
|
||||
want: providerErrorDetails{providerCode: "1.25"},
|
||||
},
|
||||
{
|
||||
name: "exponent code",
|
||||
body: `{"error":{"code":6.02e+23}}`,
|
||||
want: providerErrorDetails{providerCode: "6.02e+23"},
|
||||
},
|
||||
{
|
||||
name: "invalid fields do not discard valid fields",
|
||||
body: `{"error":{"message":null,"type":"invalid_request_error","code":false}}`,
|
||||
want: providerErrorDetails{providerType: "invalid_request_error"},
|
||||
},
|
||||
{
|
||||
name: "unknown fields are ignored",
|
||||
body: `{"trace":"do not retain","error":{"param":"temperature","metadata":{"secret":"x"}}}`,
|
||||
want: providerErrorDetails{},
|
||||
},
|
||||
{name: "missing error", body: `{}`, want: providerErrorDetails{}},
|
||||
{name: "null error", body: `{"error":null}`, want: providerErrorDetails{}},
|
||||
{name: "scalar error", body: `{"error":"nope"}`, want: providerErrorDetails{}},
|
||||
{name: "empty error", body: `{"error":{}}`, want: providerErrorDetails{}},
|
||||
{name: "malformed", body: `{"error":`, want: providerErrorDetails{}},
|
||||
{name: "truncated", body: `{"error":{"message":"x"`, want: providerErrorDetails{}},
|
||||
{name: "trailing garbage", body: `{"error":{"message":"x"}} garbage`, want: providerErrorDetails{}},
|
||||
{name: "second document", body: `{"error":{"message":"x"}} {}`, want: providerErrorDetails{}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := parseProviderErrorEnvelope([]byte(tc.body)); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("parseProviderErrorEnvelope() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorTextNormalizationAndLimits(t *testing.T) {
|
||||
validIdentifier := strings.Repeat("界", maxProviderErrorIdentifierRunes)
|
||||
validMessage := strings.Repeat("界", maxProviderErrorMessageRunes)
|
||||
tests := []struct {
|
||||
name string
|
||||
got string
|
||||
want string
|
||||
}{
|
||||
{name: "multibyte text", got: "Grüße 世界", want: "Grüße 世界"},
|
||||
{name: "invalid UTF-8", got: string([]byte{'a', 0xff, 'b'}), want: "a<>b"},
|
||||
{name: "whitespace control and format runs", got: " \n\talpha\x00\u200b\u200bbeta \r ", want: "alpha beta"},
|
||||
{name: "blank normalization", got: "\t\u200b\n", want: ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeProviderErrorText(tc.got); got != tc.want {
|
||||
t.Fatalf("normalizeProviderErrorText() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if got := normalizeProviderErrorIdentifier(validIdentifier); got != validIdentifier {
|
||||
t.Fatalf("exact identifier boundary = %q, want retained value", got)
|
||||
}
|
||||
if got := normalizeProviderErrorIdentifier(validIdentifier + "界"); got != "" {
|
||||
t.Fatalf("overlong identifier = %q, want empty", got)
|
||||
}
|
||||
if got := normalizeProviderErrorMessage(validMessage); got != validMessage {
|
||||
t.Fatalf("exact message boundary = %q, want retained value", got)
|
||||
}
|
||||
wantTruncatedMessage := strings.Repeat("界", maxProviderErrorMessageRunes-1) + "…"
|
||||
if got := normalizeProviderErrorMessage(validMessage + "界"); got != wantTruncatedMessage {
|
||||
t.Fatalf("overlong message length = %d, want %d", utf8.RuneCountInString(got), maxProviderErrorMessageRunes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderHTTPErrorIdentityAndFormatting(t *testing.T) {
|
||||
const marker = "provider-secret-marker"
|
||||
err := newProviderHTTPError(429, providerErrorDetails{
|
||||
providerCode: marker + "-code",
|
||||
providerType: marker + "-type",
|
||||
providerMessage: marker + "-message",
|
||||
})
|
||||
|
||||
if err.StatusCode() != 429 || err.ProviderCode() != marker+"-code" || err.ProviderType() != marker+"-type" || err.ProviderMessage() != marker+"-message" {
|
||||
t.Fatalf("accessors returned unexpected values: %#v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrUnexpectedStatus) {
|
||||
t.Fatalf("errors.Is(%v, ErrUnexpectedStatus) = false", err)
|
||||
}
|
||||
for _, rendered := range []string{fmt.Sprintf("%v", err), fmt.Sprintf("%+v", err), fmt.Sprintf("%#v", err)} {
|
||||
if rendered != "llm returned non-success status: status=429" {
|
||||
t.Fatalf("formatted error = %q", rendered)
|
||||
}
|
||||
if strings.Contains(rendered, marker) {
|
||||
t.Fatalf("formatted error exposed provider marker: %q", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
var nilError *ProviderHTTPError
|
||||
if nilError.StatusCode() != 0 || nilError.ProviderCode() != "" || nilError.ProviderType() != "" || nilError.ProviderMessage() != "" {
|
||||
t.Fatal("nil accessors returned provider values")
|
||||
}
|
||||
if nilError.Error() != "llm returned non-success status" || nilError.GoString() != "llm returned non-success status" || !errors.Is(nilError, ErrUnexpectedStatus) {
|
||||
t.Fatalf("nil error behavior is not safe: %v", nilError)
|
||||
}
|
||||
|
||||
zero := &ProviderHTTPError{}
|
||||
if zero.Error() != "llm returned non-success status" || zero.GoString() != "llm returned non-success status" || !errors.Is(zero, ErrUnexpectedStatus) {
|
||||
t.Fatalf("zero error behavior is not safe: %v", zero)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user