Skip to content

Commit b7d5e3c

Browse files
fix: preserve nonstandard API error responses
## Summary - preserve standard wrapped API errors and direct error objects - retain arbitrary JSON and plain-text error bodies instead of returning RequestError with nil Err - return a clear local diagnostic when an error response has an empty body - prefer the server X-Request-Id header, then fall back to the client request ID - add regression coverage for wrapped, direct, nonstandard JSON, text, and empty responses An empty body now produces `unexpected error response: empty body`; no placeholder is inserted into the body text. ## Context Found while running the public README Tokenization example with an intentionally invalid placeholder model. This is an existing SDK error-handling issue and is independent of the docs MR. ## Validation - targeted TestHandleErrorResp covering all five response shapes - go test ./... - go build ./... - go vet ./... - golangci-lint run ./... After this MR merges, docs/public-readme-byteplus will be rebased onto main and the CN/BP examples will be rerun. See merge request: !82 Sync-Source-Commit: 335e98443a9aa0fcfd7eb20c2351801805b84cf0 Ark-APIs-Commit: 665a2441c2c8d9342e4770d776ea19de36ef6f83 Hand-Written-Reason: Manual error-handling fix (preserve nonstandard API error responses). No ark-apis regeneration involved; attributed to the same ark-apis snapshot as parent 9763808. Release-Version: 0.3.0
1 parent 6e4ec0a commit b7d5e3c

3 files changed

Lines changed: 164 additions & 11 deletions

File tree

arkruntime/client.go

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -642,20 +642,63 @@ func (c *Client) fullURL(suffix string) string {
642642
}
643643

