Capture destination-specific HTTP evidence
This commit is contained in:
@@ -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
|
||||
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
|
||||
|
||||
Scope: `PreparedGeneration` execution in `internal/llm`.
|
||||
|
||||
165
internal/llm/http_evidence.go
Normal file
165
internal/llm/http_evidence.go
Normal 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)
|
||||
275
internal/llm/http_evidence_test.go
Normal file
275
internal/llm/http_evidence_test.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user