Capture destination-specific HTTP evidence

This commit is contained in:
2026-10-13 16:38:29 +00:00
parent b9c232ee63
commit 6eeb0ee9c0
3 changed files with 445 additions and 0 deletions

View File

@@ -417,6 +417,11 @@ response, no response, credential-stripping redirect, failed redirected hop, and
redirect-policy termination. Verify the supplied client is not mutated and redirect-policy termination. Verify the supplied client is not mutated and
evidence is isolated across concurrent calls. Test facts, not wrapper choreography. evidence is isolated across concurrent calls. Test facts, not wrapper choreography.
**Completed: 2026-10-13.** Added invocation-local last-hop HTTP evidence and
plain GET support over a shallow copy of the configured client. Validation
passed: `go test -race ./internal/llm`, `go vet ./internal/llm`,
`go test ./...`, and `git diff --check`.
## Stage 8: Retain Generation Evidence Before Result Discard ## Stage 8: Retain Generation Evidence Before Result Discard
Scope: `PreparedGeneration` execution in `internal/llm`. Scope: `PreparedGeneration` execution in `internal/llm`.

View File

@@ -0,0 +1,165 @@
package llm
import (
"context"
"io"
"net/http"
"sync"
"time"
)
// HTTPExecutionEvidence contains only facts observable at the configured
// RoundTripper boundary. It intentionally excludes request headers and bodies.
type HTTPExecutionEvidence struct {
DispatchInvoked bool
RequestObserved bool
Endpoint string
CredentialsSupplied bool
ResponseReceived bool
HTTPStatus *int
}
type httpEvidenceCollector struct {
mu sync.Mutex
evidence HTTPExecutionEvidence
}
func (c *httpEvidenceCollector) observeRequest(request *http.Request) {
c.mu.Lock()
defer c.mu.Unlock()
c.evidence.RequestObserved = true
c.evidence.Endpoint = endpointWithoutUserInfo(request)
c.evidence.CredentialsSupplied = requestHasCredentials(request)
c.evidence.ResponseReceived = false
c.evidence.HTTPStatus = nil
}
func (c *httpEvidenceCollector) observeResponse(response *http.Response) {
if response == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
status := response.StatusCode
c.evidence.ResponseReceived = true
c.evidence.HTTPStatus = &status
}
func (c *httpEvidenceCollector) snapshot() HTTPExecutionEvidence {
c.mu.Lock()
defer c.mu.Unlock()
evidence := c.evidence
if c.evidence.HTTPStatus != nil {
status := *c.evidence.HTTPStatus
evidence.HTTPStatus = &status
}
return evidence
}
type observingRoundTripper struct {
next http.RoundTripper
evidence *httpEvidenceCollector
}
func (t observingRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
t.evidence.observeRequest(request)
response, err := t.next.RoundTrip(request)
t.evidence.observeResponse(response)
return response, err
}
// doWithEvidence executes one request with a shallow copy of client. The copy
// preserves timeout, redirect, and cookie-jar behavior without changing client.
func doWithEvidence(client *http.Client, request *http.Request) (*http.Response, error, HTTPExecutionEvidence) {
if client == nil {
client = &http.Client{}
}
collector := &httpEvidenceCollector{}
clientCopy := *client
transport := clientCopy.Transport
if transport == nil {
transport = http.DefaultTransport
}
clientCopy.Transport = observingRoundTripper{next: transport, evidence: collector}
response, err := clientCopy.Do(request)
observed := collector.snapshot()
observed.DispatchInvoked = true
return response, err, observed
}
// GetWithEvidence performs a plain GET with the configured built-in HTTP
// client. A positive timeoutSeconds applies to the request through response
// body closure, matching the whole-request behavior of http.Client.Timeout.
func (c *OpenAICompatibleClient) GetWithEvidence(
ctx context.Context,
endpoint string,
timeoutSeconds int,
) (*http.Response, HTTPExecutionEvidence, error) {
requestContext := ctx
var cancel context.CancelFunc
if timeoutSeconds > 0 {
requestContext, cancel = context.WithTimeout(ctx, secondsDuration(timeoutSeconds))
}
request, err := http.NewRequestWithContext(requestContext, http.MethodGet, endpoint, nil)
if err != nil {
if cancel != nil {
cancel()
}
return nil, HTTPExecutionEvidence{}, err
}
response, err, evidence := doWithEvidence(c.httpClient, request)
if response == nil || response.Body == nil {
if cancel != nil {
cancel()
}
return response, evidence, err
}
if cancel != nil {
response.Body = &cancelOnCloseReadCloser{ReadCloser: response.Body, cancel: cancel}
}
return response, evidence, err
}
func secondsDuration(seconds int) time.Duration {
return time.Duration(seconds) * time.Second
}
type cancelOnCloseReadCloser struct {
io.ReadCloser
cancel context.CancelFunc
once sync.Once
}
func (r *cancelOnCloseReadCloser) Close() error {
r.once.Do(r.cancel)
return r.ReadCloser.Close()
}
func endpointWithoutUserInfo(request *http.Request) string {
if request == nil || request.URL == nil {
return ""
}
endpoint := *request.URL
endpoint.User = nil
return endpoint.String()
}
func requestHasCredentials(request *http.Request) bool {
if request == nil {
return false
}
if request.Header.Get("Authorization") != "" {
return true
}
return request.URL != nil && request.URL.User != nil
}
var _ http.RoundTripper = observingRoundTripper{}
var _ io.ReadCloser = (*cancelOnCloseReadCloser)(nil)

