Skip to content

Commit 798d8d4

Browse files
j-mok-devshimib
andauthored
Preserve HTTP status and surface non-HTTP failures in async results (llm-d#300)
* Preserve HTTP status and surface non-HTTP failures in async results Signed-off-by: Jooyeon Mok <jmok@redhat.com> * address review: InferenceResponse struct, pool gate re-enqueue on shutdown, test fixes Signed-off-by: Jooyeon Mok <jmok@redhat.com> * docs: add release-notes fragment for ResultMessage wire-format changes Record the breaking wire-format changes (StatusCode/ErrorCode/ErrorMessage, gate-drop payload, SendRequest signature) as a release-notes.d fragment, following the llm-d-router convention so the change has a durable home. Signed-off-by: Shimi Bandiel <shimib@google.com> --------- Signed-off-by: Jooyeon Mok <jmok@redhat.com> Signed-off-by: Shimi Bandiel <shimib@google.com> Co-authored-by: Shimi Bandiel <shimib@google.com>
1 parent 651c53e commit 798d8d4

12 files changed

Lines changed: 611 additions & 94 deletions

File tree

api/api.go

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package api
22

3+
import "encoding/json"
4+
35
// Request is the public interface for submitting requests to the async queue.
46
// It exposes only the caller-visible fields. Concrete types like RequestMessage,
57
// RedisRequest, and PubSubRequest satisfy this interface.
@@ -58,11 +60,71 @@ var (
5860
_ Request = (*PubSubRequest)(nil)
5961
)
6062

61-
// ResultMessage is the async inference result returned to callers. ID and Payload are
62-
// JSON fields; Routing and Metadata are infrastructure pass-through (json:"-").
63+
// ResultMessage is the async inference result returned to callers.
64+
//
65+
// Wire-format semantics:
66+
// - StatusCode > 0: an HTTP response was received. Payload contains the response body.
67+
// - StatusCode == 0: no HTTP response. ErrorCode/ErrorMessage describe the failure.
68+
//
69+
// Routing and Metadata are infrastructure pass-through (json:"-").
6370
type ResultMessage struct {
64-
ID string `json:"id"`
65-
Payload string `json:"payload"`
66-
Routing InternalRouting `json:"-"`
67-
Metadata map[string]string `json:"-"`
71+
ID string `json:"id"`
72+
StatusCode int `json:"status_code,omitempty"`
73+
Payload string `json:"payload"`
74+
ErrorCode string `json:"error_code,omitempty"`
75+
ErrorMessage string `json:"error_message,omitempty"`
76+
Routing InternalRouting `json:"-"`
77+
Metadata map[string]string `json:"-"`
78+
}
79+
80+
// Error codes for non-HTTP failures surfaced in ResultMessage.ErrorCode.
81+
// These are result-level codes describing why a request could not be completed.
82+
const (
83+
ErrCodeDeadlineExceeded = "DEADLINE_EXCEEDED"
84+
ErrCodeGateDropped = "GATE_DROPPED"
85+
ErrCodeGateError = "GATE_ERROR"
86+
ErrCodeInferenceError = "INFERENCE_ERROR"
87+
ErrCodeInvalidRequest = "INVALID_REQUEST"
88+
)
89+
90+
// NewErrorResult builds a non-HTTP error ResultMessage.
91+
// errorCode must be one of the ErrCode* constants; errMsg is a human-readable description.
92+
// Payload is populated with a JSON error object for backward compatibility with
93+
// consumers that only read Payload.
94+
func NewErrorResult(req Request, routing InternalRouting, errorCode, errMsg string) ResultMessage {
95+
errorPayload := map[string]string{"error": errMsg}
96+
payloadBytes, err := json.Marshal(errorPayload)
97+
if err != nil {
98+
payloadBytes = []byte(`{"error": "internal error"}`)
99+
}
100+
return ResultMessage{
101+
ID: req.ReqID(),
102+
Payload: string(payloadBytes),
103+
ErrorCode: errorCode,
104+
ErrorMessage: errMsg,
105+
Routing: routing,
106+
Metadata: req.ReqMetadata(),
107+
}
108+
}
109+
110+
// NewHTTPResult builds a ResultMessage for any HTTP response (success or error).
111+
// statusCode is the actual HTTP status code; responseBody is the raw body.
112+
func NewHTTPResult(req Request, routing InternalRouting, statusCode int, responseBody []byte) ResultMessage {
113+
return ResultMessage{
114+
ID: req.ReqID(),
115+
StatusCode: statusCode,
116+
Payload: string(responseBody),
117+
Routing: routing,
118+
Metadata: req.ReqMetadata(),
119+
}
120+
}
121+
122+
// NewGateDroppedResult builds a ResultMessage for a gate-dropped request.
123+
func NewGateDroppedResult(req Request, routing InternalRouting) ResultMessage {
124+
return NewErrorResult(req, routing, ErrCodeGateDropped, "Pool gating dropped request")
125+
}
126+
127+
// NewDeadlineExceededResult builds a ResultMessage for deadline expiry.
128+
func NewDeadlineExceededResult(req Request, routing InternalRouting) ResultMessage {
129+
return NewErrorResult(req, routing, ErrCodeDeadlineExceeded, "deadline exceeded")
68130
}

api/inference_client.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,25 @@ package api
22

33
import "context"
44

5+
// InferenceResponse holds the HTTP response from an upstream inference request.
6+
// StatusCode is non-zero whenever an HTTP response was received (including 4xx/5xx).
7+
// A zero StatusCode means no HTTP response was obtained (e.g. transport/network failure).
8+
// Body contains the response body; it may be partial if a read error occurred.
9+
type InferenceResponse struct {
10+
StatusCode int
11+
Body []byte
12+
}
13+
514
// InferenceClient defines the interface for sending inference requests.
615
// This interface allows for pluggable implementations beyond the default HTTP client.
716
type InferenceClient interface {
817
// SendRequest sends an inference request to the specified URL with the given headers and payload.
9-
// Returns the response body and any error that occurred.
18+
//
19+
// On success (nil error), the returned *InferenceResponse is always non-nil.
20+
// On error, *InferenceResponse may still be non-nil if the upstream sent an HTTP
21+
// response (e.g. 4xx/5xx); callers should check resp.StatusCode > 0 to determine
22+
// whether an HTTP response was received.
23+
//
1024
// Errors should implement InferenceError to provide an ErrorCategory via Category().
1125
// ErrorCategory determines retry and shedding behavior through its Fatal() and Sheddable() methods:
1226
// - ErrCategoryRateLimit: retryable, sheddable (e.g. 429)
@@ -15,6 +29,5 @@ type InferenceClient interface {
1529
// - ErrCategoryAuth: not retryable
1630
// - ErrCategoryParse: not retryable
1731
// - ErrCategoryUnknown: not retryable
18-
// A nil error indicates a successful response.
19-
SendRequest(ctx context.Context, url string, headers map[string]string, payload []byte) (responseBody []byte, err error)
32+
SendRequest(ctx context.Context, url string, headers map[string]string, payload []byte) (*InferenceResponse, error)
2033
}

api/inference_error.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type ClientError struct {
4242
Message string
4343
RawError error // original error if available
4444
RetryAfter time.Duration // server-specified retry delay from Retry-After header (0 means not set)
45+
StatusCode int // HTTP status code; 0 means no HTTP response was received
4546
}
4647

4748
func (e *ClientError) Error() string {

pkg/asyncworker/http_client.go

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func NewHTTPInferenceClient(client *http.Client) *HTTPInferenceClient {
2525
}
2626

2727
// SendRequest implements InferenceClient for HTTP-based inference requests.
28-
func (h *HTTPInferenceClient) SendRequest(ctx context.Context, url string, headers map[string]string, payload []byte) ([]byte, error) {
28+
func (h *HTTPInferenceClient) SendRequest(ctx context.Context, url string, headers map[string]string, payload []byte) (*asyncapi.InferenceResponse, error) {
2929
request, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
3030
if err != nil {
3131
return nil, &asyncapi.ClientError{
@@ -51,44 +51,43 @@ func (h *HTTPInferenceClient) SendRequest(ctx context.Context, url string, heade
5151

5252
body, err := io.ReadAll(result.Body)
5353
if err != nil {
54-
// Response read errors are retryable as the request may have succeeded
55-
return nil, &asyncapi.ClientError{
54+
return &asyncapi.InferenceResponse{StatusCode: result.StatusCode, Body: body}, &asyncapi.ClientError{
5655
ErrorCategory: asyncapi.ErrCategoryServer,
5756
Message: "failed to read response",
5857
RawError: err,
58+
StatusCode: result.StatusCode,
5959
}
6060
}
6161

62-
// Check for rate limiting / load shedding (429)
62+
resp := &asyncapi.InferenceResponse{StatusCode: result.StatusCode, Body: body}
63+
6364
if result.StatusCode == 429 {
6465
retryAfter, _ := parseRetryAfter(result.Header.Get("Retry-After"))
65-
return body, &asyncapi.ClientError{
66+
return resp, &asyncapi.ClientError{
6667
ErrorCategory: asyncapi.ErrCategoryRateLimit,
6768
Message: fmt.Sprintf("rate limited: status code %d", result.StatusCode),
68-
RawError: nil,
6969
RetryAfter: retryAfter,
70+
StatusCode: result.StatusCode,
7071
}
7172
}
7273

73-
// Check for client errors (4xx, non-429)
7474
if result.StatusCode >= 400 && result.StatusCode < 500 {
75-
return body, &asyncapi.ClientError{
75+
return resp, &asyncapi.ClientError{
7676
ErrorCategory: asyncapi.ErrCategoryInvalidReq,
7777
Message: fmt.Sprintf("client error: status code %d", result.StatusCode),
78-
RawError: nil,
78+
StatusCode: result.StatusCode,
7979
}
8080
}
8181

82-
// Check for server errors (5xx)
8382
if result.StatusCode >= 500 && result.StatusCode < 600 {
84-
return body, &asyncapi.ClientError{
83+
return resp, &asyncapi.ClientError{
8584
ErrorCategory: asyncapi.ErrCategoryServer,
8685
Message: fmt.Sprintf("server error: status code %d", result.StatusCode),
87-
RawError: nil,
86+
StatusCode: result.StatusCode,
8887
}
8988
}
9089

91-
return body, nil
90+
return resp, nil
9291
}
9392

9493
// parseRetryAfter parses a Retry-After header value, which can be either

pkg/asyncworker/http_client_test.go

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ func TestSendRequest_success(t *testing.T) {
2323
}, nil
2424
}))
2525

26-
got, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
26+
resp, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
2727
if err != nil {
2828
t.Fatalf("unexpected error: %v", err)
2929
}
30-
if string(got) != body {
31-
t.Errorf("body = %q, want %q", string(got), body)
30+
if string(resp.Body) != body {
31+
t.Errorf("body = %q, want %q", string(resp.Body), body)
32+
}
33+
if resp.StatusCode != http.StatusOK {
34+
t.Errorf("statusCode = %d, want %d", resp.StatusCode, http.StatusOK)
3235
}
3336
}
3437

@@ -68,7 +71,7 @@ func TestSendRequest_rateLimitWithoutRetryAfter(t *testing.T) {
6871
}, nil
6972
}))
7073

71-
body, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
74+
resp, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
7275
if err == nil {
7376
t.Fatal("expected error for 429 response")
7477
}
@@ -79,11 +82,17 @@ func TestSendRequest_rateLimitWithoutRetryAfter(t *testing.T) {
7982
if ce.ErrorCategory != asyncapi.ErrCategoryRateLimit {
8083
t.Errorf("category = %s, want %s", ce.ErrorCategory, asyncapi.ErrCategoryRateLimit)
8184
}
85+
if ce.StatusCode != http.StatusTooManyRequests {
86+
t.Errorf("StatusCode = %d, want %d", ce.StatusCode, http.StatusTooManyRequests)
87+
}
88+
if resp.StatusCode != http.StatusTooManyRequests {
89+
t.Errorf("returned statusCode = %d, want %d", resp.StatusCode, http.StatusTooManyRequests)
90+
}
8291
if ce.RetryAfter != 0 {
8392
t.Errorf("RetryAfter = %v, want 0 (no header)", ce.RetryAfter)
8493
}
85-
if string(body) != respBody {
86-
t.Errorf("body = %q, want %q", string(body), respBody)
94+
if string(resp.Body) != respBody {
95+
t.Errorf("body = %q, want %q", string(resp.Body), respBody)
8796
}
8897
}
8998

@@ -120,7 +129,7 @@ func TestSendRequest_clientError(t *testing.T) {
120129
}, nil
121130
}))
122131

123-
body, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
132+
resp, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
124133
if err == nil {
125134
t.Fatal("expected error for 400 response")
126135
}
@@ -131,7 +140,13 @@ func TestSendRequest_clientError(t *testing.T) {
131140
if ce.ErrorCategory != asyncapi.ErrCategoryInvalidReq {
132141
t.Errorf("category = %s, want %s", ce.ErrorCategory, asyncapi.ErrCategoryInvalidReq)
133142
}
134-
if len(body) == 0 {
143+
if ce.StatusCode != http.StatusBadRequest {
144+
t.Errorf("StatusCode = %d, want %d", ce.StatusCode, http.StatusBadRequest)
145+
}
146+
if resp.StatusCode != http.StatusBadRequest {
147+
t.Errorf("returned statusCode = %d, want %d", resp.StatusCode, http.StatusBadRequest)
148+
}
149+
if len(resp.Body) == 0 {
135150
t.Error("expected response body to be returned with 4xx error")
136151
}
137152
}
@@ -145,7 +160,7 @@ func TestSendRequest_serverError(t *testing.T) {
145160
}, nil
146161
}))
147162

148-
body, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
163+
resp, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
149164
if err == nil {
150165
t.Fatal("expected error for 500 response")
151166
}
@@ -156,7 +171,13 @@ func TestSendRequest_serverError(t *testing.T) {
156171
if ce.ErrorCategory != asyncapi.ErrCategoryServer {
157172
t.Errorf("category = %s, want %s", ce.ErrorCategory, asyncapi.ErrCategoryServer)
158173
}
159-
if len(body) == 0 {
174+
if ce.StatusCode != http.StatusInternalServerError {
175+
t.Errorf("StatusCode = %d, want %d", ce.StatusCode, http.StatusInternalServerError)
176+
}
177+
if resp.StatusCode != http.StatusInternalServerError {
178+
t.Errorf("returned statusCode = %d, want %d", resp.StatusCode, http.StatusInternalServerError)
179+
}
180+
if len(resp.Body) == 0 {
160181
t.Error("expected response body to be returned with 5xx error")
161182
}
162183
}
@@ -166,7 +187,7 @@ func TestSendRequest_transportError(t *testing.T) {
166187
return nil, fmt.Errorf("connection refused")
167188
}))
168189

