From 2e81c9e61b2aad4588acf23f47903701870e9a6e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 13 Oct 2026 17:12:31 +0000 Subject: [PATCH] Add the unauthenticated reachability probe --- docs/roadmap/implementation.md | 7 + internal/usecase/smoke_reachability.go | 77 +++++++++++ internal/usecase/smoke_reachability_test.go | 136 ++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 internal/usecase/smoke_reachability.go create mode 100644 internal/usecase/smoke_reachability_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e6dbd9f..344ca9c 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -512,6 +512,13 @@ Validate: internal probe tests for 2xx/401/404/405, transport failure, timeout, and unsupported injected transport. Derive evidence from Stage 7 rather than repeat redirect matrices here. Close response bodies; do not retain them. +**Completed: 2026-10-13.** Added the unauthenticated, single-GET reachability +probe over the built-in evidence-aware HTTP client. It records any response as +reachable regardless of status, closes and discards bodies, and reports missing +HTTP support without fallback. Validation passed: `go test -race +./internal/usecase ./internal/llm`, `go vet ./...`, `go test ./...`, and +`git diff --check`. + ## Stage 12: Implement Authentication Applicability And OpenRouter Probe Scope: named HTTP authentication adapter; no inference orchestration. diff --git a/internal/usecase/smoke_reachability.go b/internal/usecase/smoke_reachability.go new file mode 100644 index 0000000..9774a40 --- /dev/null +++ b/internal/usecase/smoke_reachability.go @@ -0,0 +1,77 @@ +package usecase + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" + "gitea.maximumdirect.net/eric/promptkit/internal/generation" + "gitea.maximumdirect.net/eric/promptkit/internal/llm" +) + +type smokeHTTPGetter interface { + GetWithEvidence(context.Context, string, int) (*http.Response, llm.HTTPExecutionEvidence, error) +} + +type reachabilityProbe struct { + client smokeHTTPGetter +} + +type reachabilityProbeOutcome struct { + supported bool + reason domain.SmokeTestReason + initialEndpoint string + reachable *bool + evidence llm.HTTPExecutionEvidence + failure domain.ExecutionFailureKind +} + +func (p reachabilityProbe) run( + ctx context.Context, + endpoint string, + timeoutSeconds int, +) (reachabilityProbeOutcome, error) { + if p.client == nil { + return reachabilityProbeOutcome{ + reason: domain.SmokeTestReasonHTTPDiagnosticsUnavailable, + }, nil + } + + endpoint, err := endpointWithoutUserInfo(endpoint) + if err != nil { + return reachabilityProbeOutcome{}, fmt.Errorf("prepare reachability endpoint: %w", err) + } + result := reachabilityProbeOutcome{supported: true, initialEndpoint: endpoint} + response, evidence, requestErr := p.client.GetWithEvidence(ctx, endpoint, timeoutSeconds) + if response != nil && response.Body != nil { + _ = response.Body.Close() + } + result.evidence = evidence + if evidence.ResponseReceived || response != nil { + result.reachable = boolPointer(true) + return result, nil + } + if evidence.DispatchInvoked { + result.reachable = boolPointer(false) + } + if requestErr != nil { + result.failure = generation.ClassifyFailure(fmt.Errorf("%w: %w", llm.ErrTransport, requestErr)) + if !evidence.DispatchInvoked { + return result, fmt.Errorf("execute reachability request: %w", requestErr) + } + } + return result, nil +} + +func endpointWithoutUserInfo(endpoint string) (string, error) { + parsed, err := url.Parse(endpoint) + if err != nil { + return "", err + } + parsed.User = nil + return parsed.String(), nil +} + +func boolPointer(value bool) *bool { return &value } diff --git a/internal/usecase/smoke_reachability_test.go b/internal/usecase/smoke_reachability_test.go new file mode 100644 index 0000000..8d876ef --- /dev/null +++ b/internal/usecase/smoke_reachability_test.go @@ -0,0 +1,136 @@ +package usecase + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" + "gitea.maximumdirect.net/eric/promptkit/internal/llm" +) + +func TestReachabilityProbeTreatsEveryHTTPResponseAsReachable(t *testing.T) { + for _, status := range []int{ + http.StatusNoContent, + http.StatusUnauthorized, + http.StatusNotFound, + http.StatusMethodNotAllowed, + } { + t.Run(http.StatusText(status), func(t *testing.T) { + calls := 0 + body := &reachabilityProbeBody{} + client := reachabilityClient(t, func(request *http.Request) (*http.Response, error) { + calls++ + if request.Method != http.MethodGet { + t.Errorf("method = %q, want GET", request.Method) + } + return &http.Response{StatusCode: status, Header: make(http.Header), Body: body}, nil + }) + + result, err := (reachabilityProbe{client: client}).run(context.Background(), "https://example.invalid/health", 0) + if err != nil { + t.Fatal(err) + } + if calls != 1 || !body.closed { + t.Fatalf("calls=%d body closed=%v", calls, body.closed) + } + if !result.supported || result.reachable == nil || !*result.reachable || !result.evidence.ResponseReceived { + t.Fatalf("response evidence = %+v", result) + } + if result.evidence.HTTPStatus == nil || *result.evidence.HTTPStatus != status || result.failure != domain.ExecutionFailureNone { + t.Fatalf("status/failure = %v / %v", result.evidence.HTTPStatus, result.failure) + } + }) + } +} + +func TestReachabilityProbeRemovesURLCredentials(t *testing.T) { + var observed *http.Request + client := reachabilityClient(t, func(request *http.Request) (*http.Response, error) { + observed = request + return &http.Response{StatusCode: http.StatusNoContent, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))}, nil + }) + endpoint := "https://operator:secret@example.invalid/health?check=1" + + result, err := (reachabilityProbe{client: client}).run(context.Background(), endpoint, 0) + if err != nil { + t.Fatal(err) + } + if observed == nil || observed.URL.User != nil || observed.Header.Get("Authorization") != "" { + t.Fatalf("probe request retained URL credentials: %+v", observed) + } + if result.initialEndpoint != "https://example.invalid/health?check=1" || result.evidence.CredentialsSupplied { + t.Fatalf("credential-free evidence = %+v", result) + } +} + +func TestReachabilityProbeReportsTransportFailureWithoutResponse(t *testing.T) { + client := reachabilityClient(t, func(*http.Request) (*http.Response, error) { + return nil, errors.New("opaque network failure") + }) + + result, err := (reachabilityProbe{client: client}).run(context.Background(), "https://example.invalid/health", 0) + if err != nil { + t.Fatal(err) + } + if result.reachable == nil || *result.reachable || result.evidence.ResponseReceived || result.evidence.HTTPStatus != nil { + t.Fatalf("no-response evidence = %+v", result) + } + if !result.evidence.DispatchInvoked || !result.evidence.RequestObserved || result.failure != domain.ExecutionFailureTransport { + t.Fatalf("transport failure evidence = %+v", result) + } +} + +func TestReachabilityProbeUsesTargetTimeout(t *testing.T) { + client := reachabilityClient(t, func(request *http.Request) (*http.Response, error) { + <-request.Context().Done() + return nil, request.Context().Err() + }) + + result, err := (reachabilityProbe{client: client}).run(context.Background(), "https://example.invalid/health", 1) + if err != nil { + t.Fatal(err) + } + if result.reachable == nil || *result.reachable || result.failure != domain.ExecutionFailureDeadline { + t.Fatalf("timeout evidence = %+v", result) + } +} + +func TestReachabilityProbeIsUnsupportedWithoutHTTPClient(t *testing.T) { + result, err := (reachabilityProbe{}).run(context.Background(), "https://example.invalid/health", 0) + if err != nil { + t.Fatal(err) + } + if result.supported || result.reason != domain.SmokeTestReasonHTTPDiagnosticsUnavailable || result.reachable != nil || result.evidence.DispatchInvoked { + t.Fatalf("unsupported outcome = %+v", result) + } +} + +func reachabilityClient(t *testing.T, roundTrip func(*http.Request) (*http.Response, error)) *llm.OpenAICompatibleClient { + t.Helper() + client, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{ + HTTPClient: &http.Client{Transport: reachabilityProbeTransport(roundTrip)}, + }) + if err != nil { + t.Fatal(err) + } + return client +} + +type reachabilityProbeTransport func(*http.Request) (*http.Response, error) + +func (t reachabilityProbeTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return t(request) +} + +type reachabilityProbeBody struct{ closed bool } + +func (*reachabilityProbeBody) Read([]byte) (int, error) { return 0, io.EOF } + +func (b *reachabilityProbeBody) Close() error { + b.closed = true + return nil +}