feat: add precise diagnostics and optional client copy logging controls

This commit is contained in:
2026-09-26 18:04:33 +08:00
parent 69585dab61
commit 3bbc6f60d7
44 changed files with 1529 additions and 84 deletions

View File

@@ -85,7 +85,20 @@ err := client.JSON(ctx, "POST", endpoint, input, &output,
- Host、Content-Length、Transfer-Encoding、Trailer 属于请求目标/分帧控制,请用标准 Request 配合 Do;JSON 选项中不允许伪装成普通头。
- `WithMaxResponseBytes` 是本次 JSON 的正数限额,不修改 Client 默认值,不改变 Do 的流式语义。
JSON 自动关闭响应体。仅 2xx 解码;空成功响应(例如 204)保留 output 原值。非空响应使用标准 json.Unmarshal,拒绝多个 JSON 文档,类型错误保留 Go 的错误信息与行为。
`WithSuccessStatuses(200)` 限制本次调用只接受 200;可传多个,例如 `(200, 201)`。不传时保持全部 2xx。列表必须非空且每项位于 200–299,重复项允许;后一个状态选项替换前一个列表。构造选项时快照,之后修改原 slice 不影响请求,并发复用不串配置。
```go
err := client.JSON(ctx, http.MethodGet, endpoint, nil, &result,
httpc.WithHeader("Authorization", credential),
httpc.WithSuccessStatuses(http.StatusOK),
)
var status *httpc.StatusError
if errors.As(err, &status) { /* status.StatusCode 是实际收到的状态 */ }
```
未允许的状态(包括 201/204)返回 StatusError,不解码、不改变 output;仍关闭响应体、按原有时间/大小预算回收连接,不读取无限错误正文。状态选项不会增加重试,原有 Do 重试规则保留。它只限制 HTTP 状态,不保证响应非空或业务字段完整,业务仍校验自身协议。严格状态、关闭与连接复用见 [示例](../examples/developerTools/README.md)。
JSON 自动关闭响应体。默认仅 2xx 解码;指定成功状态列表时只解码列表内状态;空成功响应(例如 204)保留 output 原值。非空响应使用标准 json.Unmarshal,拒绝多个 JSON 文档,类型错误保留 Go 的错误信息与行为。
`output == nil` 按限额流式丢弃成功响应,不把整个 body 读进内存,也不检查 JSON 语法。响应限额按实际读取的 body 计算;标准库自动 gzip 解压后仍受上限约束。input 会先 json.Marshal 到内存,大文件上传不适合用 JSON helper。

View File

@@ -2,7 +2,6 @@ package httpc
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
@@ -10,6 +9,8 @@ import (
"strconv"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
)
// ProxyMode controls the owned transport's proxy selection. Empty preserves
@@ -66,17 +67,23 @@ func normalizeConfig(cfg Config) (Config, error) {
{"responseHeaderTimeout", cfg.ResponseHeaderTimeout}, {"expectContinueTimeout", cfg.ExpectContinueTimeout}, {"idleConnTimeout", cfg.IdleConnTimeout},
} {
if field.value < 0 {
return Config{}, fmt.Errorf("httpc: %s must not be negative", field.name)
return Config{}, configdiag.New(field.name, "must be nonnegative")
}
}
if cfg.MaxRetries < 0 || cfg.MaxRetries > 5 {
return Config{}, errors.New("httpc: maxRetries must be 0..5")
return Config{}, configdiag.New("maxRetries", "must be between 0 and 5")
}
if cfg.MaxConnsPerHost < 0 || cfg.MaxConnsPerHost > 65536 || cfg.MaxIdleConnsPerHost < 0 || cfg.MaxIdleConnsPerHost > 65536 || cfg.MaxIdleConns < 0 || cfg.MaxIdleConns > 131072 {
return Config{}, errors.New("httpc: invalid connection pool limits")
for _, f := range []struct {
name string
value, max int
}{{"maxConnsPerHost", cfg.MaxConnsPerHost, 65536}, {"maxIdleConnsPerHost", cfg.MaxIdleConnsPerHost, 65536}, {"maxIdleConns", cfg.MaxIdleConns, 131072}} {
if f.value < 0 || f.value > f.max {
return Config{}, configdiag.New(f.name, "must be between 0 and "+strconv.Itoa(f.max))
}
}
if cfg.MaxResponseBytes < 0 || cfg.MaxResponseBytes > maxResponseBytes {
return Config{}, errors.New("httpc: maxResponseBytes must be 0..1 GiB")
return Config{}, configdiag.New("maxResponseBytes", "must be between 0 and 1 GiB")
}
if _, err := proxyFunction(cfg.Proxy); err != nil {
return Config{}, err
@@ -86,11 +93,11 @@ func normalizeConfig(cfg Config) (Config, error) {
switch value.Kind() {
case reflect.Pointer, reflect.Map, reflect.Func, reflect.Slice, reflect.Interface, reflect.Chan:
if value.IsNil() {
return Config{}, errors.New("httpc: nil Transport")
return Config{}, configdiag.New("transport", "must not be a typed nil")
}
}
if cfg.hasTransportOptions() {
return Config{}, errors.New("httpc: Transport cannot be combined with proxy, dial, TLS or connection pool options; configure the borrowed transport directly")
return Config{}, configdiag.New("transport", "cannot be combined with proxy, dial, TLS or connection pool options; configure the borrowed transport directly")
}
}
if cfg.Timeout == 0 {
@@ -103,7 +110,7 @@ func normalizeConfig(cfg Config) (Config, error) {
cfg.MaxBackoff = 2 * time.Second
}
if cfg.Backoff > cfg.MaxBackoff {
return Config{}, errors.New("httpc: backoff exceeds maxBackoff")
return Config{}, configdiag.New("backoff", "must not exceed maxBackoff")
}
if cfg.MaxConnsPerHost == 0 {
cfg.MaxConnsPerHost = 64
@@ -124,7 +131,7 @@ func (c Config) hasTransportOptions() bool {
}
func proxyFunction(c ProxyConfig) (func(*http.Request) (*url.URL, error), error) {
if c.Mode != ProxyURL && c.URL != "" {
return nil, errors.New("httpc: proxy URL requires mode url")
return nil, configdiag.New("proxy.mode", "must be url when a URL is configured")
}
switch c.Mode {
case "":
@@ -136,27 +143,27 @@ func proxyFunction(c ProxyConfig) (func(*http.Request) (*url.URL, error), error)
case ProxyURL:
u, err := url.Parse(c.URL)
if err != nil || u == nil || u.Hostname() == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
return nil, errors.New("httpc: proxy URL must be an absolute proxy origin without path, query or fragment")
return nil, configdiag.New("proxy.url", "must be an absolute proxy origin without path, query or fragment")
}
switch u.Scheme {
case "http", "https", "socks5", "socks5h":
default:
return nil, errors.New("httpc: proxy scheme must be http, https, socks5 or socks5h")
return nil, configdiag.New("proxy.url", "scheme must be http, https, socks5 or socks5h")
}
if strings.HasSuffix(u.Host, ":") {
return nil, errors.New("httpc: proxy port must be 1..65535")
return nil, configdiag.New("proxy.url", "port must be between 1 and 65535")
}
if port := u.Port(); port != "" {
n, e := strconv.Atoi(port)
if e != nil || n < 1 || n > 65535 {
return nil, errors.New("httpc: proxy port must be 1..65535")
return nil, configdiag.New("proxy.url", "port must be between 1 and 65535")
}
}
u.Path = ""
u.RawPath = ""
return http.ProxyURL(u), nil
default:
return nil, errors.New("httpc: proxy mode must be environment, direct or url")
return nil, configdiag.New("proxy.mode", "must be environment, direct or url")
}
}
func ownedTransport(cfg Config) (*http.Transport, error) {

View File

@@ -11,7 +11,7 @@ import (
)
// JSON bounds response memory, closes the body, and decodes only 2xx responses.
// Optional headers/limits belong to this call and never alter the client.
// Optional headers/limits/success statuses belong to this call and never alter the client.
// output=nil discards a bounded stream; it does not buffer or validate JSON.
// Empty successful bodies leave output unchanged. StatusError contains no body.
func (c *Client) JSON(ctx context.Context, method, url string, input, output any, options ...RequestOption) error {
@@ -57,7 +57,7 @@ func (c *Client) JSON(ctx context.Context, method, url string, input, output any
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
if !settings.accepts(response.StatusCode) {
drainStatusBody(response, settings.maxResponseBytes)
return &StatusError{StatusCode: response.StatusCode}
}

View File

@@ -9,6 +9,8 @@ import (
type requestOptions struct {
headers http.Header
maxResponseBytes int64
successStatuses [2]uint64
restrictStatus bool
}
// RequestOption applies only to one JSON call. Returned options can be reused
@@ -97,3 +99,41 @@ func checkHeader(name string, values []string) error {
}
return nil
}
// WithSuccessStatuses restricts this JSON call to the listed 2xx statuses.
// Omission keeps all 2xx successful. Empty/out-of-range lists are rejected before
// sending a request. Options snapshot their input and can be reused concurrently.
// The policy does not add retries or validate application-specific JSON fields.
func WithSuccessStatuses(statuses ...int) RequestOption {
var accepted [2]uint64
var err error
if len(statuses) == 0 {
err = errors.New("httpc: at least one success status is required")
}
for _, status := range statuses {
if status < 200 || status >= 300 {
err = errors.New("httpc: success statuses must be between 200 and 299")
break
}
bit := status - 200
accepted[bit/64] |= uint64(1) << uint(bit%64)
}
return func(o *requestOptions) error {
if err != nil {
return err
}
o.successStatuses = accepted
o.restrictStatus = true
return nil
}
}
func (o requestOptions) accepts(status int) bool {
if status < 200 || status >= 300 {
return false
}
if !o.restrictStatus {
return true
}
bit := status - 200
return o.successStatuses[bit/64]&(uint64(1)<<uint(bit%64)) != 0
}

174
httpc/status_test.go Normal file
View File

@@ -0,0 +1,174 @@
package httpc
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestJSONSuccessStatuses(t *testing.T) {
for _, status := range []int{200, 201, 204, 264, 299, 400} {
t.Run(strconv.Itoa(status), func(t *testing.T) {
var calls atomic.Int32
body := &trackedBody{Reader: strings.NewReader(`{"value":2}`)}
c := testClient(t, Config{MaxRetries: 5, Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{StatusCode: status, Body: body, ContentLength: 11, Header: make(http.Header), Request: r}, nil
})})
out := struct {
Value int `json:"value"`
}{Value: 1}
err := c.JSON(context.Background(), "GET", "http://example.invalid", nil, &out, WithSuccessStatuses(200))
if status == 200 {
if err != nil || out.Value != 2 {
t.Fatal(err, out)
}
} else {
var rejected *StatusError
if !errors.As(err, &rejected) || rejected.StatusCode != status || out.Value != 1 {
t.Fatal(err, out)
}
if strings.Contains(err.Error(), "value") {
t.Fatal("status error exposed body")
}
}
if !body.closed || calls.Load() != 1 {
t.Fatal("not closed or unexpected retry", calls.Load())
}
// Unconfigured calls continue to accept every 2xx.
c2 := testClient(t, Config{Transport: benchmarkTransport{`{}`, status}})
err = c2.JSON(context.Background(), "GET", "http://example.invalid", nil, nil)
if (err == nil) != (status >= 200 && status < 300) {
t.Fatal(err)
}
if status >= 200 && status < 300 {
if err := c2.JSON(context.Background(), "GET", "http://example.invalid", nil, nil, WithSuccessStatuses(status, status)); err != nil {
t.Fatal(err)
}
}
})
}
var calls atomic.Int32
c := testClient(t, Config{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
calls.Add(1)
return benchmarkTransport{`{}`, 200}.RoundTrip(r)
})})
for _, option := range []RequestOption{WithSuccessStatuses(), WithSuccessStatuses(199), WithSuccessStatuses(300), WithSuccessStatuses(-1), WithSuccessStatuses(200, 500)} {
if err := c.JSON(context.Background(), "GET", "http://example.invalid", nil, nil, option); err == nil {
t.Fatal("invalid policy accepted")
}
}
if calls.Load() != 0 {
t.Fatal("invalid policy reached transport")
}
}
func TestJSONSuccessStatusConcurrentOptions(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n, _ := strconv.Atoi(r.URL.Query().Get("status"))
w.WriteHeader(n)
if n != 204 {
io.WriteString(w, `{}`)
}
}))
defer srv.Close()
c := testClient(t, Config{})
statuses := []int{200, 201}
option := WithSuccessStatuses(statuses...)
statuses[0] = 204
var wg sync.WaitGroup
for i := 0; i < 30; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
status := 200 + i%2
err := c.JSON(context.Background(), "GET", fmt.Sprintf("%s?status=%d", srv.URL, status), nil, nil, option)
if err != nil {
t.Error(err)
}
err = c.JSON(context.Background(), "GET", srv.URL+"?status=204", nil, nil, option)
var rejected *StatusError
if !errors.As(err, &rejected) || rejected.StatusCode != 204 {
t.Error(err)
}
}(i)
}
wg.Wait()
// Last explicit policy wins; another call's policy must not leak.
if err := c.JSON(context.Background(), "GET", srv.URL+"?status=204", nil, nil, option, WithSuccessStatuses(204)); err != nil {
t.Fatal(err)
}
if err := c.JSON(context.Background(), "GET", srv.URL+"?status=204", nil, nil); err != nil {
t.Fatal(err)
}
}
func TestJSONRejectedSuccessBodyRecovery(t *testing.T) {
var connections atomic.Int32
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(201)
if r.URL.Path == "/stream" {
w.(http.Flusher).Flush()
<-r.Context().Done()
return
}
io.WriteString(w, "private response")
}))
srv.Config.ConnState = func(_ net.Conn, s http.ConnState) {
if s == http.StateNew {
connections.Add(1)
}
}
srv.Start()
defer srv.Close()
c := testClient(t, Config{Timeout: time.Second})
for i := 0; i < 5; i++ {
err := c.JSON(context.Background(), "GET", srv.URL, nil, nil, WithSuccessStatuses(200))
var status *StatusError
if !errors.As(err, &status) || status.StatusCode != 201 {
t.Fatal(err)
}
}
if connections.Load() != 1 {
t.Fatal("small rejected bodies lost keep-alive", connections.Load())
}
start := time.Now()
err := c.JSON(context.Background(), "GET", srv.URL+"/stream", nil, nil, WithSuccessStatuses(200))
var status *StatusError
if !errors.As(err, &status) || status.StatusCode != 201 || time.Since(start) > 500*time.Millisecond {
t.Fatal("waited for rejected unbounded body", err)
}
if err := c.JSON(context.Background(), "GET", srv.URL, nil, nil, WithSuccessStatuses(201), WithMaxResponseBytes(4)); !errors.Is(err, ErrResponseTooLarge) {
t.Fatal("accepted body bypassed size limit", err)
}
}
func BenchmarkJSONSuccessStatus(b *testing.B) {
for _, restrict := range []bool{false, true} {
b.Run(strconv.FormatBool(restrict), func(b *testing.B) {
c, _ := New(Config{Transport: benchmarkTransport{`{"id":1}`, 200}, DisableLogging: true})
defer c.Close()
var options []RequestOption
if restrict {
options = []RequestOption{WithSuccessStatuses(200)}
}
var out struct {
ID int `json:"id"`
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := c.JSON(context.Background(), "GET", "http://example.invalid", nil, &out, options...); err != nil {
b.Fatal(err)
}
}
})
}
}