feat: add precise diagnostics and optional client copy logging controls
This commit is contained in:
@@ -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。
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
174
httpc/status_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user