Preserve transport error identities
This commit is contained in:
@@ -90,6 +90,11 @@ Invalid JSON, absent choices, and empty first-choice content are malformed
|
|||||||
responses. For a non-2xx status, the error includes the status code but never
|
responses. For a non-2xx status, the error includes the status code but never
|
||||||
the provider response body.
|
the provider response body.
|
||||||
|
|
||||||
|
An outbound `http.Client.Do` failure retains both Promptkit's request-failure
|
||||||
|
identity and the exact transport error for `errors.Is` and `errors.As` checks.
|
||||||
|
The rendered error does not include the selected endpoint, request headers,
|
||||||
|
request content, credentials, or provider body.
|
||||||
|
|
||||||
## Timeout And Cancellation
|
## Timeout And Cancellation
|
||||||
|
|
||||||
Timeouts are layered:
|
Timeouts are layered:
|
||||||
@@ -103,5 +108,7 @@ Timeouts are layered:
|
|||||||
timeout when the supplied value is not positive.
|
timeout when the supplied value is not positive.
|
||||||
|
|
||||||
The earliest applicable caller, generation, or transport deadline controls the
|
The earliest applicable caller, generation, or transport deadline controls the
|
||||||
request. Constructing the internal client does not mutate a supplied
|
request. Caller cancellation retains `context.Canceled`; caller, generation,
|
||||||
`http.Client`.
|
and whole-request timeout failures retain `context.DeadlineExceeded`, together
|
||||||
|
with the request-failure identity. Constructing the internal client does not
|
||||||
|
mutate a supplied `http.Client`.
|
||||||
|
|||||||
@@ -65,9 +65,14 @@ configuration, invalid generation requests, request execution failures,
|
|||||||
non-success provider statuses, and malformed successful responses. Provider
|
non-success provider statuses, and malformed successful responses. Provider
|
||||||
response bodies are not included in non-success errors.
|
response bodies are not included in non-success errors.
|
||||||
|
|
||||||
Caller cancellation and deadline failures during the outbound request are
|
An `http.Client.Do` failure is represented by a redacting multi-cause error:
|
||||||
reported as request execution failures. The runner classifies these identities
|
the package request-failure sentinel and the exact returned transport error are
|
||||||
without depending on HTTP status mapping.
|
both available through `errors.Is` and `errors.As`, while the rendered text
|
||||||
|
does not expose the endpoint, headers, request content, credential, transport
|
||||||
|
detail, or provider body. Caller cancellation retains `context.Canceled`;
|
||||||
|
caller deadlines, generation deadlines, and whole-request client timeouts
|
||||||
|
retain `context.DeadlineExceeded`. The runner adds its generation category
|
||||||
|
without discarding those identities or depending on HTTP status mapping.
|
||||||
|
|
||||||
## Test Ownership
|
## Test Ownership
|
||||||
|
|
||||||
@@ -75,7 +80,9 @@ The
|
|||||||
[OpenAI-compatible client tests](../../internal/llm/openai_compatible_client_test.go)
|
[OpenAI-compatible client tests](../../internal/llm/openai_compatible_client_test.go)
|
||||||
own configuration, client cloning, deterministic deadline precedence,
|
own configuration, client cloning, deterministic deadline precedence,
|
||||||
authentication, request and response mapping, malformed data, error identity,
|
authentication, request and response mapping, malformed data, error identity,
|
||||||
cancellation, and response-body suppression. The root transport contract test
|
cancellation, endpoint selection, and response-body suppression. The root
|
||||||
also verifies that resolved backend settings reach this client without
|
transport contract tests also verify that resolved backend settings reach this
|
||||||
serializing backend identity. All use local test servers or test transports;
|
client without serializing backend identity and that ordinary-run cancellation
|
||||||
the default suite makes no live or paid provider requests.
|
retains its public generation and context identities. All use local test
|
||||||
|
servers or controlled test transports; the default suite makes no live or paid
|
||||||
|
provider requests.
|
||||||
|
|||||||
@@ -737,6 +737,9 @@ func TestEngineRunPropagatesCallerCancellation(t *testing.T) {
|
|||||||
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
t.Fatalf("expected ErrLLMGenerate after caller cancellation, got %v", err)
|
t.Fatalf("expected ErrLLMGenerate after caller cancellation, got %v", err)
|
||||||
}
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context cancellation identity, got %v", err)
|
||||||
|
}
|
||||||
case <-watchdog.C:
|
case <-watchdog.C:
|
||||||
t.Fatal("timed out waiting for Engine.Run to return after cancellation")
|
t.Fatal("timed out waiting for Engine.Run to return after cancellation")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ var (
|
|||||||
ErrMalformedResponse = errors.New("malformed llm response")
|
ErrMalformedResponse = errors.New("malformed llm response")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type requestFailedError struct {
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *requestFailedError) Error() string {
|
||||||
|
return ErrRequestFailed.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *requestFailedError) Unwrap() []error {
|
||||||
|
return []error{ErrRequestFailed, e.cause}
|
||||||
|
}
|
||||||
|
|
||||||
type OpenAICompatibleConfig struct {
|
type OpenAICompatibleConfig struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Model string
|
Model string
|
||||||
@@ -130,7 +142,7 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
|||||||
|
|
||||||
httpResp, err := httpClient.Do(httpReq)
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrRequestFailed, err)
|
return nil, &requestFailedError{cause: err}
|
||||||
}
|
}
|
||||||
defer httpResp.Body.Close()
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,10 +22,14 @@ var errTransportStopped = errors.New("transport stopped after request inspection
|
|||||||
type deadlineCapturingTransport struct {
|
type deadlineCapturingTransport struct {
|
||||||
deadline time.Time
|
deadline time.Time
|
||||||
hasDeadline bool
|
hasDeadline bool
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *deadlineCapturingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (t *deadlineCapturingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
t.deadline, t.hasDeadline = req.Context().Deadline()
|
t.deadline, t.hasDeadline = req.Context().Deadline()
|
||||||
|
if t.err != nil {
|
||||||
|
return nil, t.err
|
||||||
|
}
|
||||||
return nil, errTransportStopped
|
return nil, errTransportStopped
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +39,19 @@ func (contextErrorTransport) RoundTrip(req *http.Request) (*http.Response, error
|
|||||||
return nil, req.Context().Err()
|
return nil, req.Context().Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
type waitingContextTransport struct{}
|
||||||
|
|
||||||
|
func (waitingContextTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
<-req.Context().Done()
|
||||||
|
return nil, req.Context().Err()
|
||||||
|
}
|
||||||
|
|
||||||
func assertDeadlineNear(t *testing.T, deadline, before, after time.Time, duration time.Duration) {
|
func assertDeadlineNear(t *testing.T, deadline, before, after time.Time, duration time.Duration) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -762,6 +781,9 @@ func TestOpenAICompatibleClientOmittedTimeoutUsesClientTimeout(t *testing.T) {
|
|||||||
if !errors.Is(err, ErrRequestFailed) {
|
if !errors.Is(err, ErrRequestFailed) {
|
||||||
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
||||||
}
|
}
|
||||||
|
if !errors.Is(err, errTransportStopped) {
|
||||||
|
t.Fatalf("expected transport cause, got %v", err)
|
||||||
|
}
|
||||||
if !transport.hasDeadline {
|
if !transport.hasDeadline {
|
||||||
t.Fatal("expected client timeout to set a transport deadline")
|
t.Fatal("expected client timeout to set a transport deadline")
|
||||||
}
|
}
|
||||||
@@ -1027,7 +1049,7 @@ func TestOpenAICompatibleClientMalformedResponseMissingChoices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAICompatibleClientGenerationTimeoutSetsEarlierDeadline(t *testing.T) {
|
func TestOpenAICompatibleClientGenerationTimeoutSetsEarlierDeadline(t *testing.T) {
|
||||||
transport := &deadlineCapturingTransport{}
|
transport := &deadlineCapturingTransport{err: context.DeadlineExceeded}
|
||||||
generationTimeout := 2 * time.Second
|
generationTimeout := 2 * time.Second
|
||||||
|
|
||||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
@@ -1056,6 +1078,9 @@ func TestOpenAICompatibleClientGenerationTimeoutSetsEarlierDeadline(t *testing.T
|
|||||||
if !errors.Is(err, ErrRequestFailed) {
|
if !errors.Is(err, ErrRequestFailed) {
|
||||||
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
||||||
}
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("expected generation deadline identity, got %v", err)
|
||||||
|
}
|
||||||
if !transport.hasDeadline {
|
if !transport.hasDeadline {
|
||||||
t.Fatal("expected generation timeout to set a transport deadline")
|
t.Fatal("expected generation timeout to set a transport deadline")
|
||||||
}
|
}
|
||||||
@@ -1063,7 +1088,7 @@ func TestOpenAICompatibleClientGenerationTimeoutSetsEarlierDeadline(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAICompatibleClientCallerDeadlineTakesPrecedence(t *testing.T) {
|
func TestOpenAICompatibleClientCallerDeadlineTakesPrecedence(t *testing.T) {
|
||||||
transport := &deadlineCapturingTransport{}
|
transport := &deadlineCapturingTransport{err: context.DeadlineExceeded}
|
||||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
BaseURL: "http://example.com/v1",
|
BaseURL: "http://example.com/v1",
|
||||||
Model: "m",
|
Model: "m",
|
||||||
@@ -1092,6 +1117,9 @@ func TestOpenAICompatibleClientCallerDeadlineTakesPrecedence(t *testing.T) {
|
|||||||
if !errors.Is(err, ErrRequestFailed) {
|
if !errors.Is(err, ErrRequestFailed) {
|
||||||
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
||||||
}
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("expected caller deadline identity, got %v", err)
|
||||||
|
}
|
||||||
if !transport.hasDeadline {
|
if !transport.hasDeadline {
|
||||||
t.Fatal("expected caller context to set a transport deadline")
|
t.Fatal("expected caller context to set a transport deadline")
|
||||||
}
|
}
|
||||||
@@ -1124,8 +1152,87 @@ func TestOpenAICompatibleClientCancellationReturnsRequestFailure(t *testing.T) {
|
|||||||
if !errors.Is(err, ErrRequestFailed) {
|
if !errors.Is(err, ErrRequestFailed) {
|
||||||
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), context.Canceled.Error()) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Fatalf("expected cancellation detail, got %v", err)
|
t.Fatalf("expected cancellation identity, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientExpiredCallerDeadlineReturnsRequestFailure(t *testing.T) {
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "http://example.com/v1",
|
||||||
|
Model: "m",
|
||||||
|
HTTPClient: &http.Client{
|
||||||
|
Transport: contextErrorTransport{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithDeadline(context.Background(), time.Unix(1, 0))
|
||||||
|
defer cancel()
|
||||||
|
_, err = client.Generate(ctx, domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrRequestFailed) || !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("expected request failure and caller deadline identities, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientWholeRequestTimeoutReturnsRequestFailure(t *testing.T) {
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "http://example.com/v1",
|
||||||
|
Model: "m",
|
||||||
|
HTTPClient: &http.Client{
|
||||||
|
Timeout: time.Millisecond,
|
||||||
|
Transport: waitingContextTransport{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrRequestFailed) || !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("expected request failure and client timeout identities, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientTransportFailurePreservesCauseWithoutSensitiveText(t *testing.T) {
|
||||||
|
transportCause := errors.New("transport diagnostic")
|
||||||
|
const (
|
||||||
|
endpoint = "http://sensitive-endpoint.example/private"
|
||||||
|
apiKey = "sensitive-api-key"
|
||||||
|
content = "sensitive prompt content"
|
||||||
|
)
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: endpoint,
|
||||||
|
Model: "m",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, transportCause
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: content}}},
|
||||||
|
Target: domain.ExecutionTarget{APIKey: apiKey},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrRequestFailed) || !errors.Is(err, transportCause) {
|
||||||
|
t.Fatalf("expected request failure and transport cause identities, got %v", err)
|
||||||
|
}
|
||||||
|
var requestErr *url.Error
|
||||||
|
if !errors.As(err, &requestErr) {
|
||||||
|
t.Fatalf("expected underlying http.Client.Do URL error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
for _, sensitive := range []string{endpoint, "sensitive-endpoint.example", apiKey, content, transportCause.Error()} {
|
||||||
|
if strings.Contains(err.Error(), sensitive) {
|
||||||
|
t.Fatalf("transport error exposed %q: %v", sensitive, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1170,23 +1277,38 @@ func TestOpenAICompatibleClientRejectsInvalidExecutionSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAICompatibleClientAllowsEmptyConfiguredBaseURL(t *testing.T) {
|
func TestOpenAICompatibleClientAllowsEmptyConfiguredBaseURL(t *testing.T) {
|
||||||
|
var selectedURL string
|
||||||
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
BaseURL: "",
|
BaseURL: "",
|
||||||
Model: "m",
|
Model: "m",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
selectedURL = req.URL.String()
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(
|
||||||
|
`{"choices":[{"message":{"content":"request endpoint"}}]}`,
|
||||||
|
)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
})},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected empty configured base URL to be allowed, got %v", err)
|
t.Fatalf("expected empty configured base URL to be allowed, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
response, err := client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
Target: domain.ExecutionTarget{Endpoint: "http://localhost:9999/v1"},
|
Target: domain.ExecutionTarget{Endpoint: "http://request-endpoint.example/v1"},
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("expected request failure due to unreachable endpoint")
|
t.Fatalf("generate with request endpoint: %v", err)
|
||||||
}
|
}
|
||||||
if !errors.Is(err, ErrRequestFailed) {
|
if response.Content != "request endpoint" {
|
||||||
t.Fatalf("expected ErrRequestFailed with request endpoint override, got %v", err)
|
t.Fatalf("response content = %q, want request endpoint", response.Content)
|
||||||
|
}
|
||||||
|
if selectedURL != "http://request-endpoint.example/v1/chat/completions" {
|
||||||
|
t.Fatalf("selected URL = %q", selectedURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user