644644
func (c *Client) handleErrorResp(resp *http.Response) error {
645-
requestID := resp.Header.Get(model.ClientRequestHeader)
645+
requestID := responseRequestID(resp)
646+
body, readErr := io.ReadAll(resp.Body)
647+
if readErr != nil {
648+
return model.NewRequestError(
649+
resp.StatusCode,
650+
fmt.Errorf("read error response body: %w", readErr),
651+
requestID,
652+
)
653+
}
654+
646655
var errRes model.ErrorResponse
647-
err := json.NewDecoder(resp.Body).Decode(&errRes)
648-
if err != nil || errRes.Error == nil {
649-
reqErr := model.NewRequestError(resp.StatusCode, err, requestID)
650-
if errRes.Error != nil {
651-
reqErr.Err = errRes.Error
652-
}
653-
return reqErr
656+
if err := json.Unmarshal(body, &errRes); err == nil && errRes.Error != nil {
657+
return setAPIErrorResponseMetadata(errRes.Error, resp.StatusCode, requestID)
658+
}
659+
660+
// Some services return the error object directly instead of wrapping it in
661+
// an {"error": ...} envelope. Preserve its structured fields when possible.
662+
var apiErr model.APIError
663+
if err := json.Unmarshal(body, &apiErr); err == nil &&
664+
(apiErr.Message != "" || apiErr.Code != "" || apiErr.Type != "") {
665+
return setAPIErrorResponseMetadata(&apiErr, resp.StatusCode, requestID)
666+
}
667+
668+
bodyText := strings.TrimSpace(string(body))
669+
if bodyText == "" {
670+
return model.NewRequestError(
671+
resp.StatusCode,
672+
errors.New("unexpected error response: empty body"),
673+
requestID,
674+
)
675+
}
676+
return model.NewRequestError(
677+
resp.StatusCode,
678+
fmt.Errorf("unexpected error response body: %s", bodyText),
679+
requestID,
680+
)
681+
}
682+
683+
func responseRequestID(resp *http.Response) string {
684+
if requestID := resp.Header.Get(model.ServerRequestHeader); requestID != "" {
685+
return requestID
686+
}
687+
if requestID := resp.Header.Get(model.ClientRequestHeader); requestID != "" {
688+
return requestID
689+
}
690+
if resp.Request != nil {
691+
return resp.Request.Header.Get(model.ClientRequestHeader)
654692
}
693+
return ""
694+
}
655695

656-
errRes.Error.HTTPStatusCode = resp.StatusCode
657-
errRes.Error.RequestId = requestID
658-
return errRes.Error
696+
func setAPIErrorResponseMetadata(apiErr *model.APIError, statusCode int, requestID string) error {
697+
apiErr.HTTPStatusCode = statusCode
698+
if requestID != "" {
699+
apiErr.RequestId = requestID
700+
}
701+
return apiErr
659702
}
660703

661704
func (c *Client) getRetryAfter(v model.Response) int64 {

arkruntime/error_response_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2026 ByteDance Ltd. and/or its affiliates.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package arkruntime
5+
6+
import (
7+
"errors"
8+
"io"
9+
"net/http"
10+
"strings"
11+
"testing"
12+
13+
"github.com/volcengine/ark-runtime-go/arkruntime/model"
14+
)
15+
16+
func TestHandleErrorResp(t *testing.T) {
17+
tests := []struct {
18+
name string
19+
body string
20+
headers http.Header
21+
request *http.Request
22+
wantAPI bool
23+
wantCode string
24+
wantID string
25+
wantInErr string
26+
}{
27+
{
28+
name: "wrapped API error",
29+
body: `{"error":{"code":"InvalidModel","message":"model not found","type":"invalid_request_error"}}`,
30+
headers: http.Header{model.ServerRequestHeader: []string{"server-request-id"}},
31+
wantAPI: true,
32+
wantCode: "InvalidModel",
33+
wantID: "server-request-id",
34+
},
35+
{
36+
name: "direct API error",
37+
body: `{"code":"InvalidModel","message":"model not found","type":"invalid_request_error","request_id":"body-request-id"}`,
38+
headers: http.Header{},
39+
wantAPI: true,
40+
wantCode: "InvalidModel",
41+
wantID: "body-request-id",
42+
},
43+
{
44+
name: "nonstandard JSON body",
45+
body: `{"detail":"model is invalid"}`,
46+
headers: http.Header{model.ServerRequestHeader: []string{"server-request-id"}},
47+
wantID: "server-request-id",
48+
wantInErr: `{"detail":"model is invalid"}`,
49+
},
50+
{
51+
name: "plain text body",
52+
body: "bad gateway",
53+
headers: http.Header{},
54+
request: requestWithClientID("client-request-id"),
55+
wantID: "client-request-id",
56+
wantInErr: "bad gateway",
57+
},
58+
{
59+
name: "empty body",
60+
body: "",
61+
headers: http.Header{},
62+
wantInErr: "unexpected error response: empty body",
63+
},
64+
}
65+
66+
client := &Client{}
67+
for _, tt := range tests {
68+
t.Run(tt.name, func(t *testing.T) {
69+
resp := &http.Response{
70+
StatusCode: http.StatusBadRequest,
71+
Header: tt.headers,
72+
Body: io.NopCloser(strings.NewReader(tt.body)),
73+
Request: tt.request,
74+
}
75+
err := client.handleErrorResp(resp)
76+
77+
if tt.wantAPI {
78+
var apiErr *model.APIError
79+
if !errors.As(err, &apiErr) {
80+
t.Fatalf("error = %T, want *model.APIError", err)
81+
}
82+
if apiErr.Code != tt.wantCode || apiErr.RequestId != tt.wantID {
83+
t.Fatalf("API error = %#v, want code %q and request ID %q", apiErr, tt.wantCode, tt.wantID)
84+
}
85+
return
86+
}
87+
88+
var requestErr *model.RequestError
89+
if !errors.As(err, &requestErr) {
90+
t.Fatalf("error = %T, want *model.RequestError", err)
91+
}
92+
if requestErr.Err == nil {
93+
t.Fatal("RequestError.Err is nil")
94+
}
95+
if requestErr.RequestId != tt.wantID {
96+
t.Fatalf("request ID = %q, want %q", requestErr.RequestId, tt.wantID)
97+
}
98+
if !strings.Contains(requestErr.Error(), tt.wantInErr) {
99+
t.Fatalf("error = %q, want it to contain %q", requestErr, tt.wantInErr)
100+
}
101+
})
102+
}
103+
}
104+
105+
func requestWithClientID(requestID string) *http.Request {
106+
request, _ := http.NewRequest(http.MethodPost, "https://example.com", nil)
107+
request.Header.Set(model.ClientRequestHeader, requestID)
108+
return request
109+
}

arkruntime/model/common.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
const (
1313
ClientRequestHeader = "X-Client-Request-Id"
14+
ServerRequestHeader = "X-Request-Id"
1415
RetryAfterHeader = "Retry-After"
1516

1617
DefaultMandatoryRefreshTimeout = 10 * 60 // 10 min

0 commit comments

Comments
 (0)