Files
go-omni/httpc/status_test.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)
}
}
})
}
}