Preserve transport error identities

This commit is contained in:
2026-08-11 23:28:00 +00:00
parent 350b0e76d9
commit c281f721bc
5 changed files with 171 additions and 20 deletions

View File

@@ -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
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
Timeouts are layered:
@@ -103,5 +108,7 @@ Timeouts are layered:
timeout when the supplied value is not positive.
The earliest applicable caller, generation, or transport deadline controls the
request. Constructing the internal client does not mutate a supplied
`http.Client`.
request. Caller cancellation retains `context.Canceled`; caller, generation,
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`.

View File

@@ -65,9 +65,14 @@ configuration, invalid generation requests, request execution failures,
non-success provider statuses, and malformed successful responses. Provider
response bodies are not included in non-success errors.
Caller cancellation and deadline failures during the outbound request are
reported as request execution failures. The runner classifies these identities
without depending on HTTP status mapping.
An `http.Client.Do` failure is represented by a redacting multi-cause error:
the package request-failure sentinel and the exact returned transport error are
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
@@ -75,7 +80,9 @@ The
[OpenAI-compatible client tests](../../internal/llm/openai_compatible_client_test.go)
own configuration, client cloning, deterministic deadline precedence,
authentication, request and response mapping, malformed data, error identity,
cancellation, and response-body suppression. The root transport contract test
also verifies that resolved backend settings reach this client without
serializing backend identity. All use local test servers or test transports;
the default suite makes no live or paid provider requests.
cancellation, endpoint selection, and response-body suppression. The root
transport contract tests also verify that resolved backend settings reach this
client without serializing backend identity and that ordinary-run cancellation
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.

View File

@@ -737,6 +737,9 @@ func TestEngineRunPropagatesCallerCancellation(t *testing.T) {
if !errors.Is(err, promptkit.ErrLLMGenerate) {
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:
t.Fatal("timed out waiting for Engine.Run to return after cancellation")
}

View File

@@ -25,6 +25,18 @@ var (
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 {
BaseURL string
Model string
@@ -130,7 +142,7 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
httpResp, err := httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrRequestFailed, err)
return nil, &requestFailedError{cause: err}
}
defer httpResp.Body.Close()

View File

@@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
@@ -20,10 +22,14 @@ var errTransportStopped = errors.New("transport stopped after request inspection
type deadlineCapturingTransport struct {
deadline time.Time
hasDeadline bool
err error
}
func (t *deadlineCapturingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.deadline, t.hasDeadline = req.Context().Deadline()
if t.err != nil {
return nil, t.err
}
return nil, errTransportStopped
}
@@ -33,6 +39,19 @@ func (contextErrorTransport) RoundTrip(req *http.Request) (*http.Response, error
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) {
t.Helper()
@@ -762,6 +781,9 @@ func TestOpenAICompatibleClientOmittedTimeoutUsesClientTimeout(t *testing.T) {
if !errors.Is(err, ErrRequestFailed) {
t.Fatalf("expected ErrRequestFailed, got %v", err)
}
if !errors.Is(err, errTransportStopped) {
t.Fatalf("expected transport cause, got %v", err)
}
if !transport.hasDeadline {
t.Fatal("expected client timeout to set a transport deadline")
}
@@ -1027,7 +1049,7 @@ func TestOpenAICompatibleClientMalformedResponseMissingChoices(t *testing.T) {
}
func TestOpenAICompatibleClientGenerationTimeoutSetsEarlierDeadline(t *testing.T) {
transport := &deadlineCapturingTransport{}
transport := &deadlineCapturingTransport{err: context.DeadlineExceeded}
generationTimeout := 2 * time.Second
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
@@ -1056,6 +1078,9 @@ func TestOpenAICompatibleClientGenerationTimeoutSetsEarlierDeadline(t *testing.T
if !errors.Is(err, ErrRequestFailed) {
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 {
t.Fatal("expected generation timeout to set a transport deadline")
}
@@ -1063,7 +1088,7 @@ func TestOpenAICompatibleClientGenerationTimeoutSetsEarlierDeadline(t *testing.T
}
func TestOpenAICompatibleClientCallerDeadlineTakesPrecedence(t *testing.T) {
transport := &deadlineCapturingTransport{}
transport := &deadlineCapturingTransport{err: context.DeadlineExceeded}
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "http://example.com/v1",
Model: "m",
@@ -1092,6 +1117,9 @@ func TestOpenAICompatibleClientCallerDeadlineTakesPrecedence(t *testing.T) {
if !errors.Is(err, ErrRequestFailed) {
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 {
t.Fatal("expected caller context to set a transport deadline")
}
@@ -1124,8 +1152,87 @@ func TestOpenAICompatibleClientCancellationReturnsRequestFailure(t *testing.T) {
if !errors.Is(err, ErrRequestFailed) {
t.Fatalf("expected ErrRequestFailed, got %v", err)
}
if !strings.Contains(err.Error(), context.Canceled.Error()) {
t.Fatalf("expected cancellation detail, got %v", err)
if !errors.Is(err, context.Canceled) {
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) {
var selectedURL string
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "",
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 {
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"}}},
Target: domain.ExecutionTarget{Endpoint: "http://localhost:9999/v1"},
Target: domain.ExecutionTarget{Endpoint: "http://request-endpoint.example/v1"},
})
if err == nil {
t.Fatal("expected request failure due to unreachable endpoint")
if err != nil {
t.Fatalf("generate with request endpoint: %v", err)
}
if !errors.Is(err, ErrRequestFailed) {
t.Fatalf("expected ErrRequestFailed with request endpoint override, got %v", err)
if response.Content != "request endpoint" {
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)
}
}