From 449ffea7a3ae5e64c5c44a6592a2ef4c44e0dd85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B1=E4=BC=9F?= Date: Sun, 12 Apr 2026 11:28:34 +0800 Subject: [PATCH] fix(sdk/go): return structured APIError instead of flattening error response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #436: AI client flattens structured error responses into fmt.Errorf, losing HTTP status code, error type, and error code — preventing callers from distinguishing auth (401) vs rate-limit (429) vs server errors (5xx). Changes: - Add APIError struct with HTTPStatus, Type, Code, Message fields - Implement error interface with IsRetryable(), IsAuthError(), IsRateLimited() - Update doRequest (both streaming and non-streaming) to return *APIError - Callers can now use type assertion to access structured error fields Fixes #436 --- sdk/go/ai/client.go | 17 +++++++++++++++-- sdk/go/ai/response.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/sdk/go/ai/client.go b/sdk/go/ai/client.go index 981084e4d..104108894 100644 --- a/sdk/go/ai/client.go +++ b/sdk/go/ai/client.go @@ -134,7 +134,13 @@ func (c *Client) doRequest(ctx context.Context, req *Request) (*Response, error) if err := json.Unmarshal(respBody, &errResp); err != nil { return nil, fmt.Errorf("API error (%d): %s", httpResp.StatusCode, string(respBody)) } - return nil, fmt.Errorf("API error: %s", errResp.Error.Message) + // Return a structured APIError so callers can distinguish auth vs rate-limit vs server errors + return nil, &APIError{ + HTTPStatus: httpResp.StatusCode, + Type: errResp.Error.Type, + Code: errResp.Error.Code, + Message: errResp.Error.Message, + } } // Parse response @@ -228,7 +234,14 @@ func (c *Client) StreamComplete(ctx context.Context, prompt string, opts ...Opti // Check for errors if httpResp.StatusCode >= 400 { respBody, _ := io.ReadAll(httpResp.Body) - errCh <- fmt.Errorf("API error (%d): %s", httpResp.StatusCode, string(respBody)) + var errResp ErrorResponse + json.Unmarshal(respBody, &errResp) // best-effort parsing; fall back to raw body + errCh <- &APIError{ + HTTPStatus: httpResp.StatusCode, + Type: errResp.Error.Type, + Code: errResp.Error.Code, + Message: errResp.Error.Message, + } return } diff --git a/sdk/go/ai/response.go b/sdk/go/ai/response.go index 673c7d831..d072df6f3 100644 --- a/sdk/go/ai/response.go +++ b/sdk/go/ai/response.go @@ -64,6 +64,37 @@ type ErrorDetail struct { Code string `json:"code,omitempty"` } +// APIError represents a structured API error with full context for retry/fallback decisions. +// Formerly all non-2xx responses were flattened into fmt.Errorf, losing HTTP status code, +// provider error type, and structured error code — preventing callers from distinguishing +// auth (401) vs rate-limit (429) vs content filter (400) vs server error (5xx). +type APIError struct { + HTTPStatus int + Type string // e.g. "invalid_request_error", "authentication_error", "rate_limit_error" + Code string // provider-specific error code + Message string // human-readable message +} + +func (e *APIError) Error() string { + return fmt.Sprintf("API error (%d): [%s] %s", e.HTTPStatus, e.Type, e.Message) +} + +// IsRetryable returns true if the error represents a transient condition worth retrying. +// 429 Rate Limited and 5xx Server Errors are retryable; 400, 401, 403, 404 are not. +func (e *APIError) IsRetryable() bool { + return e.HTTPStatus == 429 || e.HTTPStatus >= 500 +} + +// IsAuthError returns true if this is an authentication error (401). +func (e *APIError) IsAuthError() bool { + return e.HTTPStatus == 401 +} + +// IsRateLimited returns true if this is a rate limit error (429). +func (e *APIError) IsRateLimited() bool { + return e.HTTPStatus == 429 +} + // HasToolCalls returns true if the response contains tool calls. func (r *Response) HasToolCalls() bool { if len(r.Choices) == 0 {