175 lines
5.6 KiB
Go
175 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|