8000 feat(sdk): export APIError with Kind classification · felinics/twilight@a4f89c6 · GitHub
[go: up one dir, main page]

Skip to content

Commit a4f89c6

Browse files
authored
feat(sdk): export APIError with Kind classification
Provider failures reached callers only as text: the HTTP layer's error type was internal, so status, provider codes and the response body could not be recovered with errors.As, and downstream code matched error strings. APIError is the exported type for a failure the provider reported. It keeps the provider's type, code, message and request ID verbatim, the response headers and raw body, and a cross-provider Kind. StatusCode is 0 when the HTTP status line did not report the failure. Error() carries provider, status, type/code, message and request ID, never the body or any header, because the body may echo end-user input. RetryAfter parses retry-after-ms, delta-seconds and HTTP-date. ErrorKind is an open set; KindOf reads it from an error chain.
1 parent 00814be commit a4f89c6

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

‎sdk/api_error.go‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package sdk
2+
3+
import (
4+
"errors"
5+
"math"
6+
"net/http"
7+
"strconv"
8+
"strings"
9+
"time"
10+
)
11+
12+
// APIError reports a failure that the provider itself reported: a non-2xx
13+
// HTTP response, an error object inside a 2xx body, an error event inside a
14+
// stream, or a failure frame on a WebSocket session.
15+
//
16+
// Transport failures (DNS, TLS, connection resets), context cancellation,
17+
// response decoding failures and local validation are not APIErrors; they are
18+
// returned as ordinary errors that wrap their cause.
19+
//
20+
// Always handle it as a pointer:
21+
//
22+
// var apiErr *sdk.APIError
23+
// if errors.As(err, &apiErr) { ... }
24+
//
25+
// The names and meanings of the fields are stable. The contents of Type, Code,
26+
// Message and Body are the provider's own and change when the provider changes
27+
// them; do not depend on them beyond what the provider documents. The text
28+
// returned by Error is for humans and has no stable format.
29+
type APIError struct {
30+
// Provider identifies the provider that reported the failure. For chat
31+
// providers it is the Name() of the provider that sent the request, e.g.
32+
// "anthropic-messages". A provider that delegates requests to another
33+
// provider reports the delegate's name.
34+
Provider string
35+
36+
// StatusCode is the HTTP status that reported this failure. It is 0 when
37+
// the HTTP status line did not report it: an error event after a 2xx
38+
// stream started, an error object inside a 2xx body, or a WebSocket frame.
39+
StatusCode int
40+
41+
// Type and Code are the provider's own error identifiers, copied verbatim.
42+
// Which of them a provider fills is provider-specific; either may be empty.
43+
Type string
44+
Code string
45+
46+
// Message is the provider's human-readable message, copied verbatim.
47+
Message string
48+
49+
// RequestID is the provider's identifier for the failed request, when the
50+
// provider sends one. Quote it in support requests to the provider.
51+
RequestID string
52+
53+
// Kind is the SDK's cross-provider classification of the failure.
54+
Kind ErrorKind
55+
56+
// Header holds the HTTP response headers. It is set for stream errors too,
57+
// since the stream's response headers arrive before the stream starts. Nil
58+
// when there was no HTTP response.
59+
Header http.Header
60+
61+
// Body is the raw response body, event payload or frame, as received. It
62+
// may echo parts of the request, including end-user input, so do not log
63+
// it verbatim. It is never part of Error.
64+
Body []byte
65+
}
66+
67+
// Error describes the failure with the provider, the HTTP status, the
68+
// provider's type and code, its message and the request ID. It never includes
69+
// Body or any header.
70+
func (e *APIError) Error() string {
71+
var b strings.Builder
72+
if e.Provider != "" {
73+
b.WriteString(e.Provider)
74+
b.WriteString(": ")
75+
}
76+
if e.StatusCode != 0 {
77+
b.WriteString(strconv.Itoa(e.StatusCode))
78+
} else {
79+
b.WriteString("error")
80+
}
81+
if e.Type != "" {
82+
b.WriteString(" ")
83+
b.WriteString(e.Type)
84+
}
85+
if e.Code != "" && e.Code != e.Type {
86+
b.WriteString(" ")
87+
b.WriteString(e.Code)
88+
}
89+
if e.Message != "" {
90+
b.WriteString(": ")
91+
b.WriteString(e.Message)
92+
}
93+
if e.RequestID != "" {
94+
b.WriteString(" (request-id ")
95+
b.WriteString(e.RequestID)
96+
b.WriteString(")")
97+
}
98+
return b.String()
99+
}
100+
101+
// RetryAfter reports the delay the provider asked for before retrying, read
102+
// from the retry-after-ms header (milliseconds) or the Retry-After header
103+
// (delta-seconds or an HTTP-date), in that order. A date in the past yields
104+
// zero. ok is false when neither header is present or parsable.
105+
func (e *APIError) RetryAfter() (d time.Duration, ok bool) {
106+
if e.Header == nil {
107+
return 0, false
108+
}
109+
if ms, err := strconv.ParseFloat(e.Header.Get("retry-after-ms"), 64); err == nil && validDelay(ms) {
110+
return time.Duration(ms * float64(time.Millisecond)), true
111+
}
112+
value := e.Header.Get("Retry-After")
113+
if value == "" {
114+
return 0, false
115+
}
116+
if secs, err := strconv.ParseFloat(value, 64); err == nil {
117+
if !validDelay(secs) {
118+
return 0, false
119+
}
120+
return time.Duration(secs * float64(time.Second)), true
121+
}
122+
if at, err := http.ParseTime(value); err == nil {
123+
return max(time.Until(at), 0), true
124+
}
125+
return 0, false
126+
}
127+
128+
func validDelay(v float64) bool {
129+
return v >= 0 && !math.IsInf(v, 0) && !math.IsNaN(v)
130+
}
131+
132+
// ErrorKind classifies an APIError across providers.
133+
//
134+
// The set of values is open: later versions may add values, so a switch on
135+
// ErrorKind must have a default branch. A response the SDK has no mapping for
136+
// is KindUnknown, and a response that was KindUnknown may gain a specific Kind
137+
// in a later version.
138+
type ErrorKind string
139+
140+
const (
141+
// KindUnknown means the SDK has no classification for the failure.
142+
// StatusCode, Type and Code are still available.
143+
KindUnknown ErrorKind = "unknown"
144+
// KindAuthentication means the credentials are missing, invalid or
145+
// expired.
146+
KindAuthentication ErrorKind = "authentication"
147+
// KindPermissionDenied means the credentials are valid but lack access to
148+
// the requested resource.
149+
KindPermissionDenied ErrorKind = "permission_denied"
150+
// KindQuotaExhausted means the account's credit, billing or spend limit is
151+
// used up; waiting briefly does not help.
152+
KindQuotaExhausted ErrorKind = "quota_exhausted"
153+
// KindRateLimited means a request or token rate limit was hit; it is
154+
// transient.
155+
KindRateLimited ErrorKind = "rate_limited"
156+
// KindServerError means the provider failed or is overloaded; it is
157+
// transient.
158+
KindServerError ErrorKind = "server_error"
159+
)
160+
161+
// KindOf returns the Kind of the first *APIError in err's chain. It returns
162+
// KindUnknown when err is nil, when the chain holds no *APIError, or when the
163+
// APIError has no Kind set.
164+
func KindOf(err error) ErrorKind {
165+
var apiErr *APIError
166+
if !errors.As(err, &apiErr) || apiErr.Kind == "" {
167+
return KindUnknown
168+
}
169+
return apiErr.Kind
170+
}

