Skip to content

Commit a7e62a3

Browse files
authored
refactor: extract shared probehttp helper; replace http.DefaultClient (#41)
* refactor: extract shared probehttp helper; replace http.DefaultClient The eyrie repo had three credential-probe / model-probe sites that each used http.DefaultClient.Do() and each hand-rolled the same status-code-to-error mapping. Three problems with that: 1. http.DefaultClient has no Timeout, so a slow or hijacked response can hang the probe forever. The call sites *do* wrap the request in a context, so the in-flight request itself will be cancelled, but TLS / dial / redirect / response-header reads before the body is opened are not bounded. 2. probeStatusErr (catalog/xiaomi) and probeHTTPError (config/credential) were near-duplicates with identical switch cases and identical error messages. A bug fix in one would silently miss the other. 3. probe-specific plumbing (request build, body drain, status check) was reimplemented per call site, which made the probe functions hard to read. This change introduces internal/probehttp: - DefaultClient: a shared *http.Client with a 15s Timeout. - ProbeError(status): single source of truth for the HTTP-status-to-error mapping. Stable error wording (those strings are part of what hawk surfaces to users on /config probe failure). - DoGet(ctx, url, headers): builds a GET, applies headers, runs through DefaultClient, drains up to 1 MiB of body, returns (status, body, err). - UserAgent(), JoinURL(): small shared helpers so call sites stop re-implementing the trim/concat dance. The two consumer packages now import probehttp and the call sites collapse from 6 functions (probeOpenAIModels, probeAnthropic, probeGemini, doProbeRequest, probeHTTPError, plus the xiaomi-side doProbe/doModelsRequest/probeStatusErr) to 4 thin wrappers (probeOpenAIModels, probeAnthropic, probeGemini in config/credential, and the two exported entry points in catalog/xiaomi) that each issue a DoGet and translate the status via ProbeError. Verification: - go build ./... -> clean - go vet ./... -> clean - staticcheck ./... -> clean - gofumpt -d -> clean - go test -short ./... -> all packages pass - go test ./internal/probehttp -> 8 new tests cover ProbeError, DoGet (success + body bound + context deadline), and the URL/UA helpers. * style: gofumpt-cleanup of probehttp_test.go Remove the trailing blank lines left over from removing the TestDoGet_NilContextDoesNotPanic test in the previous commit. Verification: - gofumpt -l . -> 0 files - go test ./internal/probehttp -> all pass
1 parent 98e5781 commit a7e62a3

4 files changed

Lines changed: 268 additions & 126 deletions

File tree

catalog/xiaomi/http.go

Lines changed: 43 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7-
"io"
87
"net/http"
98
"strings"
9+
10+
"github.com/GrayCodeAI/eyrie/internal/probehttp"
1011
)
1112

