Extend retry backoff; rework logging (LOGLEVEL, request lines, colors)

Backoff now covers all dial-phase errors (refused, timeout, DNS), TLS
handshake failures, and 5xx responses with replayable bodies; streamed
POSTs are never replayed to avoid duplicate work. First retry of an
episode logs at WARN, subsequent attempts at INFO.

LOGLEVEL (LOG_LEVEL kept as alias) now defaults to warn: startup logs
version plus every setting; INFO adds one line per incoming request and
per response with status/duration, ANSI-colored in text mode (bypasses
slog's escaping so colors render in docker compose logs); NO_COLOR or
LOG_FORMAT=json disables colors.
This commit is contained in:
mram
2026-09-20 20:00:57 +02:00
parent 20d5439b5f
commit 42e1386811
5 changed files with 312 additions and 42 deletions
+86 -1
View File
@@ -2,6 +2,7 @@ package proxy
import (
"context"
"crypto/tls"
"io"
"net"
"net/http"
@@ -116,7 +117,91 @@ func TestRetryViaReverseProxy(t *testing.T) {
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/", nil)
_, err = rt.RoundTrip(req)
if err == nil || !isConnRefused(err) {
if err == nil || !shouldRetry(err) {
t.Fatalf("err = %v, want connection refused", err)
}
}
// flakyStatus returns 500 for the first fails requests, then 200.
type flakyStatus struct {
fails int
calls int
}
func (s *flakyStatus) RoundTrip(req *http.Request) (*http.Response, error) {
s.calls++
code := 200
if s.calls <= s.fails {
code = 500
}
return &http.Response{
StatusCode: code,
Status: http.StatusText(code),
Body: io.NopCloser(strings.NewReader("")),
Header: make(http.Header),
}, nil
}
func TestRetryTransport5xxGet(t *testing.T) {
fs := &flakyStatus{fails: 2}
rt := &retryTransport{base: fs, initial: time.Millisecond, max: 2 * time.Millisecond}
req, _ := http.NewRequest(http.MethodGet, "http://upstream/api/version", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if fs.calls != 3 {
t.Fatalf("calls = %d, want 3", fs.calls)
}
}
func TestRetryTransport5xxPostNotRetried(t *testing.T) {
fs := &flakyStatus{fails: 10}
rt := &retryTransport{base: fs, initial: time.Millisecond, max: time.Millisecond}
// A streamed body (no GetBody) must not be replayed after a 500.
req, _ := http.NewRequest(http.MethodPost, "http://upstream/prompt", io.NopCloser(strings.NewReader("{}")))
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 500 {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
if fs.calls != 1 {
t.Fatalf("calls = %d, want 1 (no retry for streamed POST)", fs.calls)
}
}
func TestShouldRetryClassification(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"dial refused", &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}, true},
{"dial timeout", &net.OpError{Op: "dial", Net: "tcp", Err: timeoutErr{}}, true},
{"dns", &net.DNSError{Err: "no such host", IsNotFound: true}, true},
{"tls record", tls.RecordHeaderError{Msg: "bad"}, true},
{"tls alert", tls.AlertError(42), true},
{"read error mid-request", &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}, false},
{"plain error", io.EOF, false},
}
for _, c := range cases {
if got := shouldRetry(c.err); got != c.want {
t.Errorf("%s: shouldRetry = %v, want %v", c.name, got, c.want)
}
}
}
type timeoutErr struct{}
func (timeoutErr) Error() string { return "i/o timeout" }
func (timeoutErr) Timeout() bool { return true }
func (timeoutErr) Temporary() bool { return true }