@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

master
go 974 lines 31 KB
Raw
1 package reverseproxy
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "net/http/httptest"
12 "net/url"
13 "strings"
14 "sync"
15 "sync/atomic"
16 "testing"
17 "time"
18
19 "go.uber.org/zap"
20
21 "github.com/caddyserver/caddy/v2"
22 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
23 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
24 )
25
26 // prepareTestRequest injects the context values that ServeHTTP and
27 // proxyLoopIteration require (caddy.ReplacerCtxKey, VarsCtxKey, etc.) using
28 // the same helper that the real HTTP server uses.
29 //
30 // A zero-value Server is passed so that caddyhttp.ServerCtxKey is set to a
31 // non-nil pointer; reverseProxy dereferences it to check ShouldLogCredentials.
32 func prepareTestRequest(req *http.Request) *http.Request {
33 repl := caddy.NewReplacer()
34 return caddyhttp.PrepareRequest(req, repl, nil, &caddyhttp.Server{})
35 }
36
37 // closeOnCloseReader is an io.ReadCloser whose Close method actually makes
38 // subsequent reads fail, mimicking the behaviour of a real HTTP request body
39 // (as opposed to io.NopCloser, whose Close is a no-op and would mask the bug
40 // we are testing).
41 type closeOnCloseReader struct {
42 mu sync.Mutex
43 r *strings.Reader
44 closed bool
45 }
46
47 func newCloseOnCloseReader(s string) *closeOnCloseReader {
48 return &closeOnCloseReader{r: strings.NewReader(s)}
49 }
50
51 func (c *closeOnCloseReader) Read(p []byte) (int, error) {
52 c.mu.Lock()
53 defer c.mu.Unlock()
54 if c.closed {
55 return 0, errors.New("http: invalid Read on closed Body")
56 }
57 return c.r.Read(p)
58 }
59
60 func (c *closeOnCloseReader) Close() error {
61 c.mu.Lock()
62 defer c.mu.Unlock()
63 c.closed = true
64 return nil
65 }
66
67 // deadUpstreamAddr returns a TCP address that is guaranteed to refuse
68 // connections: we bind a listener, note its address, close it immediately,
69 // and return the address. Any dial to that address will get ECONNREFUSED.
70 func deadUpstreamAddr(t *testing.T) string {
71 t.Helper()
72 ln, err := net.Listen("tcp", "127.0.0.1:0")
73 if err != nil {
74 t.Fatalf("failed to create dead upstream listener: %v", err)
75 }
76 addr := ln.Addr().String()
77 ln.Close()
78 return addr
79 }
80
81 // testTransport wraps http.Transport to:
82 // 1. Set the URL scheme to "http" when it is empty (matching what
83 // HTTPTransport.SetScheme does in production; cloneRequest strips the
84 // scheme intentionally so a plain *http.Transport would fail with
85 // "unsupported protocol scheme").
86 // 2. Wrap dial errors as DialError so that tryAgain correctly identifies them
87 // as safe-to-retry regardless of request method (as HTTPTransport does in
88 // production via its custom dialer).
89 type testTransport struct{ *http.Transport }
90
91 func (t testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
92 if req.URL.Scheme == "" {
93 req.URL.Scheme = "http"
94 }
95 resp, err := t.Transport.RoundTrip(req)
96 if err != nil {
97 // Wrap dial errors as DialError to match production behaviour.
98 // Without this wrapping, tryAgain treats ECONNREFUSED on a POST
99 // request as non-retryable (only GET is retried by default when
100 // the error is not a DialError).
101 var opErr *net.OpError
102 if errors.As(err, &opErr) && opErr.Op == "dial" {
103 return nil, DialError{err}
104 }
105 }
106 return resp, err
107 }
108
109 // minimalHandler returns a Handler with only the fields required by ServeHTTP
110 // set directly, bypassing Provision (which requires a full Caddy runtime).
111 // RoundRobinSelection is used so that successive iterations of the proxy loop
112 // advance through the upstream pool in a predictable order.
113 func minimalHandler(retries int, upstreams ...*Upstream) *Handler {
114 return &Handler{
115 logger: zap.NewNop(),
116 Transport: testTransport{&http.Transport{}},
117 Upstreams: upstreams,
118 LoadBalancing: &LoadBalancing{
119 Retries: retries,
120 SelectionPolicy: &RoundRobinSelection{},
121 // RetryMatch intentionally nil: dial errors are always retried
122 // regardless of RetryMatch or request method.
123 },
124 // ctx, connections, connectionsMu, events: zero/nil values are safe
125 // for the code paths exercised by these tests (TryInterval=0 so
126 // ctx.Done() is never consulted; no WebSocket hijacking; no passive
127 // health-check event emission).
128 }
129 }
130
131 // TestDialErrorBodyRetry verifies that a POST request whose body has NOT been
132 // pre-buffered via request_buffers can still be retried after a dial error.
133 //
134 // Before the fix, a dial error caused Go's transport to close the shared body
135 // (via cloneRequest's shallow copy), so the retry attempt would read from an
136 // already-closed io.ReadCloser and produce:
137 //
138 // http: invalid Read on closed Body → HTTP 502
139 //
140 // After the fix the handler wraps the body in noCloseBody when retries are
141 // configured, preventing the transport's Close() from propagating to the
142 // shared body. Since dial errors never read any bytes, the body remains at
143 // position 0 for the retry.
144 func TestDialErrorBodyRetry(t *testing.T) {
145 // Good upstream: echoes the request body with 200 OK.
146 goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147 body, err := io.ReadAll(r.Body)
148 if err != nil {
149 http.Error(w, "read body: "+err.Error(), http.StatusInternalServerError)
150 return
151 }
152 w.WriteHeader(http.StatusOK)
153 _, _ = w.Write(body)
154 }))
155 t.Cleanup(goodServer.Close)
156
157 const requestBody = "hello, retry"
158
159 tests := []struct {
160 name string
161 method string
162 body string
163 retries int
164 wantStatus int
165 wantBody string
166 }{
167 {
168 // Core regression case: POST with a body, no request_buffers,
169 // dial error on first upstream → retry to second upstream succeeds.
170 name: "POST body retried after dial error",
171 method: http.MethodPost,
172 body: requestBody,
173 retries: 1,
174 wantStatus: http.StatusOK,
175 wantBody: requestBody,
176 },
177 {
178 // Dial errors are always retried regardless of method, but there
179 // is no body to re-read, so GET has always worked. Keep it as a
180 // sanity check that we did not break the no-body path.
181 name: "GET without body retried after dial error",
182 method: http.MethodGet,
183 body: "",
184 retries: 1,
185 wantStatus: http.StatusOK,
186 wantBody: "",
187 },
188 {
189 // Without any retry configuration the handler must give up on the
190 // first dial error and return a 502. Confirms no wrapping occurs
191 // in the no-retry path.
192 name: "no retries configured returns 502 on dial error",
193 method: http.MethodPost,
194 body: requestBody,
195 retries: 0,
196 wantStatus: http.StatusBadGateway,
197 wantBody: "",
198 },
199 }
200
201 for _, tc := range tests {
202 t.Run(tc.name, func(t *testing.T) {
203 dead := deadUpstreamAddr(t)
204
205 // Build the upstream pool. RoundRobinSelection starts its
206 // counter at 0 and increments before returning, so with a
207 // two-element pool it picks index 1 first, then index 0.
208 // Put the good upstream at index 0 and the dead one at
209 // index 1 so that:
210 // attempt 1 → pool[1] = dead → DialError (ECONNREFUSED)
211 // attempt 2 → pool[0] = good → 200
212 upstreams := []*Upstream{
213 {Host: new(Host), Dial: goodServer.Listener.Addr().String()},
214 {Host: new(Host), Dial: dead},
215 }
216 if tc.retries == 0 {
217 // For the "no retries" case use only the dead upstream so
218 // there is nowhere to retry to.
219 upstreams = []*Upstream{
220 {Host: new(Host), Dial: dead},
221 }
222 }
223
224 h := minimalHandler(tc.retries, upstreams...)
225
226 // Use closeOnCloseReader so that Close() truly prevents further
227 // reads, matching real http.body semantics. io.NopCloser would
228 // mask the bug because its Close is a no-op.
229 var bodyReader io.ReadCloser
230 if tc.body != "" {
231 bodyReader = newCloseOnCloseReader(tc.body)
232 }
233 req := httptest.NewRequest(tc.method, "http://example.com/", bodyReader)
234 if bodyReader != nil {
235 // httptest.NewRequest wraps the reader in NopCloser; replace
236 // it with our close-aware reader so Close() is propagated.
237 req.Body = bodyReader
238 req.ContentLength = int64(len(tc.body))
239 }
240 req = prepareTestRequest(req)
241
242 rec := httptest.NewRecorder()
243 err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
244 return nil
245 }))
246
247 // For error cases (e.g. 502) ServeHTTP returns a HandlerError
248 // rather than writing the status itself.
249 gotStatus := rec.Code
250 if err != nil {
251 if herr, ok := err.(caddyhttp.HandlerError); ok {
252 gotStatus = herr.StatusCode
253 }
254 }
255
256 if gotStatus != tc.wantStatus {
257 t.Errorf("status: got %d, want %d (err=%v)", gotStatus, tc.wantStatus, err)
258 }
259 if tc.wantBody != "" && rec.Body.String() != tc.wantBody {
260 t.Errorf("body: got %q, want %q", rec.Body.String(), tc.wantBody)
261 }
262 })
263 }
264 }
265
266 // newExpressionMatcher provisions a MatchExpression for use in tests
267 func newExpressionMatcher(t *testing.T, expr string) *caddyhttp.MatchExpression {
268 t.Helper()
269 ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
270 t.Cleanup(cancel)
271 m := &caddyhttp.MatchExpression{Expr: expr}
272 if err := m.Provision(ctx); err != nil {
273 t.Fatalf("failed to provision expression %q: %v", expr, err)
274 }
275 return m
276 }
277
278 // minimalHandlerWithRetryMatch is like minimalHandler but also configures
279 // RetryMatch so that response-based retry can be tested
280 func minimalHandlerWithRetryMatch(retries int, retryMatch caddyhttp.MatcherSets, upstreams ...*Upstream) *Handler {
281 h := minimalHandler(retries, upstreams...)
282 h.LoadBalancing.RetryMatch = retryMatch
283 return h
284 }
285
286 // TestResponseRetryStatusCode verifies that when an upstream returns a status
287 // code matching a retry_match expression, the request is retried on the next
288 // upstream
289 func TestResponseRetryStatusCode(t *testing.T) {
290 for _, expr := range []string{
291 "{http.reverse_proxy.status_code} in [502, 503]",
292 `ph(req, "http.reverse_proxy.status_code") in [502, 503]`,
293 } {
294 t.Run(expr, func(t *testing.T) {
295 // Bad upstream: returns 502
296 badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
297 w.WriteHeader(http.StatusBadGateway)
298 }))
299 t.Cleanup(badServer.Close)
300
301 // Good upstream: returns 200
302 goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
303 w.WriteHeader(http.StatusOK)
304 w.Write([]byte("ok"))
305 }))
306 t.Cleanup(goodServer.Close)
307
308 retryMatch := caddyhttp.MatcherSets{
309 caddyhttp.MatcherSet{
310 newExpressionMatcher(t, expr),
311 },
312 }
313
314 // RoundRobin picks index 1 first, then 0
315 upstreams := []*Upstream{
316 {Host: new(Host), Dial: goodServer.Listener.Addr().String()},
317 {Host: new(Host), Dial: badServer.Listener.Addr().String()},
318 }
319
320 h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...)
321
322 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
323 req = prepareTestRequest(req)
324 rec := httptest.NewRecorder()
325
326 err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
327 return nil
328 }))
329
330 gotStatus := rec.Code
331 if err != nil {
332 if herr, ok := err.(caddyhttp.HandlerError); ok {
333 gotStatus = herr.StatusCode
334 }
335 }
336
337 if gotStatus != http.StatusOK {
338 t.Errorf("status: got %d, want %d (err=%v)", gotStatus, http.StatusOK, err)
339 }
340 })
341 }
342 }
343
344 // TestResponseRetryHeader verifies that response header matching triggers
345 // retries via a CEL expression checking {rp.header.*}
346 func TestResponseRetryHeader(t *testing.T) {
347 // Bad upstream: returns 200 but with X-Upstream-Retry header
348 badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
349 w.Header().Set("X-Upstream-Retry", "true")
350 w.WriteHeader(http.StatusOK)
351 w.Write([]byte("bad"))
352 }))
353 t.Cleanup(badServer.Close)
354
355 // Good upstream: returns 200 without retry header
356 goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
357 w.WriteHeader(http.StatusOK)
358 w.Write([]byte("good"))
359 }))
360 t.Cleanup(goodServer.Close)
361
362 retryMatch := caddyhttp.MatcherSets{
363 caddyhttp.MatcherSet{
364 newExpressionMatcher(t, `{http.reverse_proxy.header.X-Upstream-Retry} == "true"`),
365 },
366 }
367
368 // RoundRobin picks index 1 first, then 0
369 upstreams := []*Upstream{
370 {Host: new(Host), Dial: goodServer.Listener.Addr().String()},
371 {Host: new(Host), Dial: badServer.Listener.Addr().String()},
372 }
373
374 h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...)
375
376 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
377 req = prepareTestRequest(req)
378 rec := httptest.NewRecorder()
379
380 err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
381 return nil
382 }))
383 if err != nil {
384 t.Fatalf("unexpected error: %v", err)
385 }
386
387 if rec.Code != http.StatusOK {
388 t.Errorf("status: got %d, want %d", rec.Code, http.StatusOK)
389 }
390 if rec.Body.String() != "good" {
391 t.Errorf("body: got %q, want %q (retried to wrong upstream)", rec.Body.String(), "good")
392 }
393 }
394
395 // TestResponseRetryNoMatchNoRetry verifies that when no retry_match entries
396 // match the response, the original response is returned without retrying
397 func TestResponseRetryNoMatchNoRetry(t *testing.T) {
398 var hits atomic.Int32
399
400 // Server that returns 500 - but retry_match only matches 502/503
401 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
402 hits.Add(1)
403 w.WriteHeader(http.StatusInternalServerError)
404 }))
405 t.Cleanup(server.Close)
406
407 retryMatch := caddyhttp.MatcherSets{
408 caddyhttp.MatcherSet{
409 newExpressionMatcher(t, "{http.reverse_proxy.status_code} in [502, 503]"),
410 },
411 }
412
413 upstreams := []*Upstream{
414 {Host: new(Host), Dial: server.Listener.Addr().String()},
415 {Host: new(Host), Dial: server.Listener.Addr().String()},
416 }
417
418 h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...)
419
420 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
421 req = prepareTestRequest(req)
422 rec := httptest.NewRecorder()
423
424 _ = h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
425 return nil
426 }))
427
428 // Only one hit - no retry since 500 doesn't match [502, 503]
429 if hits.Load() != 1 {
430 t.Errorf("upstream hits: got %d, want 1 (should not have retried)", hits.Load())
431 }
432 }
433
434 // TestResponseRetryExhaustedPreservesStatusCode verifies that when retries
435 // are exhausted, the actual upstream status code (e.g. 503) is reported
436 // to the client, not a generic 502
437 func TestResponseRetryExhaustedPreservesStatusCode(t *testing.T) {
438 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
439 w.WriteHeader(http.StatusServiceUnavailable) // 503
440 }))
441 t.Cleanup(server.Close)
442
443 retryMatch := caddyhttp.MatcherSets{
444 caddyhttp.MatcherSet{
445 newExpressionMatcher(t, "{http.reverse_proxy.status_code} == 503"),
446 },
447 }
448
449 upstreams := []*Upstream{
450 {Host: new(Host), Dial: server.Listener.Addr().String()},
451 {Host: new(Host), Dial: server.Listener.Addr().String()},
452 }
453
454 h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...)
455
456 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
457 req = prepareTestRequest(req)
458 rec := httptest.NewRecorder()
459
460 err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
461 return nil
462 }))
463
464 gotStatus := rec.Code
465 if err != nil {
466 if herr, ok := err.(caddyhttp.HandlerError); ok {
467 gotStatus = herr.StatusCode
468 }
469 }
470
471 // Must return 503 (actual upstream status), not 502 (generic proxy error)
472 if gotStatus != http.StatusServiceUnavailable {
473 t.Errorf("status: got %d, want %d (status code not preserved)", gotStatus, http.StatusServiceUnavailable)
474 }
475 }
476
477 // TestResponseRetryHeaderCleanup verifies that stale response header
478 // placeholders from a previous upstream attempt are cleaned up before the
479 // next retry evaluation. Without cleanup, a header like X-Retry: true from
480 // upstream A would leak into the retry match for upstream B even if B does
481 // not set that header
482 func TestResponseRetryHeaderCleanup(t *testing.T) {
483 // First upstream: returns 200 with X-Retry header (triggers retry)
484 firstServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
485 w.Header().Set("X-Retry", "true")
486 w.WriteHeader(http.StatusOK)
487 w.Write([]byte("first"))
488 }))
489 t.Cleanup(firstServer.Close)
490
491 // Second upstream: returns 200 WITHOUT X-Retry header (should NOT retry)
492 secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
493 w.WriteHeader(http.StatusOK)
494 w.Write([]byte("second"))
495 }))
496 t.Cleanup(secondServer.Close)
497
498 retryMatch := caddyhttp.MatcherSets{
499 caddyhttp.MatcherSet{
500 newExpressionMatcher(t, `{http.reverse_proxy.header.X-Retry} == "true"`),
501 },
502 }
503
504 // RoundRobin picks index 1 first, then 0
505 upstreams := []*Upstream{
506 {Host: new(Host), Dial: secondServer.Listener.Addr().String()},
507 {Host: new(Host), Dial: firstServer.Listener.Addr().String()},
508 }
509
510 h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...)
511
512 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
513 req = prepareTestRequest(req)
514 rec := httptest.NewRecorder()
515
516 err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
517 return nil
518 }))
519 if err != nil {
520 t.Fatalf("unexpected error: %v", err)
521 }
522
523 // Should get "second" - the first upstream's X-Retry header must not
524 // leak into the second upstream's retry evaluation
525 if rec.Body.String() != "second" {
526 t.Errorf("body: got %q, want %q (stale header leaked between retries)", rec.Body.String(), "second")
527 }
528 }
529
530 // TestRequestOnlyMatcherDoesNotRetryResponses verifies that a pure request
531 // matcher like method PUT in lb_retry_match does NOT trigger response-based
532 // retries. Only expression matchers (which can reference response data)
533 // should trigger response retries
534 func TestRequestOnlyMatcherDoesNotRetryResponses(t *testing.T) {
535 var hits atomic.Int32
536
537 // Server returns 200 OK for all requests
538 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
539 hits.Add(1)
540 w.WriteHeader(http.StatusOK)
541 w.Write([]byte("ok"))
542 }))
543 t.Cleanup(server.Close)
544
545 // method PUT matcher - should NOT trigger response retries
546 retryMatch := caddyhttp.MatcherSets{
547 caddyhttp.MatcherSet{
548 caddyhttp.MatchMethod{"PUT"},
549 },
550 }
551
552 upstreams := []*Upstream{
553 {Host: new(Host), Dial: server.Listener.Addr().String()},
554 {Host: new(Host), Dial: server.Listener.Addr().String()},
555 }
556
557 h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...)
558
559 req := httptest.NewRequest(http.MethodPut, "http://example.com/", nil)
560 req = prepareTestRequest(req)
561 rec := httptest.NewRecorder()
562
563 err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
564 return nil
565 }))
566 if err != nil {
567 t.Fatalf("unexpected error: %v", err)
568 }
569
570 // Should hit only once - no retry for 200 OK even though method matches
571 if hits.Load() != 1 {
572 t.Errorf("upstream hits: got %d, want 1 (should not retry successful responses)", hits.Load())
573 }
574 if rec.Code != http.StatusOK {
575 t.Errorf("status: got %d, want %d", rec.Code, http.StatusOK)
576 }
577 }
578
579 // TestRequestOnlyExpressionDoesNotRetryResponses verifies that an expression
580 // matcher that only tests the request or the selected upstream (no response
581 // placeholders) does not cause successful responses to be retried
582 func TestRequestOnlyExpressionDoesNotRetryResponses(t *testing.T) {
583 for _, expr := range []string{
584 `{http.request.method} == "POST"`,
585 `{http.reverse_proxy.upstream.host} == "127.0.0.1"`,
586 `{http.request.uri.path} != "\{http.reverse_proxy.status_code}"`,
587 `{http.reverse_proxy.status_codes} == null`,
588 } {
589 t.Run(expr, func(t *testing.T) {
590 var hits atomic.Int32
591
592 // Server returns 200 OK for all requests
593 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
594 hits.Add(1)
595 w.WriteHeader(http.StatusOK)
596 w.Write([]byte("ok"))
597 }))
598 t.Cleanup(server.Close)
599
600 retryMatch := caddyhttp.MatcherSets{
601 caddyhttp.MatcherSet{
602 newExpressionMatcher(t, expr),
603 },
604 }
605
606 upstreams := []*Upstream{
607 {Host: new(Host), Dial: server.Listener.Addr().String()},
608 {Host: new(Host), Dial: server.Listener.Addr().String()},
609 }
610
611 h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...)
612
613 req := httptest.NewRequest(http.MethodPost, "http://example.com/", strings.NewReader("order=1"))
614 req = prepareTestRequest(req)
615 rec := httptest.NewRecorder()
616
617 err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
618 return nil
619 }))
620 if err != nil {
621 t.Fatalf("unexpected error: %v", err)
622 }
623
624 if hits.Load() != 1 {
625 t.Errorf("upstream hits: got %d, want 1 (should not retry successful responses)", hits.Load())
626 }
627 if rec.Code != http.StatusOK {
628 t.Errorf("status: got %d, want %d", rec.Code, http.StatusOK)
629 }
630 if rec.Body.String() != "ok" {
631 t.Errorf("body: got %q, want %q", rec.Body.String(), "ok")
632 }
633 })
634 }
635 }
636
637 // brokenUpstreamAddr returns the address of a TCP listener that accepts
638 // connections but immediately closes them, causing a transport error (not
639 // a dial error). This simulates an upstream that is reachable but broken
640 func brokenUpstreamAddr(t *testing.T) string {
641 t.Helper()
642 ln, err := net.Listen("tcp", "127.0.0.1:0")
643 if err != nil {
644 t.Fatalf("failed to listen: %v", err)
645 }
646 t.Cleanup(func() { ln.Close() })
647 go func() {
648 for {
649 conn, err := ln.Accept()
650 if err != nil {
651 return
652 }
653 conn.Close()
654 }
655 }()
656 return ln.Addr().String()
657 }
658
659 // TestTransportErrorPlaceholder verifies that the is_transport_error
660 // placeholder is set to true during transport error evaluation in tryAgain()
661 // and that expression matchers using {rp.is_transport_error} can match it
662 func TestTransportErrorPlaceholder(t *testing.T) {
663 broken := brokenUpstreamAddr(t)
664
665 goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
666 w.WriteHeader(http.StatusOK)
667 w.Write([]byte("ok"))
668 }))
669 t.Cleanup(goodServer.Close)
670
671 retryMatch := caddyhttp.MatcherSets{
672 caddyhttp.MatcherSet{
673 newExpressionMatcher(t, "{http.reverse_proxy.is_transport_error} == true"),
674 },
675 }
676
677 // RoundRobin picks index 1 first (broken), then 0 (good)
678 upstreams := []*Upstream{
679 {Host: new(Host), Dial: goodServer.Listener.Addr().String()},
680 {Host: new(Host), Dial: broken},
681 }
682
683 h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...)
684
685 req := httptest.NewRequest(http.MethodPost, "http://example.com/", nil)
686 req = prepareTestRequest(req)
687 rec := httptest.NewRecorder()
688
689 err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
690 return nil
691 }))
692
693 gotStatus := rec.Code
694 if err != nil {
695 if herr, ok := err.(caddyhttp.HandlerError); ok {
696 gotStatus = herr.StatusCode
697 }
698 }
699
700 // POST transport error should be retried because is_transport_error matched
701 if gotStatus != http.StatusOK {
702 t.Errorf("status: got %d, want %d (transport error should have been retried)", gotStatus, http.StatusOK)
703 }
704 }
705
706 // TestTransportErrorPlaceholderNotSetForResponses verifies that the
707 // is_transport_error placeholder is NOT set when evaluating response
708 // matchers, so {rp.is_transport_error} is false for response retries
709 func TestTransportErrorPlaceholderNotSetForResponses(t *testing.T) {
710 var hits atomic.Int32
711
712 // Server returns 502 - but the matcher only checks is_transport_error
713 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
714 hits.Add(1)
715 w.WriteHeader(http.StatusBadGateway)
716 }))
717 t.Cleanup(server.Close)
718
719 // Only matches transport errors, not response errors
720 retryMatch := caddyhttp.MatcherSets{
721 caddyhttp.MatcherSet{
722 newExpressionMatcher(t, "{http.reverse_proxy.is_transport_error} == true"),
723 },
724 }
725
726 upstreams := []*Upstream{
727 {Host: new(Host), Dial: server.Listener.Addr().String()},
728 {Host: new(Host), Dial: server.Listener.Addr().String()},
729 }
730
731 h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...)
732
733 req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
734 req = prepareTestRequest(req)
735 rec := httptest.NewRecorder()
736
737 _ = h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
738 return nil
739 }))
740
741 // Should hit only once - is_transport_error is false during response
742 // evaluation so the 502 is NOT retried
743 if hits.Load() != 1 {
744 t.Errorf("upstream hits: got %d, want 1 (is_transport_error should be false for responses)", hits.Load())
745 }
746 }
747
748 // TestRetryMatchAllowsExpressionMixedWithOtherMatchers verifies that
749 // lb_retry_match accepts a block mixing expression with other matchers
750 func TestRetryMatchAllowsExpressionMixedWithOtherMatchers(t *testing.T) {
751 tests := []struct {
752 name string
753 input string
754 }{
755 {
756 name: "expression alone",
757 input: `reverse_proxy localhost:9080 {
758 lb_retry_match {
759 expression ` + "`{rp.status_code} in [502, 503]`" + `
760 }
761 }`,
762 },
763 {
764 name: "method alone",
765 input: `reverse_proxy localhost:9080 {
766 lb_retry_match {
767 method PUT
768 }
769 }`,
770 },
771 {
772 name: "expression mixed with method",
773 input: `reverse_proxy localhost:9080 {
774 lb_retry_match {
775 method POST
776 expression ` + "`{rp.status_code} in [502, 503]`" + `
777 }
778 }`,
779 },
780 {
781 name: "expression mixed with path",
782 input: `reverse_proxy localhost:9080 {
783 lb_retry_match {
784 path /api*
785 expression ` + "`{rp.status_code} == 502`" + `
786 }
787 }`,
788 },
789 }
790
791 for _, tc := range tests {
792 t.Run(tc.name, func(t *testing.T) {
793 h := &Handler{}
794 d := caddyfile.NewTestDispenser(tc.input)
795 err := h.UnmarshalCaddyfile(d)
796 if err != nil {
797 t.Errorf("unexpected error: %v", err)
798 }
799 })
800 }
801 }
802
803 // TestSubrouteErrorFallbackWithBody is similar to TestDialErrorBodyRetry but
804 // mimics Subroute's Error handler rather than testing retries specifically
805 func TestSubrouteErrorFallbackWithBody(t *testing.T) {
806 // Good upstream: echoes the request body with 200 OK.
807 goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
808 body, err := io.ReadAll(r.Body)
809 if err != nil {
810 http.Error(w, "read body: "+err.Error(), http.StatusInternalServerError)
811 return
812 }
813 w.WriteHeader(http.StatusOK)
814 _, err = w.Write(body)
815 if err != nil {
816 t.Errorf("error writing in good server: %v", err)
817 }
818 }))
819 t.Cleanup(goodServer.Close)
820
821 // Handler which will dial error
822 badProxy := minimalHandler(0, &Upstream{Host: new(Host), Dial: deadUpstreamAddr(t)})
823
824 bodyReader := newCloseOnCloseReader("hello world")
825 req := httptest.NewRequest("POST", "http://localhost/", bodyReader)
826 // httptest.NewRequest wraps the reader in NopCloser; replace
827 // it with our close-aware reader so Close() is propagated.
828 req.Body = bodyReader
829
830 req = prepareTestRequest(req)
831 rec := httptest.NewRecorder()
832 err := badProxy.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
833 return nil
834 }))
835 if err == nil {
836 t.Fatalf("Expected error from badProxy.ServeHTTP")
837 }
838
839 // Simulate the Subroute's Error handler by calling another handler with the
840 // same request and recorder
841 goodProxy := minimalHandler(0, &Upstream{Host: new(Host), Dial: goodServer.Listener.Addr().String()})
842 err = goodProxy.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
843 return nil
844 }))
845
846 if err != nil {
847 t.Fatalf("Expected no error from goodProxy.ServeHTTP, got: %v", err)
848 }
849 if rec.Code != http.StatusOK {
850 t.Errorf("status: got %d, want %d", rec.Code, http.StatusOK)
851 }
852 expectedBody := "hello world"
853 if rec.Body.String() != expectedBody {
854 t.Errorf("body: got %q, want %q", rec.Body.String(), expectedBody)
855 }
856 }
857
858 // TestTryAgainDialErrorBehindForwardProxy verifies that tryAgain recognizes
859 // a DialError even when it is wrapped. When a forward proxy is configured,
860 // net/http wraps dial errors in a *net.OpError with Op "proxyconnect", and
861 // the DialError produced by our dialer ends up inside it. Errors that do not
862 // contain a DialError must still not be retried for non-GET requests, since
863 // the request may have reached the proxy or the upstream.
864 func TestTryAgainDialErrorBehindForwardProxy(t *testing.T) {
865 dialErr := DialError{errors.New("dial tcp 127.0.0.1:3128: connect: connection refused")}
866
867 for i, tc := range []struct {
868 name string
869 err error
870 method string
871 want bool
872 }{
873 {
874 name: "unwrapped dial error",
875 err: dialErr,
876 method: http.MethodPost,
877 want: true,
878 },
879 {
880 name: "dial error wrapped by forward proxy",
881 err: &net.OpError{Op: "proxyconnect", Net: "tcp", Err: dialErr},
882 method: http.MethodPost,
883 want: true,
884 },
885 {
886 name: "dial error wrapped with fmt.Errorf",
887 err: fmt.Errorf("round trip: %w", dialErr),
888 method: http.MethodPost,
889 want: true,
890 },
891 {
892 name: "proxyconnect error without a dial error",
893 err: &net.OpError{Op: "proxyconnect", Net: "tcp", Err: errors.New("Bad Gateway")},
894 method: http.MethodPost,
895 want: false,
896 },
897 {
898 name: "non-dial error",
899 err: errors.New("read: connection reset by peer"),
900 method: http.MethodPost,
901 want: false,
902 },
903 {
904 name: "non-dial error on GET",
905 err: errors.New("read: connection reset by peer"),
906 method: http.MethodGet,
907 want: true,
908 },
909 } {
910 lb := LoadBalancing{Retries: 1}
911 req := httptest.NewRequest(tc.method, "http://example.com/", nil)
912
913 got := lb.tryAgain(caddy.Context{}, time.Now(), 0, tc.err, req, zap.NewNop())
914 if got != tc.want {
915 t.Errorf("Test %d (%s): expected tryAgain=%t, got %t", i, tc.name, tc.want, got)
916 }
917 }
918 }
919
920 // TestForwardProxyDialErrorRetried verifies end-to-end that when the
921 // configured forward proxy cannot be reached, a non-GET request is retried
922 // just like a GET request, because no connection was ever established.
923 // It uses a real HTTPTransport so that the error is wrapped exactly as
924 // net/http does in production.
925 func TestForwardProxyDialErrorRetried(t *testing.T) {
926 deadProxy := deadUpstreamAddr(t)
927
928 for _, method := range []string{http.MethodGet, http.MethodPost} {
929 t.Run(method, func(t *testing.T) {
930 ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
931 defer cancel()
932
933 ht := &HTTPTransport{
934 NetworkProxyRaw: json.RawMessage(fmt.Sprintf(`{"from": "url", "url": "http://%s"}`, deadProxy)),
935 }
936 if err := ht.Provision(ctx); err != nil {
937 t.Fatalf("provisioning transport: %v", err)
938 }
939
940 // each round trip consults the proxy function exactly once,
941 // so counting calls counts the attempts made by the handler
942 var attempts atomic.Int32
943 proxyFunc := ht.Transport.Proxy
944 ht.Transport.Proxy = func(r *http.Request) (*url.URL, error) {
945 attempts.Add(1)
946 return proxyFunc(r)
947 }
948
949 h := minimalHandler(1,
950 &Upstream{Host: new(Host), Dial: "upstream-a.example.com:80"},
951 &Upstream{Host: new(Host), Dial: "upstream-b.example.com:80"},
952 )
953 h.Transport = ht
954
955 var body io.Reader
956 if method == http.MethodPost {
957 body = strings.NewReader("payload")
958 }
959 req := prepareTestRequest(httptest.NewRequest(method, "http://example.com/", body))
960
961 err := h.ServeHTTP(httptest.NewRecorder(), req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
962 return nil
963 }))
964
965 var herr caddyhttp.HandlerError
966 if !errors.As(err, &herr) || herr.StatusCode != http.StatusBadGateway {
967 t.Fatalf("expected a 502 handler error, got %v", err)
968 }
969 if got := attempts.Load(); got != 2 {
970 t.Errorf("expected 2 attempts (1 try + 1 retry), got %d", got)
971 }
972 })
973 }
974 }