diff --git a/arkruntime/client.go b/arkruntime/client.go index 95d29d6..cf4ea68 100644 --- a/arkruntime/client.go +++ b/arkruntime/client.go @@ -642,20 +642,63 @@ func (c *Client) fullURL(suffix string) string { } func (c *Client) handleErrorResp(resp *http.Response) error { - requestID := resp.Header.Get(model.ClientRequestHeader) + requestID := responseRequestID(resp) + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return model.NewRequestError( + resp.StatusCode, + fmt.Errorf("read error response body: %w", readErr), + requestID, + ) + } + var errRes model.ErrorResponse - err := json.NewDecoder(resp.Body).Decode(&errRes) - if err != nil || errRes.Error == nil { - reqErr := model.NewRequestError(resp.StatusCode, err, requestID) - if errRes.Error != nil { - reqErr.Err = errRes.Error - } - return reqErr + if err := json.Unmarshal(body, &errRes); err == nil && errRes.Error != nil { + return setAPIErrorResponseMetadata(errRes.Error, resp.StatusCode, requestID) + } + + // Some services return the error object directly instead of wrapping it in + // an {"error": ...} envelope. Preserve its structured fields when possible. + var apiErr model.APIError + if err := json.Unmarshal(body, &apiErr); err == nil && + (apiErr.Message != "" || apiErr.Code != "" || apiErr.Type != "") { + return setAPIErrorResponseMetadata(&apiErr, resp.StatusCode, requestID) + } + + bodyText := strings.TrimSpace(string(body)) + if bodyText == "" { + return model.NewRequestError( + resp.StatusCode, + errors.New("unexpected error response: empty body"), + requestID, + ) + } + return model.NewRequestError( + resp.StatusCode, + fmt.Errorf("unexpected error response body: %s", bodyText), + requestID, + ) +} + +func responseRequestID(resp *http.Response) string { + if requestID := resp.Header.Get(model.ServerRequestHeader); requestID != "" { + return requestID + } + if requestID := resp.Header.Get(model.ClientRequestHeader); requestID != "" { + return requestID + } + if resp.Request != nil { + return resp.Request.Header.Get(model.ClientRequestHeader) } + return "" +} - errRes.Error.HTTPStatusCode = resp.StatusCode - errRes.Error.RequestId = requestID - return errRes.Error +func setAPIErrorResponseMetadata(apiErr *model.APIError, statusCode int, requestID string) error { + apiErr.HTTPStatusCode = statusCode + if requestID != "" { + apiErr.RequestId = requestID + } + return apiErr } func (c *Client) getRetryAfter(v model.Response) int64 { diff --git a/arkruntime/error_response_test.go b/arkruntime/error_response_test.go new file mode 100644 index 0000000..80f8430 --- /dev/null +++ b/arkruntime/error_response_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates. +// SPDX-License-Identifier: Apache-2.0 + +package arkruntime + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/volcengine/ark-runtime-go/arkruntime/model" +) + +func TestHandleErrorResp(t *testing.T) { + tests := []struct { + name string + body string + headers http.Header + request *http.Request + wantAPI bool + wantCode string + wantID string + wantInErr string + }{ + { + name: "wrapped API error", + body: `{"error":{"code":"InvalidModel","message":"model not found","type":"invalid_request_error"}}`, + headers: http.Header{model.ServerRequestHeader: []string{"server-request-id"}}, + wantAPI: true, + wantCode: "InvalidModel", + wantID: "server-request-id", + }, + { + name: "direct API error", + body: `{"code":"InvalidModel","message":"model not found","type":"invalid_request_error","request_id":"body-request-id"}`, + headers: http.Header{}, + wantAPI: true, + wantCode: "InvalidModel", + wantID: "body-request-id", + }, + { + name: "nonstandard JSON body", + body: `{"detail":"model is invalid"}`, + headers: http.Header{model.ServerRequestHeader: []string{"server-request-id"}}, + wantID: "server-request-id", + wantInErr: `{"detail":"model is invalid"}`, + }, + { + name: "plain text body", + body: "bad gateway", + headers: http.Header{}, + request: requestWithClientID("client-request-id"), + wantID: "client-request-id", + wantInErr: "bad gateway", + }, + { + name: "empty body", + body: "", + headers: http.Header{}, + wantInErr: "unexpected error response: empty body", + }, + } + + client := &Client{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Header: tt.headers, + Body: io.NopCloser(strings.NewReader(tt.body)), + Request: tt.request, + } + err := client.handleErrorResp(resp) + + if tt.wantAPI { + var apiErr *model.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %T, want *model.APIError", err) + } + if apiErr.Code != tt.wantCode || apiErr.RequestId != tt.wantID { + t.Fatalf("API error = %#v, want code %q and request ID %q", apiErr, tt.wantCode, tt.wantID) + } + return + } + + var requestErr *model.RequestError + if !errors.As(err, &requestErr) { + t.Fatalf("error = %T, want *model.RequestError", err) + } + if requestErr.Err == nil { + t.Fatal("RequestError.Err is nil") + } + if requestErr.RequestId != tt.wantID { + t.Fatalf("request ID = %q, want %q", requestErr.RequestId, tt.wantID) + } + if !strings.Contains(requestErr.Error(), tt.wantInErr) { + t.Fatalf("error = %q, want it to contain %q", requestErr, tt.wantInErr) + } + }) + } +} + +func requestWithClientID(requestID string) *http.Request { + request, _ := http.NewRequest(http.MethodPost, "https://example.com", nil) + request.Header.Set(model.ClientRequestHeader, requestID) + return request +} diff --git a/arkruntime/model/common.go b/arkruntime/model/common.go index 3fae081..f5d781c 100644 --- a/arkruntime/model/common.go +++ b/arkruntime/model/common.go @@ -11,6 +11,7 @@ import ( const ( ClientRequestHeader = "X-Client-Request-Id" + ServerRequestHeader = "X-Request-Id" RetryAfterHeader = "Retry-After" DefaultMandatoryRefreshTimeout = 10 * 60 // 10 min