mirror of
https://github.com/hanzoai/ingress.git
synced 2026-10-26 02:47:15 +00:00
168 lines
4.6 KiB
Go
168 lines
4.6 KiB
Go
package httputil
|
|
|
|
import (
|
|
"bufio"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
ptypes "github.com/hanzoai/ingress-parser/types"
|
|
"github.com/hanzoai/ingress/pkg/config/dynamic"
|
|
"github.com/hanzoai/ingress/pkg/middlewares/retry"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// rollRetry is the Retry the edge puts in front of a backend that rolls: no
|
|
// status list, so only a connection-level failure retries; a short interval and
|
|
// a long timeout, so a request is held across the gap rather than failed.
|
|
func rollRetry() dynamic.Retry {
|
|
return dynamic.Retry{
|
|
Attempts: 1000,
|
|
InitialInterval: ptypes.Duration(10 * time.Millisecond),
|
|
Timeout: ptypes.Duration(5 * time.Second),
|
|
}
|
|
}
|
|
|
|
type retries struct{ n atomic.Int32 }
|
|
|
|
func (r *retries) Retried(*http.Request, int) { r.n.Add(1) }
|
|
|
|
// edge is the chain the service manager builds for one server: Retry, then
|
|
// retry.WrapHandler, then the reverse proxy over a real transport.
|
|
func edge(t *testing.T, backend string, l retry.Listener) http.Handler {
|
|
t.Helper()
|
|
|
|
target, err := url.Parse("http://" + backend)
|
|
require.NoError(t, err)
|
|
|
|
transport := &http.Transport{MaxIdleConnsPerHost: 10}
|
|
t.Cleanup(transport.CloseIdleConnections)
|
|
|
|
proxy := retry.WrapHandler(buildSingleHostProxy(target, false, false, 0, transport, nil))
|
|
|
|
h, err := retry.New(t.Context(), proxy, rollRetry(), l, "roll")
|
|
require.NoError(t, err)
|
|
|
|
return h
|
|
}
|
|
|
|
// A POST whose bytes reached the backend is never sent again, however the
|
|
// backend fails afterwards. The backend here reads what arrives and closes the
|
|
// connection without answering, which is what a process killed mid-request does.
|
|
func TestRetryNeverResendsWhatTheBackendReceived(t *testing.T) {
|
|
testCases := []struct {
|
|
desc string
|
|
read func(*bufio.Reader) error
|
|
}{
|
|
{
|
|
desc: "backend read the whole request",
|
|
read: func(r *bufio.Reader) error {
|
|
req, err := http.ReadRequest(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = io.Copy(io.Discard, req.Body)
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
desc: "backend read only the request line",
|
|
read: func(r *bufio.Reader) error {
|
|
_, err := r.ReadString('\n')
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range testCases {
|
|
t.Run(test.desc, func(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
|
|
var received atomic.Int32
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := test.read(bufio.NewReader(conn)); err == nil {
|
|
received.Add(1)
|
|
}
|
|
_ = conn.Close()
|
|
}
|
|
}()
|
|
|
|
l := &retries{}
|
|
h := edge(t, ln.Addr().String(), l)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "http://api.example/v1/chat/completions",
|
|
strings.NewReader(`{"model":"zen","messages":[{"role":"user","content":"hi"}]}`))
|
|
h.ServeHTTP(rec, req)
|
|
|
|
// Give a duplicate time to arrive before counting.
|
|
time.Sleep(200 * time.Millisecond)
|
|
_ = ln.Close()
|
|
wg.Wait()
|
|
|
|
assert.Equal(t, int32(1), received.Load(), "the backend must see the POST exactly once")
|
|
assert.Equal(t, int32(0), l.n.Load(), "no retry once bytes reached the backend")
|
|
assert.Equal(t, http.StatusBadGateway, rec.Code)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A POST that reached no backend — nothing is listening — is held and retried
|
|
// until one listens, and then lands exactly once.
|
|
func TestRetryHoldsAPostUntilTheBackendListens(t *testing.T) {
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
addr := ln.Addr().String()
|
|
require.NoError(t, ln.Close()) // the port refuses until the backend binds it
|
|
|
|
var received atomic.Int32
|
|
srv := &http.Server{Handler: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
body, _ := io.ReadAll(req.Body)
|
|
if string(body) == "once" {
|
|
received.Add(1)
|
|
}
|
|
rw.WriteHeader(http.StatusOK)
|
|
})}
|
|
|
|
go func() {
|
|
time.Sleep(300 * time.Millisecond)
|
|
up, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = srv.Serve(up)
|
|
}()
|
|
t.Cleanup(func() { _ = srv.Close() })
|
|
|
|
l := &retries{}
|
|
h := edge(t, addr, l)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "http://api.example/v1/chat/completions", strings.NewReader("once"))
|
|
start := time.Now()
|
|
h.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Equal(t, int32(1), received.Load(), "the held POST lands exactly once")
|
|
assert.Positive(t, l.n.Load(), "refused connections are retried")
|
|
assert.GreaterOrEqual(t, time.Since(start), 300*time.Millisecond)
|
|
}
|