Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 54 additions & 11 deletions arkruntime/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
109 changes: 109 additions & 0 deletions arkruntime/error_response_test.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions arkruntime/model/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

const (
ClientRequestHeader = "X-Client-Request-Id"
ServerRequestHeader = "X-Request-Id"
RetryAfterHeader = "Retry-After"

DefaultMandatoryRefreshTimeout = 10 * 60 // 10 min
Expand Down
Loading