‎sdk/api_error_test.go‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package sdk_test
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
"github.com/felinics/twilight/sdk"
12+
)
13+
14+
func TestAPIErrorRetryAfter(t *testing.T) {
15+
future := time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat)
16+
past := time.Now().Add(-time.Hour).UTC().Format(http.TimeFormat)
17+
tests := []struct {
18+
name string
19+
header http.Header
20+
want time.Duration
21+
wantOK bool
22+
between [2]time.Duration
23+
}{
24+
{name: "retry-after-ms", header: http.Header{"Retry-After-Ms": {"1500"}}, want: 1500 * time.Millisecond, wantOK: true},
25+
{name: "fractional retry-after-ms", header: http.Header{"Retry-After-Ms": {"2.5"}}, want: 2500 * time.Microsecond, wantOK: true},
26+
{name: "seconds", header: http.Header{"Retry-After": {"20"}}, want: 20 * time.Second, wantOK: true},
27+
{name: "retry-after-ms wins over seconds", header: http.Header{"Retry-After-Ms": {"250"}, "Retry-After": {"20"}}, want: 250 * time.Millisecond, wantOK: true},
28+
{name: "unparsable retry-after-ms falls back to seconds", header: http.Header{"Retry-After-Ms": {"soon"}, "Retry-After": {"3"}}, want: 3 * time.Second, wantOK: true},
29+
{name: "http date", header: http.Header{"Retry-After": {future}}, wantOK: true, between: [2]time.Duration{80 * time.Second, 90 * time.Second}},
30+
{name: "http date in the past", header: http.Header{"Retry-After": {past}}, want: 0, wantOK: true},
31+
{name: "absent", header: http.Header{}, wantOK: false},
32+
{name: "nil header", header: nil, wantOK: false},
33+
{name: "unparsable", header: http.Header{"Retry-After": {"later"}}, wantOK: false},
34+
{name: "negative seconds", header: http.Header{"Retry-After": {"-5"}}, wantOK: false},
35+
}
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
got, ok := (&sdk.APIError{Header: tt.header}).RetryAfter()
39+
if ok != tt.wantOK {
40+
t.Fatalf("RetryAfter() ok = %v, want %v", ok, tt.wantOK)
41+
}
42+
if tt.between != [2]time.Duration{} {
43+
if got < tt.between[0] || got > tt.between[1] {
44+
t.Fatalf("RetryAfter() = %v, want within %v", got, tt.between)
45+
}
46+
return
47+
}
48+
if got != tt.want {
49+
t.Fatalf("RetryAfter() = %v, want %v", got, tt.want)
50+
}
51+
})
52+
}
53+
}
54+
55+
func TestAPIErrorErrorText(t *testing.T) {
56+
tests := []struct {
57+
name string
58+
err *sdk.APIError
59+
want string
60+
}{
61+
{
62+
name: "all parts",
63+
err: &sdk.APIError{Provider: "anthropic-messages", StatusCode: 429, Type: "rate_limit_error", Message: "slow down", RequestID: "req_1"},
64+
want: "anthropic-messages: 429 rate_limit_error: slow down (request-id req_1)",
65+
},
66+
{
67+
name: "type and code",
68+
err: &sdk.APIError{Provider: "openai-completions", StatusCode: 401, Type: "invalid_request_error", Code: "invalid_api_key", Message: "bad key"},
69+
want: "openai-completions: 401 invalid_request_error invalid_api_key: bad key",
70+
},
71+
{
72+
name: "type equal to code is written once",
73+
err: &sdk.APIError{Provider: "openai-completions", StatusCode: 429, Type: "insufficient_quota", Code: "insufficient_quota"},
74+
want: "openai-completions: 429 insufficient_quota",
75+
},
76+
{
77+
name: "no status",
78+
err: &sdk.APIError{Provider: "anthropic-messages", Type: "overloaded_error", Message: "Overloaded"},
79+
want: "anthropic-messages: error overloaded_error: Overloaded",
80+
},
81+
{
82+
name: "status only",
83+
err: &sdk.APIError{StatusCode: 502},
84+
want: "502",
85+
},
86+
}
87+
for _, tt := range tests {
88+
t.Run(tt.name, func(t *testing.T) {
89+
if got := tt.err.Error(); got != tt.want {
90+
t.Fatalf("Error() = %q, want %q", got, tt.want)
91+
}
92+
})
93+
}
94+
}
95+
96+
func TestAPIErrorTextExcludesBodyAndHeaders(t *testing.T) {
97+
const bodySecret = "body-marker-7f3a"
98+
const headerSecret = "header-marker-7f3a"
99+
err := &sdk.APIError{
100+
Provider: "openai-completions",
101+
StatusCode: 400,
102+
Message: "bad request",
103+
Header: http.Header{"X-Echo": {headerSecret}, "Retry-After": {"1"}},
104+
Body: []byte(`{"error":{"message":"bad request","prompt":"` + bodySecret + `"}}`),
105+
}
106+
wrapped := fmt.Errorf("call failed: %w", err)
107+
for _, text := range []string{err.Error(), wrapped.Error(), fmt.Sprintf("%v", err), fmt.Sprintf("%+v", wrapped)} {
108+
if strings.Contains(text, bodySecret) || strings.Contains(text, headerSecret) {
109+
t.Fatalf("error text %q leaks the body or a header", text)
110+
}
111+
}
112+
}
113+
114+
func TestKindOf(t *testing.T) {
115+
rateLimited := &sdk.APIError{StatusCode: 429, Kind: sdk.KindRateLimited}
116+
tests := []struct {
117+
name string
118+
err error
119+
want sdk.ErrorKind
120+
}{
121+
{name: "nil", err: nil, want: sdk.KindUnknown},
122+
{name: "not an APIError", err: errors.New("dial tcp: connection refused"), want: sdk.KindUnknown},
123+
{name: "direct", err: rateLimited, want: sdk.KindRateLimited},
124+
{name: "wrapped", err: fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", rateLimited)), want: sdk.KindRateLimited},
125+
{name: "joined", err: errors.Join(errors.New("other"), rateLimited), want: sdk.KindRateLimited},
126+
{name: "first in chain wins", err: fmt.Errorf("%w", &sdk.APIError{Kind: sdk.KindAuthentication}), want: sdk.KindAuthentication},
127+
{name: "unset kind", err: &sdk.APIError{StatusCode: 400}, want: sdk.KindUnknown},
128+
}
129+
for _, tt := range tests {
130+
t.Run(tt.name, func(t *testing.T) {
131+
if got := sdk.KindOf(tt.err); got != tt.want {
132+
t.Fatalf("KindOf() = %q, want %q", got, tt.want)
133+
}
134+
})
135+
}
136+
}

0 commit comments

Comments
 (0)
0