169-
_, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
190+
resp, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
170191
if err == nil {
171192
t.Fatal("expected error for transport failure")
172193
}
@@ -177,6 +198,12 @@ func TestSendRequest_transportError(t *testing.T) {
177198
if ce.ErrorCategory != asyncapi.ErrCategoryUnknown {
178199
t.Errorf("category = %s, want %s", ce.ErrorCategory, asyncapi.ErrCategoryUnknown)
179200
}
201+
if ce.StatusCode != 0 {
202+
t.Errorf("StatusCode = %d, want 0 for transport error", ce.StatusCode)
203+
}
204+
if resp != nil {
205+
t.Errorf("resp = %+v, want nil for transport error", resp)
206+
}
180207
}
181208

182209
func TestSendRequest_invalidURL(t *testing.T) {
@@ -220,6 +247,43 @@ func TestSendRequest_contextCancellation(t *testing.T) {
220247
}
221248
}
222249

250+
func TestSendRequest_bodyReadFailurePreservesStatusCode(t *testing.T) {
251+
client := NewHTTPInferenceClient(NewTestClient(func(req *http.Request) (*http.Response, error) {
252+
return &http.Response{
253+
StatusCode: http.StatusOK,
254+
Body: io.NopCloser(&failReader{}),
255+
Header: make(http.Header),
256+
}, nil
257+
}))
258+
259+
resp, err := client.SendRequest(context.Background(), "http://localhost/v1/completions", nil, []byte(`{}`))
260+
if err == nil {
261+
t.Fatal("expected error for body read failure")
262+
}
263+
var ce *asyncapi.ClientError
264+
if !errors.As(err, &ce) {
265+
t.Fatalf("expected *ClientError, got %T", err)
266+
}
267+
if ce.ErrorCategory != asyncapi.ErrCategoryServer {
268+
t.Errorf("category = %s, want %s", ce.ErrorCategory, asyncapi.ErrCategoryServer)
269+
}
270+
if resp == nil {
271+
t.Fatal("expected non-nil response when HTTP response was received")
272+
}
273+
if resp.StatusCode != http.StatusOK {
274+
t.Errorf("returned statusCode = %d, want %d (response was received)", resp.StatusCode, http.StatusOK)
275+
}
276+
if ce.StatusCode != http.StatusOK {
277+
t.Errorf("ClientError.StatusCode = %d, want %d", ce.StatusCode, http.StatusOK)
278+
}
279+
}
280+
281+
type failReader struct{}
282+
283+
func (f *failReader) Read([]byte) (int, error) {
284+
return 0, fmt.Errorf("simulated read error")
285+
}
286+
223287
func TestNewHTTPInferenceClient(t *testing.T) {
224288
httpClient := &http.Client{}
225289
client := NewHTTPInferenceClient(httpClient)

0 commit comments

Comments
 (0)