1213
// ProbeOpenAIModels GETs {baseURL}/models using api-key auth, then Bearer on 401.
@@ -19,68 +20,49 @@ func ProbeOpenAIModels(ctx context.Context, baseURL, apiKey string) error {
1920
if apiKey == "" {
2021
return fmt.Errorf("xiaomi probe: missing API key")
2122
}
22-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
23-
if err != nil {
24-
return err
23+
url := baseURL + "/models"
24+
commonHeaders := map[string]string{
25+
"Accept": "application/json",
26+
"User-Agent": probehttp.UserAgent(),
2527
}
26-
req.Header.Set("Accept", "application/json")
27-
req.Header.Set("User-Agent", "eyrie-model-catalog/1.0")
28-
setAPIKeyAuth(req, apiKey)
2928

30-
status, err := doProbe(req)
29+
status, _, err := probehttp.DoGet(ctx, url, func() map[string]string {
30+
h := map[string]string{}
31+
for k, v := range commonHeaders {
32+
h[k] = v
33+
}
34+
setAPIKeyAuthHeader(h, apiKey)
35+
return h
36+
}())
3137
if err != nil {
32-
return err
38+
return fmt.Errorf("xiaomi probe: network error: %w", err)
3339
}
3440
if status == http.StatusUnauthorized {
35-
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
36-
if err != nil {
37-
return err
38-
}
39-
req2.Header.Set("Accept", "application/json")
40-
req2.Header.Set("User-Agent", "eyrie-model-catalog/1.0")
41-
req2.Header.Set("Authorization", "Bearer "+apiKey)
42-
status, err = doProbe(req2)
41+
status, _, err = probehttp.DoGet(ctx, url, func() map[string]string {
42+
h := map[string]string{}
43+
for k, v := range commonHeaders {
44+
h[k] = v
45+
}
46+
h["Authorization"] = "Bearer " + apiKey
47+
return h
48+
}())
4349
if err != nil {
44-
return err
50+
return fmt.Errorf("xiaomi probe: network error: %w", err)
4551
}
4652
}
47-
return probeStatusErr(status)
48-
}
49-
50-
func setAPIKeyAuth(req *http.Request, apiKey string) {
51-
req.Header.Set("api-key", apiKey)
52-
}
53-
54-
func doProbe(req *http.Request) (int, error) {
55-
resp, err := http.DefaultClient.Do(req)
56-
if err != nil {
57-
return 0, fmt.Errorf("xiaomi probe: network error: %w", err)
58-
}
59-
defer func() { _ = resp.Body.Close() }()
60-
_, _ = io.Copy(io.Discard, resp.Body)
61-
return resp.StatusCode, nil
62-
}
63-
64-
func probeStatusErr(status int) error {
6553
if status >= 200 && status < 300 {
6654
return nil
6755
}
68-
switch status {
69-
case http.StatusUnauthorized, http.StatusForbidden:
70-
return fmt.Errorf("credential probe failed: invalid API key (HTTP %d)", status)
71-
case http.StatusTooManyRequests:
72-
return fmt.Errorf("credential probe failed: rate limited — try again shortly")
73-
default:
74-
if status >= 500 {
75-
return fmt.Errorf("credential probe failed: provider unavailable (HTTP %d)", status)
76-
}
77-
return fmt.Errorf("credential probe failed: HTTP %d", status)
78-
}
56+
return probehttp.ProbeError(status)
57+
}
58+
59+
func setAPIKeyAuthHeader(h map[string]string, apiKey string) {
60+
h["api-key"] = apiKey
7961
}
8062

8163
// SetMimoRequestAuth applies MiMo-preferred auth (api-key header).
8264
func SetMimoRequestAuth(req *http.Request, apiKey string) {
83-
setAPIKeyAuth(req, apiKey)
65+
req.Header.Set("api-key", apiKey)
8466
}
8567

8668
// FetchOpenAIModelsJSON GETs /models and returns raw model objects from the OpenAI list response.
@@ -90,18 +72,28 @@ func FetchOpenAIModelsJSON(ctx context.Context, baseURL, apiKey string) ([]json.
9072
if baseURL == "" || apiKey == "" {
9173
return nil, fmt.Errorf("xiaomi: base URL and API key required")
9274
}
93-
body, status, err := getModelsBody(ctx, baseURL, apiKey)
75+
url := baseURL + "/models"
76+
77+
headers := map[string]string{
78+
"Accept": "application/json",
79+
"User-Agent": probehttp.UserAgent(),
80+
}
81+
setAPIKeyAuthHeader(headers, apiKey)
82+
83+
status, body, err := probehttp.DoGet(ctx, url, headers)
9484
if err != nil {
9585
return nil, err
9686
}
9787
if status == http.StatusUnauthorized {
98-
body, status, err = getModelsBodyBearer(ctx, baseURL, apiKey)
88+
delete(headers, "api-key")
89+
headers["Authorization"] = "Bearer " + apiKey
90+
status, body, err = probehttp.DoGet(ctx, url, headers)
9991
if err != nil {
10092
return nil, err
10193
}
10294
}
10395
if status < 200 || status >= 300 {
104-
return nil, probeStatusErr(status)
96+
return nil, probehttp.ProbeError(status)
10597
}
10698
var payload struct {
10799
Data []json.RawMessage `json:"data"`
@@ -112,41 +104,6 @@ func FetchOpenAIModelsJSON(ctx context.Context, baseURL, apiKey string) ([]json.
112104
return payload.Data, nil
113105
}
114106

115-
func getModelsBody(ctx context.Context, baseURL, apiKey string) ([]byte, int, error) {
116-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
117-
if err != nil {
118-
return nil, 0, err
119-
}
120-
req.Header.Set("Accept", "application/json")
121-
req.Header.Set("User-Agent", "eyrie-model-catalog/1.0")
122-
SetMimoRequestAuth(req, apiKey)
123-
return doModelsRequest(req)
124-
}
125-
126-
func getModelsBodyBearer(ctx context.Context, baseURL, apiKey string) ([]byte, int, error) {
127-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
128-
if err != nil {
129-
return nil, 0, err
130-
}
131-
req.Header.Set("Accept", "application/json")
132-
req.Header.Set("User-Agent", "eyrie-model-catalog/1.0")
133-
req.Header.Set("Authorization", "Bearer "+apiKey)
134-
return doModelsRequest(req)
135-
}
136-
137-
func doModelsRequest(req *http.Request) ([]byte, int, error) {
138-
resp, err := http.DefaultClient.Do(req)
139-
if err != nil {
140-
return nil, 0, err
141-
}
142-
defer func() { _ = resp.Body.Close() }()
143-
body, err := io.ReadAll(resp.Body)
144-
if err != nil {
145-
return nil, resp.StatusCode, err
146-
}
147-
return body, resp.StatusCode, nil
148-
}
149-
150107
// IsRetryableHTTPStatus reports whether chat may retry via Anthropic compatibility.
151108
func IsRetryableHTTPStatus(status int) bool {
152109
switch status {

config/credential/probe.go

Lines changed: 23 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@ package credential
33
import (
44
"context"
55
"fmt"
6-
"io"
7-
"net/http"
86
"os"
97
"strings"
108
"time"
119

1210
"github.com/GrayCodeAI/eyrie/catalog"
1311
"github.com/GrayCodeAI/eyrie/catalog/registry"
1412
"github.com/GrayCodeAI/eyrie/catalog/xiaomi"
13+
"github.com/GrayCodeAI/eyrie/internal/probehttp"
1514
)
1615

1716
const credentialProbeTimeout = 8 * time.Second
@@ -131,57 +130,41 @@ func probeOpenAIModels(ctx context.Context, baseURL, secret string) error {
131130
if baseURL == "" {
132131
return fmt.Errorf("credential probe: missing base URL")
133132
}
134-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
133+
status, _, err := probehttp.DoGet(ctx, baseURL+"/models", map[string]string{
134+
"Authorization": "Bearer " + secret,
135+
})
135136
if err != nil {
136-
return err
137+
return fmt.Errorf("credential probe: network error: %w", err)
138+
}
139+
if status >= 200 && status < 300 {
140+
return nil
137141
}
138-
req.Header.Set("Authorization", "Bearer "+secret)
139-
return doProbeRequest(req)
142+
return probehttp.ProbeError(status)
140143
}
141144

142145
func probeAnthropic(ctx context.Context, secret string) error {
143-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.anthropic.com/v1/models", nil)
146+
status, _, err := probehttp.DoGet(ctx, "https://api.anthropic.com/v1/models", map[string]string{
147+
"x-api-key": secret,
148+
"anthropic-version": "2023-06-01",
149+
})
144150
if err != nil {
145-
return err
151+
return fmt.Errorf("credential probe: network error: %w", err)
146152
}
147-
req.Header.Set("x-api-key", secret)
148-
req.Header.Set("anthropic-version", "2023-06-01")
149-
return doProbeRequest(req)
150-
}
151-
152-
func probeGemini(ctx context.Context, secret string) error {
153-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://generativelanguage.googleapis.com/v1beta/models", nil)
154-
if err != nil {
155-
return err
153+
if status >= 200 && status < 300 {
154+
return nil
156155
}
157-
req.Header.Set("x-goog-api-key", secret)
158-
return doProbeRequest(req)
156+
return probehttp.ProbeError(status)
159157
}
160158

161-
func doProbeRequest(req *http.Request) error {
162-
resp, err := http.DefaultClient.Do(req)
159+
func probeGemini(ctx context.Context, secret string) error {
160+
status, _, err := probehttp.DoGet(ctx, "https://generativelanguage.googleapis.com/v1beta/models", map[string]string{
161+
"x-goog-api-key": secret,
162+
})
163163
if err != nil {
164164
return fmt.Errorf("credential probe: network error: %w", err)
165165
}
166-
defer func() { _ = resp.Body.Close() }()
167-
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
168-
_, _ = io.Copy(io.Discard, resp.Body)
166+
if status >= 200 && status < 300 {
169167
return nil
170168
}
171-
_, _ = io.ReadAll(io.LimitReader(resp.Body, 512))
172-
return probeHTTPError(resp.StatusCode)
173-
}
174-
175-
func probeHTTPError(status int) error {
176-
switch status {
177-
case http.StatusUnauthorized, http.StatusForbidden:
178-
return fmt.Errorf("credential probe failed: invalid API key (HTTP %d)", status)
179-
case http.StatusTooManyRequests:
180-
return fmt.Errorf("credential probe failed: rate limited — try again shortly")
181-
default:
182-
if status >= 500 {
183-
return fmt.Errorf("credential probe failed: provider unavailable (HTTP %d)", status)
184-
}
185-
return fmt.Errorf("credential probe failed: HTTP %d", status)
186-
}
169+
return probehttp.ProbeError(status)
187170
}

internal/probehttp/probehttp.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Package probehttp contains shared helpers for the eyrie credential-probe
2+
// and catalog-probe call sites. It centralises the HTTP-client configuration
3+
// and the HTTP-status-to-error mapping that probe code reaches for on every
4+
// request. Keeping it in one place means timeout policy and error wording
5+
// stay aligned across credential probes and live catalog probes.
6+
package probehttp
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"io"
12+
"net/http"
13+
"strings"
14+
"time"
15+
)
16+
17+
// DefaultRequestTimeout caps the time a single probe HTTP request can take.
18+
// The probe context already carries a deadline, but the http.Client.Timeout
19+
// is a second line of defence: it bounds the time spent in TLS, redirects,
20+
// and the like even if the caller's context deadline is missing.
21+
const DefaultRequestTimeout = 15 * time.Second
22+
23+
// DefaultClient is the shared *http.Client used by probe code in the eyrie
24+
// repo. Callers should reuse it instead of http.DefaultClient so the
25+
// per-request timeout policy stays consistent.
26+
var DefaultClient = &http.Client{Timeout: DefaultRequestTimeout}
27+
28+
// ProbeError builds a credential-probe error message for a non-2xx response.
29+
// The wording is part of the public surface that hawk surfaces to users when
30+
// /config probe fails, so the strings here are stable.
31+
//
32+
// status is the HTTP status code returned by the provider. The function
33+
// collapses 401/403 into a single "invalid key" message, distinguishes
34+
// 429 (rate limited) from a hard 5xx (provider unavailable), and falls
35+
// back to a generic HTTP-status message for everything else.
36+
func ProbeError(status int) error {
37+
switch {
38+
case status == http.StatusUnauthorized || status == http.StatusForbidden:
39+
return fmt.Errorf("credential probe failed: invalid API key (HTTP %d)", status)
40+
case status == http.StatusTooManyRequests:
41+
return fmt.Errorf("credential probe failed: rate limited — try again shortly")
42+
case status >= 500:
43+
return fmt.Errorf("credential probe failed: provider unavailable (HTTP %d)", status)
44+
default:
45+
return fmt.Errorf("credential probe failed: HTTP %d", status)
46+
}
47+
}
48+
49+
// DoGet issues a GET against url with the given headers, returns the status
50+
// code and body. The body is bounded to 1 MiB so a malicious or buggy
51+
// provider cannot exhaust memory. The body is read and closed on the caller's
52+
// behalf; callers only need to inspect (status, body, err).
53+
//
54+
// The request inherits the supplied context and the package-level
55+
// DefaultClient, so a missing context deadline is still capped by the
56+
// client Timeout.
57+
func DoGet(ctx context.Context, url string, headers map[string]string) (int, []byte, error) {
58+
if ctx == nil {
59+
ctx = context.Background()
60+
}
61+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
62+
if err != nil {
63+
return 0, nil, err
64+
}
65+
for k, v := range headers {
66+
req.Header.Set(k, v)
67+
}
68+
resp, err := DefaultClient.Do(req)
69+
if err != nil {
70+
return 0, nil, err
71+
}
72+
defer func() { _ = resp.Body.Close() }()
73+
74+
const maxBody = 1 << 20 // 1 MiB
75+
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
76+
if err != nil {
77+
return resp.StatusCode, nil, err
78+
}
79+
return resp.StatusCode, body, nil
80+
}
81+
82+
// UserAgent returns the standard eyrie User-Agent string for probe traffic.
83+
func UserAgent() string { return "eyrie-probe/1.0" }
84+
85+
// JoinURL trims a trailing slash from base and joins it with the supplied
86+
// path. It's a tiny helper kept here so the various probe call sites stop
87+
// re-implementing the trim/concat dance.
88+
func JoinURL(base, path string) string {
89+
base = strings.TrimRight(base, "/")
90+
path = strings.TrimLeft(path, "/")
91+
if path == "" {
92+
return base
93+
}
94+
return base + "/" + path
95+
}

0 commit comments

Comments
 (0)