View File

@@ -0,0 +1,275 @@
package llm
import (
"context"
"errors"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
)
func TestGetWithEvidenceCapturesResponseAndOmitsURLUserInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.Header.Get("Authorization") == "" {
t.Error("request did not carry URL user credentials")
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{Timeout: time.Second})
if err != nil {
t.Fatalf("construct client: %v", err)
}
endpoint := strings.Replace(server.URL, "http://", "http://user:secret@", 1) + "/health"
response, evidence, err := client.GetWithEvidence(context.Background(), endpoint, 0)
if err != nil {
t.Fatalf("GET endpoint: %v", err)
}
if response == nil {
t.Fatal("GET returned no response")
}
response.Body.Close()
if !evidence.DispatchInvoked || !evidence.RequestObserved || !evidence.ResponseReceived {
t.Fatalf("incomplete response evidence: %+v", evidence)
}
if evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusNoContent {
t.Fatalf("status evidence = %v, want %d", evidence.HTTPStatus, http.StatusNoContent)
}
if !evidence.CredentialsSupplied {
t.Fatal("evidence did not record URL credentials")
}
if strings.Contains(evidence.Endpoint, "user") || strings.Contains(evidence.Endpoint, "secret") {
t.Fatalf("exposed endpoint contains URL user info: %q", evidence.Endpoint)
}
if want := server.URL + "/health"; evidence.Endpoint != want {
t.Fatalf("endpoint = %q, want %q", evidence.Endpoint, want)
}
}
func TestDoWithEvidenceRecordsRequestWithoutResponse(t *testing.T) {
transportErr := errors.New("transport unavailable")
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, transportErr
})}
request, err := http.NewRequest(http.MethodGet, "https://diagnostic.example/status", nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
response, err, evidence := doWithEvidence(client, request)
if response != nil || !errors.Is(err, transportErr) {
t.Fatalf("response/error = %v/%v, want nil/transport error", response, err)
}
if !evidence.DispatchInvoked || !evidence.RequestObserved || evidence.ResponseReceived {
t.Fatalf("unexpected request evidence: %+v", evidence)
}
if evidence.HTTPStatus != nil || evidence.Endpoint != request.URL.String() || evidence.CredentialsSupplied {
t.Fatalf("unexpected evidence values: %+v", evidence)
}
}
func TestDoWithEvidenceRetainsResponseReturnedAlongsideError(t *testing.T) {
transportErr := errors.New("transport reported an error with a response")
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
return responseFor(request, http.StatusBadGateway, ""), transportErr
})}
request, err := http.NewRequest(http.MethodGet, "https://diagnostic.example/status", nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
response, err, evidence := doWithEvidence(client, request)
if !errors.Is(err, transportErr) {
t.Fatalf("error = %v, want transport error", err)
}
if response != nil {
response.Body.Close()
}
if !evidence.RequestObserved || !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusBadGateway {
t.Fatalf("observed response was lost alongside error: %+v", evidence)
}
}
func TestDoWithEvidenceUsesLastRedirectRequest(t *testing.T) {
var observedAuthorization []string
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
observedAuthorization = append(observedAuthorization, request.Header.Get("Authorization"))
if request.URL.Host == "source.example" {
return responseFor(request, http.StatusFound, "https://other.example/final"), nil
}
return responseFor(request, http.StatusUnauthorized, ""), nil
})
client := &http.Client{Transport: transport}
request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
request.Header.Set("Authorization", "Bearer private-token")
response, err, evidence := doWithEvidence(client, request)
if err != nil {
t.Fatalf("follow redirect: %v", err)
}
if response == nil {
t.Fatal("redirect returned no response")
}
response.Body.Close()
if len(observedAuthorization) != 2 || observedAuthorization[0] == "" || observedAuthorization[1] != "" {
t.Fatalf("authorization across redirect = %#v", observedAuthorization)
}
if evidence.Endpoint != "https://other.example/final" || evidence.CredentialsSupplied {
t.Fatalf("evidence did not describe credential-stripped last hop: %+v", evidence)
}
if !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusUnauthorized {
t.Fatalf("last-hop response evidence = %+v", evidence)
}
}
func TestDoWithEvidenceClearsPriorResponseOnFailedRedirect(t *testing.T) {
transportErr := errors.New("redirect target unavailable")
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Host == "source.example" {
return responseFor(request, http.StatusTemporaryRedirect, "https://target.example/unavailable"), nil
}
return nil, transportErr
})
client := &http.Client{Transport: transport}
request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
response, err, evidence := doWithEvidence(client, request)
if response != nil || !errors.Is(err, transportErr) {
t.Fatalf("response/error = %v/%v, want nil/transport error", response, err)
}
if !evidence.RequestObserved || evidence.Endpoint != "https://target.example/unavailable" {
t.Fatalf("evidence did not retain failed last request: %+v", evidence)
}
if evidence.ResponseReceived || evidence.HTTPStatus != nil {
t.Fatalf("prior redirect response was not cleared: %+v", evidence)
}
}
func TestDoWithEvidenceRetainsResponseWhenRedirectPolicyStops(t *testing.T) {
redirectErr := errors.New("redirect rejected")
transportCalls := 0
client := &http.Client{
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
transportCalls++
return responseFor(request, http.StatusFound, "https://other.example/never-requested"), nil
}),
CheckRedirect: func(*http.Request, []*http.Request) error { return redirectErr },
}
request, err := http.NewRequest(http.MethodGet, "https://source.example/start", nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
response, err, evidence := doWithEvidence(client, request)
if !errors.Is(err, redirectErr) {
t.Fatalf("redirect error = %v, want policy error", err)
}
if response == nil || response.StatusCode != http.StatusFound {
t.Fatalf("redirect response = %v, want original response", response)
}
response.Body.Close()
if transportCalls != 1 || evidence.Endpoint != "https://source.example/start" {
t.Fatalf("redirect policy inferred or dispatched another request: calls=%d evidence=%+v", transportCalls, evidence)
}
if !evidence.ResponseReceived || evidence.HTTPStatus == nil || *evidence.HTTPStatus != http.StatusFound {
t.Fatalf("original response evidence was lost: %+v", evidence)
}
}
func TestGetWithEvidencePreservesClientAndIsolatesConcurrentCalls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
redirect := func(*http.Request, []*http.Request) error { return nil }
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("construct cookie jar: %v", err)
}
transport := http.DefaultTransport
supplied := &http.Client{Transport: transport, Timeout: 7 * time.Second, CheckRedirect: redirect, Jar: jar}
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{HTTPClient: supplied})
if err != nil {
t.Fatalf("construct client: %v", err)
}
const calls = 8
evidences := make([]HTTPExecutionEvidence, calls)
errs := make([]error, calls)
var wait sync.WaitGroup
for index := 0; index < calls; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
endpoint := server.URL + "/call/" + strconv.Itoa(index)
response, evidence, getErr := client.GetWithEvidence(context.Background(), endpoint, 0)
if response != nil {
response.Body.Close()
}
evidences[index], errs[index] = evidence, getErr
}(index)
}
wait.Wait()
if supplied.Transport != transport || supplied.Timeout != 7*time.Second || supplied.CheckRedirect == nil || supplied.Jar != jar {
t.Fatal("diagnostic GET mutated the supplied HTTP client")
}
for index, evidence := range evidences {
if errs[index] != nil {
t.Fatalf("GET %d: %v", index, errs[index])
}
want := server.URL + "/call/" + strconv.Itoa(index)
if !evidence.RequestObserved || evidence.Endpoint != want {
t.Fatalf("GET %d evidence = %+v, want endpoint %q", index, evidence, want)
}
}
}
func TestGetWithEvidenceAppliesTargetTimeout(t *testing.T) {
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{Timeout: 5 * time.Second, HTTPClient: &http.Client{
Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
<-request.Context().Done()
return nil, request.Context().Err()
}),
}})
if err != nil {
t.Fatalf("construct client: %v", err)
}
response, evidence, err := client.GetWithEvidence(context.Background(), "https://diagnostic.example/slow", 1)
if response != nil || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("response/error = %v/%v, want nil/deadline exceeded", response, err)
}
if !evidence.DispatchInvoked || !evidence.RequestObserved || evidence.ResponseReceived {
t.Fatalf("timeout evidence = %+v", evidence)
}
}
func responseFor(request *http.Request, status int, location string) *http.Response {
header := make(http.Header)
if location != "" {
header.Set("Location", location)
}
return &http.Response{
StatusCode: status,
Header: header,
Body: io.NopCloser(strings.NewReader("")),
Request: request,
}
}