@marcel91200 / caddy Mirror · read-only / commits / aed1af59
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, 12:15 UTC

reverseproxy: add `lb_retry_match` condition on response status (#7569)

Daniil Sivak committed Apr 21, 2026 at 21:59 UTC aed1af59763d54520a9c72f1fd0222d43904ebfd
8 files changed +1029 -20
caddytest/integration/caddyfile_adapt/reverse_proxy_retry_match_oneline.caddyfiletest new
+58
@@ -0,0 +1,58 @@
1 +:8884
2 +
3 +reverse_proxy 127.0.0.1:65535 {
4 + lb_retries 3
5 + lb_retry_match expression `{rp.status_code} in [502, 503]`
6 + lb_retry_match expression `{rp.is_transport_error} || {rp.status_code} == 502`
7 + lb_retry_match expression `method('POST') && {rp.status_code} == 503`
8 + lb_retry_match `{rp.status_code} == 504`
9 + lb_retry_match `{rp.is_transport_error} && method('PUT')`
10 +}
11 +----------
12 +{
13 + "apps": {
14 + "http": {
15 + "servers": {
16 + "srv0": {
17 + "listen": [
18 + ":8884"
19 + ],
20 + "routes": [
21 + {
22 + "handle": [
23 + {
24 + "handler": "reverse_proxy",
25 + "load_balancing": {
26 + "retries": 3,
27 + "retry_match": [
28 + {
29 + "expression": "{http.reverse_proxy.status_code} in [502, 503]"
30 + },
31 + {
32 + "expression": "{http.reverse_proxy.is_transport_error} || {http.reverse_proxy.status_code} == 502"
33 + },
34 + {
35 + "expression": "method('POST') \u0026\u0026 {http.reverse_proxy.status_code} == 503"
36 + },
37 + {
38 + "expression": "{http.reverse_proxy.status_code} == 504"
39 + },
40 + {
41 + "expression": "{http.reverse_proxy.is_transport_error} \u0026\u0026 method('PUT')"
42 + }
43 + ]
44 + },
45 + "upstreams": [
46 + {
47 + "dial": "127.0.0.1:65535"
48 + }
49 + ]
50 + }
51 + ]
52 + }
53 + ]
54 + }
55 + }
56 + }
57 + }
58 +}
caddytest/integration/caddyfile_adapt/reverse_proxy_retry_match_response.caddyfiletest new
+147
@@ -0,0 +1,147 @@
1 +:8884
2 +
3 +reverse_proxy 127.0.0.1:65535 {
4 + lb_retries 5
5 +
6 + # request matchers (backward-compatible, non-expression)
7 + lb_retry_match {
8 + method POST PUT
9 + }
10 + lb_retry_match {
11 + path /foo*
12 + }
13 + lb_retry_match {
14 + header X-Idempotency-Key *
15 + }
16 +
17 + # response status code via expression
18 + lb_retry_match {
19 + expression `{rp.status_code} in [502, 503, 504]`
20 + }
21 +
22 + # response header via expression
23 + lb_retry_match {
24 + expression `{rp.header.X-Retry} == "true"`
25 + }
26 +
27 + # CEL request functions combined with response placeholders
28 + lb_retry_match {
29 + expression `method('POST') && {rp.status_code} >= 500`
30 + }
31 + lb_retry_match {
32 + expression `path('/api*') && {rp.status_code} in [502, 503]`
33 + }
34 + lb_retry_match {
35 + expression `host('example.com') && {rp.status_code} == 503`
36 + }
37 + lb_retry_match {
38 + expression `query({'retry': 'true'}) && {rp.status_code} >= 500`
39 + }
40 + lb_retry_match {
41 + expression `header({'X-Idempotency-Key': '*'}) && {rp.status_code} in [502, 503]`
42 + }
43 + lb_retry_match {
44 + expression `protocol('https') && {rp.status_code} == 502`
45 + }
46 + lb_retry_match {
47 + expression `path_regexp('^/api/v[0-9]+/') && {rp.status_code} >= 500`
48 + }
49 + lb_retry_match {
50 + expression `header_regexp('Content-Type', '^application/json') && {rp.status_code} == 502`
51 + }
52 +
53 + # transport error handling via placeholder
54 + lb_retry_match {
55 + expression `{rp.is_transport_error} || {rp.status_code} in [502, 503]`
56 + }
57 + lb_retry_match {
58 + expression `{rp.is_transport_error} && method('POST')`
59 + }
60 +}
61 +----------
62 +{
63 + "apps": {
64 + "http": {
65 + "servers": {
66 + "srv0": {
67 + "listen": [
68 + ":8884"
69 + ],
70 + "routes": [
71 + {
72 + "handle": [
73 + {
74 + "handler": "reverse_proxy",
75 + "load_balancing": {
76 + "retries": 5,
77 + "retry_match": [
78 + {
79 + "method": [
80 + "POST",
81 + "PUT"
82 + ]
83 + },
84 + {
85 + "path": [
86 + "/foo*"
87 + ]
88 + },
89 + {
90 + "header": {
91 + "X-Idempotency-Key": [
92 + "*"
93 + ]
94 + }
95 + },
96 + {
97 + "expression": "{http.reverse_proxy.status_code} in [502, 503, 504]"
98 + },
99 + {
100 + "expression": "{http.reverse_proxy.header.X-Retry} == \"true\""
101 + },
102 + {
103 + "expression": "method('POST') \u0026\u0026 {http.reverse_proxy.status_code} \u003e= 500"
104 + },
105 + {
106 + "expression": "path('/api*') \u0026\u0026 {http.reverse_proxy.status_code} in [502, 503]"
107 + },
108 + {
109 + "expression": "host('example.com') \u0026\u0026 {http.reverse_proxy.status_code} == 503"
110 + },
111 + {
112 + "expression": "query({'retry': 'true'}) \u0026\u0026 {http.reverse_proxy.status_code} \u003e= 500"
113 + },
114 + {
115 + "expression": "header({'X-Idempotency-Key': '*'}) \u0026\u0026 {http.reverse_proxy.status_code} in [502, 503]"
116 + },
117 + {
118 + "expression": "protocol('https') \u0026\u0026 {http.reverse_proxy.status_code} == 502"
119 + },
120 + {
121 + "expression": "path_regexp('^/api/v[0-9]+/') \u0026\u0026 {http.reverse_proxy.status_code} \u003e= 500"
122 + },
123 + {
124 + "expression": "header_regexp('Content-Type', '^application/json') \u0026\u0026 {http.reverse_proxy.status_code} == 502"
125 + },
126 + {
127 + "expression": "{http.reverse_proxy.is_transport_error} || {http.reverse_proxy.status_code} in [502, 503]"
128 + },
129 + {
130 + "expression": "{http.reverse_proxy.is_transport_error} \u0026\u0026 method('POST')"
131 + }
132 + ]
133 + },
134 + "upstreams": [
135 + {
136 + "dial": "127.0.0.1:65535"
137 + }
138 + ]
139 + }
140 + ]
141 + }
142 + ]
143 + }
144 + }
145 + }
146 + }
147 +}
caddytest/integration/reverseproxy_test.go
+231
@@ -7,6 +7,7 @@ import (
7 "os"
8 "runtime"
9 "strings"
10 + "sync/atomic"
11 "testing"
12
13 "github.com/caddyserver/caddy/v2/caddytest"
@@ -562,3 +563,233 @@ func TestReverseProxyHealthCheckUnixSocketWithoutPort(t *testing.T) {
563
564 tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
565 }
566 +
567 +// TestReverseProxyRetryMatchStatusCode verifies that lb_retry_match with a
568 +// CEL expression matching on {rp.status_code} causes the request to be
569 +// retried on the next upstream when the first upstream returns a matching
570 +// status code
571 +func TestReverseProxyRetryMatchStatusCode(t *testing.T) {
572 + // Bad upstream: returns 502
573 + badSrv := &http.Server{
574 + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
575 + w.WriteHeader(http.StatusBadGateway)
576 + }),
577 + }
578 + badLn, err := net.Listen("tcp", "127.0.0.1:0")
579 + if err != nil {
580 + t.Fatalf("failed to listen: %v", err)
581 + }
582 + go badSrv.Serve(badLn)
583 + t.Cleanup(func() { badSrv.Close(); badLn.Close() })
584 +
585 + // Good upstream: returns 200
586 + goodSrv := &http.Server{
587 + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
588 + w.Write([]byte("ok"))
589 + }),
590 + }
591 + goodLn, err := net.Listen("tcp", "127.0.0.1:0")
592 + if err != nil {
593 + t.Fatalf("failed to listen: %v", err)
594 + }
595 + go goodSrv.Serve(goodLn)
596 + t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
597 +
598 + tester := caddytest.NewTester(t)
599 + tester.InitServer(fmt.Sprintf(`
600 + {
601 + skip_install_trust
602 + admin localhost:2999
603 + http_port 9080
604 + https_port 9443
605 + grace_period 1ns
606 + }
607 + http://localhost:9080 {
608 + reverse_proxy %s %s {
609 + lb_policy round_robin
610 + lb_retries 1
611 + lb_retry_match {
612 + expression `+"`{rp.status_code} in [502, 503]`"+`
613 + }
614 + }
615 + }
616 + `, goodLn.Addr().String(), badLn.Addr().String()), "caddyfile")
617 +
618 + tester.AssertGetResponse("http://localhost:9080/", 200, "ok")
619 +}
620 +
621 +// TestReverseProxyRetryMatchHeader verifies that lb_retry_match with a CEL
622 +// expression matching on {rp.header.*} causes the request to be retried when
623 +// the upstream sets a matching response header
624 +func TestReverseProxyRetryMatchHeader(t *testing.T) {
625 + var badHits atomic.Int32
626 +
627 + // Bad upstream: returns 200 but signals retry via header
628 + badSrv := &http.Server{
629 + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
630 + badHits.Add(1)
631 + w.Header().Set("X-Upstream-Retry", "true")
632 + w.Write([]byte("bad"))
633 + }),
634 + }
635 + badLn, err := net.Listen("tcp", "127.0.0.1:0")
636 + if err != nil {
637 + t.Fatalf("failed to listen: %v", err)
638 + }
639 + go badSrv.Serve(badLn)
640 + t.Cleanup(func() { badSrv.Close(); badLn.Close() })
641 +
642 + // Good upstream: returns 200 without retry header
643 + goodSrv := &http.Server{
644 + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
645 + w.Write([]byte("good"))
646 + }),
647 + }
648 + goodLn, err := net.Listen("tcp", "127.0.0.1:0")
649 + if err != nil {
650 + t.Fatalf("failed to listen: %v", err)
651 + }
652 + go goodSrv.Serve(goodLn)
653 + t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
654 +
655 + tester := caddytest.NewTester(t)
656 + tester.InitServer(fmt.Sprintf(`
657 + {
658 + skip_install_trust
659 + admin localhost:2999
660 + http_port 9080
661 + https_port 9443
662 + grace_period 1ns
663 + }
664 + http://localhost:9080 {
665 + reverse_proxy %s %s {
666 + lb_policy round_robin
667 + lb_retries 1
668 + lb_retry_match {
669 + expression `+"`{rp.header.X-Upstream-Retry} == \"true\"`"+`
670 + }
671 + }
672 + }
673 + `, goodLn.Addr().String(), badLn.Addr().String()), "caddyfile")
674 +
675 + tester.AssertGetResponse("http://localhost:9080/", 200, "good")
676 +
677 + if badHits.Load() != 1 {
678 + t.Errorf("bad upstream hits: got %d, want 1", badHits.Load())
679 + }
680 +}
681 +
682 +// TestReverseProxyRetryMatchCombined verifies that a CEL expression combining
683 +// request path matching with response status code matching works correctly -
684 +// only retrying when both conditions are met
685 +func TestReverseProxyRetryMatchCombined(t *testing.T) {
686 + // Upstream: returns 502 for all requests
687 + srv := &http.Server{
688 + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
689 + w.WriteHeader(http.StatusBadGateway)
690 + }),
691 + }
692 + ln, err := net.Listen("tcp", "127.0.0.1:0")
693 + if err != nil {
694 + t.Fatalf("failed to listen: %v", err)
695 + }
696 + go srv.Serve(ln)
697 + t.Cleanup(func() { srv.Close(); ln.Close() })
698 +
699 + // Good upstream
700 + goodSrv := &http.Server{
701 + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
702 + w.Write([]byte("ok"))
703 + }),
704 + }
705 + goodLn, err := net.Listen("tcp", "127.0.0.1:0")
706 + if err != nil {
707 + t.Fatalf("failed to listen: %v", err)
708 + }
709 + go goodSrv.Serve(goodLn)
710 + t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
711 +
712 + tester := caddytest.NewTester(t)
713 + tester.InitServer(fmt.Sprintf(`
714 + {
715 + skip_install_trust
716 + admin localhost:2999
717 + http_port 9080
718 + https_port 9443
719 + grace_period 1ns
720 + }
721 + http://localhost:9080 {
722 + reverse_proxy %s %s {
723 + lb_policy round_robin
724 + lb_retries 1
725 + lb_retry_match {
726 + expression `+"`path('/retry*') && {rp.status_code} in [502, 503]`"+`
727 + }
728 + }
729 + }
730 + `, goodLn.Addr().String(), ln.Addr().String()), "caddyfile")
731 +
732 + // /retry path matches the expression - should retry to good upstream
733 + tester.AssertGetResponse("http://localhost:9080/retry", 200, "ok")
734 +
735 + // /other path does NOT match - should return the 502
736 + req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/other", nil)
737 + tester.AssertResponse(req, 502, "")
738 +}
739 +
740 +// TestReverseProxyRetryMatchIsTransportError verifies that the
741 +// {rp.is_transport_error} == true CEL function correctly identifies transport errors
742 +// and allows retrying them alongside response-based matching
743 +func TestReverseProxyRetryMatchIsTransportError(t *testing.T) {
744 + // Good upstream: returns 200
745 + goodSrv := &http.Server{
746 + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
747 + w.Write([]byte("ok"))
748 + }),
749 + }
750 + goodLn, err := net.Listen("tcp", "127.0.0.1:0")
751 + if err != nil {
752 + t.Fatalf("failed to listen: %v", err)
753 + }
754 + go goodSrv.Serve(goodLn)
755 + t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
756 +
757 + // Broken upstream: accepts connections but closes immediately
758 + brokenLn, err := net.Listen("tcp", "127.0.0.1:0")
759 + if err != nil {
760 + t.Fatalf("failed to listen: %v", err)
761 + }
762 + t.Cleanup(func() { brokenLn.Close() })
763 + go func() {
764 + for {
765 + conn, err := brokenLn.Accept()
766 + if err != nil {
767 + return
768 + }
769 + conn.Close()
770 + }
771 + }()
772 +
773 + tester := caddytest.NewTester(t)
774 + tester.InitServer(fmt.Sprintf(`
775 + {
776 + skip_install_trust
777 + admin localhost:2999
778 + http_port 9080
779 + https_port 9443
780 + grace_period 1ns
781 + }
782 + http://localhost:9080 {
783 + reverse_proxy %s %s {
784 + lb_policy round_robin
785 + lb_retries 1
786 + lb_retry_match {
787 + expression `+"`{rp.is_transport_error} || {rp.status_code} in [502, 503]`"+`
788 + }
789 + }
790 + }
791 + `, goodLn.Addr().String(), brokenLn.Addr().String()), "caddyfile")
792 +
793 + // Transport error on broken upstream should be retried to good upstream
794 + tester.AssertGetResponse("http://localhost:9080/", 200, "ok")
795 +}
modules/caddyhttp/matchers.go
+8
@@ -1562,6 +1562,14 @@ func ParseCaddyfileNestedMatcherSet(d *caddyfile.Dispenser) (caddy.ModuleMap, er
1562 // instances of the matcher in this set
1563 tokensByMatcherName := make(map[string][]caddyfile.Token)
1564 for nesting := d.Nesting(); d.NextArg() || d.NextBlock(nesting); {
1565 + // if the token is quoted (backtick), treat it as a shorthand
1566 + // for an expression matcher, same as @named matcher parsing
1567 + if d.Token().Quoted() {
1568 + expressionToken := d.Token().Clone()
1569 + expressionToken.Text = "expression"
1570 + tokensByMatcherName["expression"] = append(tokensByMatcherName["expression"], expressionToken, d.Token())
1571 + continue
1572 + }
1573 matcherName := d.Val()
1574 tokensByMatcherName[matcherName] = append(tokensByMatcherName[matcherName], d.NextSegment()...)
1575 }
modules/caddyhttp/reverseproxy/caddyfile.go
+1 -1
@@ -67,7 +67,7 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
67 // lb_retries <retries>
68 // lb_try_duration <duration>
69 // lb_try_interval <interval>
70 -// lb_retry_match <request-matcher>
70 +// lb_retry_match <matcher>
71 //
72 // # active health checking
73 // health_uri <uri>
modules/caddyhttp/reverseproxy/retries_test.go
+475
@@ -1,6 +1,7 @@
1 package reverseproxy
2
3 import (
4 + "context"
5 "errors"
6 "io"
7 "net"
@@ -8,11 +9,13 @@ import (
9 "net/http/httptest"
10 "strings"
11 "sync"
12 + "sync/atomic"
13 "testing"
14
15 "go.uber.org/zap"
16
17 "github.com/caddyserver/caddy/v2"
18 + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
19 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
20 )
21
@@ -255,3 +258,475 @@ func TestDialErrorBodyRetry(t *testing.T) {
258 })
259 }
260 }
261 +
262 +// newExpressionMatcher provisions a MatchExpression for use in tests
263 +func newExpressionMatcher(t *testing.T, expr string) *caddyhttp.MatchExpression {
264 + t.Helper()
265 + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
266 + t.Cleanup(cancel)
267 + m := &caddyhttp.MatchExpression{Expr: expr}
268 + if err := m.Provision(ctx); err != nil {
269 + t.Fatalf("failed to provision expression %q: %v", expr, err)
270 + }
271 + return m
272 +}
273 +
274 +// minimalHandlerWithRetryMatch is like minimalHandler but also configures
275 +// RetryMatch so that response-based retry can be tested
276 +func minimalHandlerWithRetryMatch(retries int, retryMatch caddyhttp.MatcherSets, upstreams ...*Upstream) *Handler {
277 + h := minimalHandler(retries, upstreams...)
278 + h.LoadBalancing.RetryMatch = retryMatch
279 + return h
280 +}
281 +
282 +// TestResponseRetryStatusCode verifies that when an upstream returns a status
283 +// code matching a retry_match expression, the request is retried on the next
284 +// upstream
285 +func TestResponseRetryStatusCode(t *testing.T) {
286 + // Bad upstream: returns 502
287 + badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
288 + w.WriteHeader(http.StatusBadGateway)
289 + }))
290 + t.Cleanup(badServer.Close)
291 +
292 + // Good upstream: returns 200
293 + goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
294 + w.WriteHeader(http.StatusOK)
295 + w.Write([]byte("ok"))
296 + }))
297 + t.Cleanup(goodServer.Close)
298 +
299 + retryMatch := caddyhttp.MatcherSets{
300 + caddyhttp.MatcherSet{
301 + newExpressionMatcher(t, "{http.reverse_proxy.status_code} in [502, 503]"),
302 + },
303 + }
304 +
305 + // RoundRobin picks index 1 first, then 0
306 + upstreams := []*Upstream{
307 + {Host: new(Host), Dial: goodServer.Listener.Addr().String()},
308 + {Host: new(Host), Dial: badServer.Listener.Addr().String()},
309 + }
310 +
311 + h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...)
312 +
313 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
314 + req = prepareTestRequest(req)
315 + rec := httptest.NewRecorder()
316 +
317 + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
318 + return nil
319 + }))
320 +
321 + gotStatus := rec.Code
322 + if err != nil {
323 + if herr, ok := err.(caddyhttp.HandlerError); ok {
324 + gotStatus = herr.StatusCode
325 + }
326 + }
327 +
328 + if gotStatus != http.StatusOK {
329 + t.Errorf("status: got %d, want %d (err=%v)", gotStatus, http.StatusOK, err)
330 + }
331 +}
332 +
333 +// TestResponseRetryHeader verifies that response header matching triggers
334 +// retries via a CEL expression checking {rp.header.*}
335 +func TestResponseRetryHeader(t *testing.T) {
336 + // Bad upstream: returns 200 but with X-Upstream-Retry header
337 + badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
338 + w.Header().Set("X-Upstream-Retry", "true")
339 + w.WriteHeader(http.StatusOK)
340 + w.Write([]byte("bad"))
341 + }))
342 + t.Cleanup(badServer.Close)
343 +
344 + // Good upstream: returns 200 without retry header
345 + goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
346 + w.WriteHeader(http.StatusOK)
347 + w.Write([]byte("good"))
348 + }))
349 + t.Cleanup(goodServer.Close)
350 +
351 + retryMatch := caddyhttp.MatcherSets{
352 + caddyhttp.MatcherSet{
353 + newExpressionMatcher(t, `{http.reverse_proxy.header.X-Upstream-Retry} == "true"`),
354 + },
355 + }
356 +
357 + // RoundRobin picks index 1 first, then 0
358 + upstreams := []*Upstream{
359 + {Host: new(Host), Dial: goodServer.Listener.Addr().String()},
360 + {Host: new(Host), Dial: badServer.Listener.Addr().String()},
361 + }
362 +
363 + h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...)
364 +
365 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
366 + req = prepareTestRequest(req)
367 + rec := httptest.NewRecorder()
368 +
369 + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
370 + return nil
371 + }))
372 + if err != nil {
373 + t.Fatalf("unexpected error: %v", err)
374 + }
375 +
376 + if rec.Code != http.StatusOK {
377 + t.Errorf("status: got %d, want %d", rec.Code, http.StatusOK)
378 + }
379 + if rec.Body.String() != "good" {
380 + t.Errorf("body: got %q, want %q (retried to wrong upstream)", rec.Body.String(), "good")
381 + }
382 +}
383 +
384 +// TestResponseRetryNoMatchNoRetry verifies that when no retry_match entries
385 +// match the response, the original response is returned without retrying
386 +func TestResponseRetryNoMatchNoRetry(t *testing.T) {
387 + var hits atomic.Int32
388 +
389 + // Server that returns 500 - but retry_match only matches 502/503
390 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
391 + hits.Add(1)
392 + w.WriteHeader(http.StatusInternalServerError)
393 + }))
394 + t.Cleanup(server.Close)
395 +
396 + retryMatch := caddyhttp.MatcherSets{
397 + caddyhttp.MatcherSet{
398 + newExpressionMatcher(t, "{http.reverse_proxy.status_code} in [502, 503]"),
399 + },
400 + }
401 +
402 + upstreams := []*Upstream{
403 + {Host: new(Host), Dial: server.Listener.Addr().String()},
404 + {Host: new(Host), Dial: server.Listener.Addr().String()},
405 + }
406 +
407 + h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...)
408 +
409 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
410 + req = prepareTestRequest(req)
411 + rec := httptest.NewRecorder()
412 +
413 + _ = h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
414 + return nil
415 + }))
416 +
417 + // Only one hit - no retry since 500 doesn't match [502, 503]
418 + if hits.Load() != 1 {
419 + t.Errorf("upstream hits: got %d, want 1 (should not have retried)", hits.Load())
420 + }
421 +}
422 +
423 +// TestResponseRetryExhaustedPreservesStatusCode verifies that when retries
424 +// are exhausted, the actual upstream status code (e.g. 503) is reported
425 +// to the client, not a generic 502
426 +func TestResponseRetryExhaustedPreservesStatusCode(t *testing.T) {
427 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
428 + w.WriteHeader(http.StatusServiceUnavailable) // 503
429 + }))
430 + t.Cleanup(server.Close)
431 +
432 + retryMatch := caddyhttp.MatcherSets{
433 + caddyhttp.MatcherSet{
434 + newExpressionMatcher(t, "{http.reverse_proxy.status_code} == 503"),
435 + },
436 + }
437 +
438 + upstreams := []*Upstream{
439 + {Host: new(Host), Dial: server.Listener.Addr().String()},
440 + {Host: new(Host), Dial: server.Listener.Addr().String()},
441 + }
442 +
443 + h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...)
444 +
445 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
446 + req = prepareTestRequest(req)
447 + rec := httptest.NewRecorder()
448 +
449 + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
450 + return nil
451 + }))
452 +
453 + gotStatus := rec.Code
454 + if err != nil {
455 + if herr, ok := err.(caddyhttp.HandlerError); ok {
456 + gotStatus = herr.StatusCode
457 + }
458 + }
459 +
460 + // Must return 503 (actual upstream status), not 502 (generic proxy error)
461 + if gotStatus != http.StatusServiceUnavailable {
462 + t.Errorf("status: got %d, want %d (status code not preserved)", gotStatus, http.StatusServiceUnavailable)
463 + }
464 +}
465 +
466 +// TestResponseRetryHeaderCleanup verifies that stale response header
467 +// placeholders from a previous upstream attempt are cleaned up before the
468 +// next retry evaluation. Without cleanup, a header like X-Retry: true from
469 +// upstream A would leak into the retry match for upstream B even if B does
470 +// not set that header
471 +func TestResponseRetryHeaderCleanup(t *testing.T) {
472 + // First upstream: returns 200 with X-Retry header (triggers retry)
473 + firstServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
474 + w.Header().Set("X-Retry", "true")
475 + w.WriteHeader(http.StatusOK)
476 + w.Write([]byte("first"))
477 + }))
478 + t.Cleanup(firstServer.Close)
479 +
480 + // Second upstream: returns 200 WITHOUT X-Retry header (should NOT retry)
481 + secondServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
482 + w.WriteHeader(http.StatusOK)
483 + w.Write([]byte("second"))
484 + }))
485 + t.Cleanup(secondServer.Close)
486 +
487 + retryMatch := caddyhttp.MatcherSets{
488 + caddyhttp.MatcherSet{
489 + newExpressionMatcher(t, `{http.reverse_proxy.header.X-Retry} == "true"`),
490 + },
491 + }
492 +
493 + // RoundRobin picks index 1 first, then 0
494 + upstreams := []*Upstream{
495 + {Host: new(Host), Dial: secondServer.Listener.Addr().String()},
496 + {Host: new(Host), Dial: firstServer.Listener.Addr().String()},
497 + }
498 +
499 + h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...)
500 +
501 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
502 + req = prepareTestRequest(req)
503 + rec := httptest.NewRecorder()
504 +
505 + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
506 + return nil
507 + }))
508 + if err != nil {
509 + t.Fatalf("unexpected error: %v", err)
510 + }
511 +
512 + // Should get "second" - the first upstream's X-Retry header must not
513 + // leak into the second upstream's retry evaluation
514 + if rec.Body.String() != "second" {
515 + t.Errorf("body: got %q, want %q (stale header leaked between retries)", rec.Body.String(), "second")
516 + }
517 +}
518 +
519 +// TestRequestOnlyMatcherDoesNotRetryResponses verifies that a pure request
520 +// matcher like method PUT in lb_retry_match does NOT trigger response-based
521 +// retries. Only expression matchers (which can reference response data)
522 +// should trigger response retries
523 +func TestRequestOnlyMatcherDoesNotRetryResponses(t *testing.T) {
524 + var hits atomic.Int32
525 +
526 + // Server returns 200 OK for all requests
527 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
528 + hits.Add(1)
529 + w.WriteHeader(http.StatusOK)
530 + w.Write([]byte("ok"))
531 + }))
532 + t.Cleanup(server.Close)
533 +
534 + // method PUT matcher - should NOT trigger response retries
535 + retryMatch := caddyhttp.MatcherSets{
536 + caddyhttp.MatcherSet{
537 + caddyhttp.MatchMethod{"PUT"},
538 + },
539 + }
540 +
541 + upstreams := []*Upstream{
542 + {Host: new(Host), Dial: server.Listener.Addr().String()},
543 + {Host: new(Host), Dial: server.Listener.Addr().String()},
544 + }
545 +
546 + h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...)
547 +
548 + req := httptest.NewRequest(http.MethodPut, "http://example.com/", nil)
549 + req = prepareTestRequest(req)
550 + rec := httptest.NewRecorder()
551 +
552 + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
553 + return nil
554 + }))
555 + if err != nil {
556 + t.Fatalf("unexpected error: %v", err)
557 + }
558 +
559 + // Should hit only once - no retry for 200 OK even though method matches
560 + if hits.Load() != 1 {
561 + t.Errorf("upstream hits: got %d, want 1 (should not retry successful responses)", hits.Load())
562 + }
563 + if rec.Code != http.StatusOK {
564 + t.Errorf("status: got %d, want %d", rec.Code, http.StatusOK)
565 + }
566 +}
567 +
568 +// brokenUpstreamAddr returns the address of a TCP listener that accepts
569 +// connections but immediately closes them, causing a transport error (not
570 +// a dial error). This simulates an upstream that is reachable but broken
571 +func brokenUpstreamAddr(t *testing.T) string {
572 + t.Helper()
573 + ln, err := net.Listen("tcp", "127.0.0.1:0")
574 + if err != nil {
575 + t.Fatalf("failed to listen: %v", err)
576 + }
577 + t.Cleanup(func() { ln.Close() })
578 + go func() {
579 + for {
580 + conn, err := ln.Accept()
581 + if err != nil {
582 + return
583 + }
584 + conn.Close()
585 + }
586 + }()
587 + return ln.Addr().String()
588 +}
589 +
590 +// TestTransportErrorPlaceholder verifies that the is_transport_error
591 +// placeholder is set to true during transport error evaluation in tryAgain()
592 +// and that expression matchers using {rp.is_transport_error} can match it
593 +func TestTransportErrorPlaceholder(t *testing.T) {
594 + broken := brokenUpstreamAddr(t)
595 +
596 + goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
597 + w.WriteHeader(http.StatusOK)
598 + w.Write([]byte("ok"))
599 + }))
600 + t.Cleanup(goodServer.Close)
601 +
602 + retryMatch := caddyhttp.MatcherSets{
603 + caddyhttp.MatcherSet{
604 + newExpressionMatcher(t, "{http.reverse_proxy.is_transport_error} == true"),
605 + },
606 + }
607 +
608 + // RoundRobin picks index 1 first (broken), then 0 (good)
609 + upstreams := []*Upstream{
610 + {Host: new(Host), Dial: goodServer.Listener.Addr().String()},
611 + {Host: new(Host), Dial: broken},
612 + }
613 +
614 + h := minimalHandlerWithRetryMatch(1, retryMatch, upstreams...)
615 +
616 + req := httptest.NewRequest(http.MethodPost, "http://example.com/", nil)
617 + req = prepareTestRequest(req)
618 + rec := httptest.NewRecorder()
619 +
620 + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
621 + return nil
622 + }))
623 +
624 + gotStatus := rec.Code
625 + if err != nil {
626 + if herr, ok := err.(caddyhttp.HandlerError); ok {
627 + gotStatus = herr.StatusCode
628 + }
629 + }
630 +
631 + // POST transport error should be retried because is_transport_error matched
632 + if gotStatus != http.StatusOK {
633 + t.Errorf("status: got %d, want %d (transport error should have been retried)", gotStatus, http.StatusOK)
634 + }
635 +}
636 +
637 +// TestTransportErrorPlaceholderNotSetForResponses verifies that the
638 +// is_transport_error placeholder is NOT set when evaluating response
639 +// matchers, so {rp.is_transport_error} is false for response retries
640 +func TestTransportErrorPlaceholderNotSetForResponses(t *testing.T) {
641 + var hits atomic.Int32
642 +
643 + // Server returns 502 - but the matcher only checks is_transport_error
644 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
645 + hits.Add(1)
646 + w.WriteHeader(http.StatusBadGateway)
647 + }))
648 + t.Cleanup(server.Close)
649 +
650 + // Only matches transport errors, not response errors
651 + retryMatch := caddyhttp.MatcherSets{
652 + caddyhttp.MatcherSet{
653 + newExpressionMatcher(t, "{http.reverse_proxy.is_transport_error} == true"),
654 + },
655 + }
656 +
657 + upstreams := []*Upstream{
658 + {Host: new(Host), Dial: server.Listener.Addr().String()},
659 + {Host: new(Host), Dial: server.Listener.Addr().String()},
660 + }
661 +
662 + h := minimalHandlerWithRetryMatch(2, retryMatch, upstreams...)
663 +
664 + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
665 + req = prepareTestRequest(req)
666 + rec := httptest.NewRecorder()
667 +
668 + _ = h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
669 + return nil
670 + }))
671 +
672 + // Should hit only once - is_transport_error is false during response
673 + // evaluation so the 502 is NOT retried
674 + if hits.Load() != 1 {
675 + t.Errorf("upstream hits: got %d, want 1 (is_transport_error should be false for responses)", hits.Load())
676 + }
677 +}
678 +
679 +// TestRetryMatchAllowsExpressionMixedWithOtherMatchers verifies that
680 +// lb_retry_match accepts a block mixing expression with other matchers
681 +func TestRetryMatchAllowsExpressionMixedWithOtherMatchers(t *testing.T) {
682 + tests := []struct {
683 + name string
684 + input string
685 + }{
686 + {
687 + name: "expression alone",
688 + input: `reverse_proxy localhost:9080 {
689 + lb_retry_match {
690 + expression ` + "`{rp.status_code} in [502, 503]`" + `
691 + }
692 + }`,
693 + },
694 + {
695 + name: "method alone",
696 + input: `reverse_proxy localhost:9080 {
697 + lb_retry_match {
698 + method PUT
699 + }
700 + }`,
701 + },
702 + {
703 + name: "expression mixed with method",
704 + input: `reverse_proxy localhost:9080 {
705 + lb_retry_match {
706 + method POST
707 + expression ` + "`{rp.status_code} in [502, 503]`" + `
708 + }
709 + }`,
710 + },
711 + {
712 + name: "expression mixed with path",
713 + input: `reverse_proxy localhost:9080 {
714 + lb_retry_match {
715 + path /api*
716 + expression ` + "`{rp.status_code} == 502`" + `
717 + }
718 + }`,
719 + },
720 + }
721 +
722 + for _, tc := range tests {
723 + t.Run(tc.name, func(t *testing.T) {
724 + h := &Handler{}
725 + d := caddyfile.NewTestDispenser(tc.input)
726 + err := h.UnmarshalCaddyfile(d)
727 + if err != nil {
728 + t.Errorf("unexpected error: %v", err)
729 + }
730 + })
731 + }
732 +}
modules/caddyhttp/reverseproxy/reverseproxy.go
+97 -19
@@ -670,8 +670,12 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h
670 return true, succ.error
671 }
672
673 - // remember this failure (if enabled)
674 - h.countFailure(upstream)
673 + // remember this failure (if enabled); response-based retries
674 + // are not counted as failures since the upstream did respond
675 + // successfully - only the response content triggered a retry
676 + if _, isRetryableResponse := proxyErr.(retryableResponseError); !isRetryableResponse {
677 + h.countFailure(upstream)
678 + }
679
680 // if we've tried long enough, break
681 if !h.LoadBalancing.tryAgain(h.ctx, start, retries, proxyErr, r, h.logger) {
@@ -1055,6 +1059,45 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe
1059 res.Body, _ = h.bufferedBody(res.Body, h.ResponseBuffers)
1060 }
1061
1062 + // set response placeholders so they can be used in retry match
1063 + // expressions and handle_response routes; clear stale header
1064 + // placeholders from a previous attempt first so they don't
1065 + // leak into the next retry evaluation
1066 + repl.DeleteByPrefix("http.reverse_proxy.header.")
1067 + for field, value := range res.Header {
1068 + repl.Set("http.reverse_proxy.header."+field, strings.Join(value, ","))
1069 + }
1070 + repl.Set("http.reverse_proxy.status_code", res.StatusCode)
1071 + repl.Set("http.reverse_proxy.status_text", res.Status)
1072 +
1073 + // check if the response matches a retry match entry; if so,
1074 + // close the body and return a retryable error so the request
1075 + // is retried with the next upstream. Only evaluate matcher sets
1076 + // that contain at least one expression matcher, since those are
1077 + // the ones that can reference response data ({rp.status_code},
1078 + // {rp.header.*}). Pure request-only matchers (method, path, etc.)
1079 + // are skipped to avoid retrying every response that matches a
1080 + // request condition
1081 + if h.LoadBalancing != nil && len(h.LoadBalancing.RetryMatch) > 0 {
1082 + for _, matcherSet := range h.LoadBalancing.RetryMatch {
1083 + if !matcherSetHasExpressionMatcher(matcherSet) {
1084 + continue
1085 + }
1086 + match, err := matcherSet.MatchWithError(req)
1087 + if err != nil {
1088 + h.logger.Error("error matching request for retry", zap.Error(err))
1089 + break
1090 + }
1091 + if match {
1092 + res.Body.Close()
1093 + return retryableResponseError{
1094 + error: fmt.Errorf("upstream response matched retry_match (status %d)", res.StatusCode),
1095 + statusCode: res.StatusCode,
1096 + }
1097 + }
1098 + }
1099 + }
1100 +
1101 // see if any response handler is configured for this response from the backend
1102 for i, rh := range h.HandleResponse {
1103 if rh.Match != nil && !rh.Match.Match(res.StatusCode, res.Header) {
@@ -1074,14 +1117,6 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe
1117 break
1118 }
1119
1077 - // set up the replacer so that parts of the original response can be
1078 - // used for routing decisions
1079 - for field, value := range res.Header {
1080 - repl.Set("http.reverse_proxy.header."+field, strings.Join(value, ","))
1081 - }
1082 - repl.Set("http.reverse_proxy.status_code", res.StatusCode)
1083 - repl.Set("http.reverse_proxy.status_text", res.Status)
1084 -
1120 if c := logger.Check(zapcore.DebugLevel, "handling response"); c != nil {
1121 c.Write(zap.Int("handler", i))
1122 }
@@ -1266,18 +1301,29 @@ func (lb LoadBalancing) tryAgain(ctx caddy.Context, start time.Time, retries int
1301 // specifically a dialer error, we need to be careful
1302 if proxyErr != nil {
1303 _, isDialError := proxyErr.(DialError)
1304 + _, isRetryableResponse := proxyErr.(retryableResponseError)
1305 herr, isHandlerError := proxyErr.(caddyhttp.HandlerError)
1306
1307 // if the error occurred after a connection was established,
1308 // we have to assume the upstream received the request, and
1309 // retries need to be carefully decided, because some requests
1274 - // are not idempotent
1275 - if !isDialError && (!isHandlerError || !errors.Is(herr, errNoUpstream)) {
1310 + // are not idempotent; retryableResponseError is excluded here
1311 + // because its retry decision was already made in reverseProxy()
1312 + // when the response matchers were evaluated
1313 + if !isDialError && !isRetryableResponse && (!isHandlerError || !errors.Is(herr, errNoUpstream)) {
1314 if lb.RetryMatch == nil && req.Method != "GET" {
1315 // by default, don't retry requests if they aren't GET
1316 return false
1317 }
1318
1319 + // set transport error flag so CEL expressions can use
1320 + // {rp.is_transport_error} to decide whether to retry
1321 + repl, _ := req.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
1322 + if repl != nil {
1323 + repl.Set("http.reverse_proxy.is_transport_error", true)
1324 + defer repl.Delete("http.reverse_proxy.is_transport_error")
1325 + }
1326 +
1327 match, err := lb.RetryMatch.AnyMatchWithError(req)
1328 if err != nil {
1329 logger.Error("error matching request for retry", zap.Error(err))
@@ -1507,6 +1553,12 @@ func removeConnectionHeaders(h http.Header) {
1553
1554 // statusError returns an error value that has a status code.
1555 func statusError(err error) error {
1556 + // if a response-based retry was exhausted, use the actual upstream
1557 + // status code instead of a generic 502
1558 + if rre, ok := err.(retryableResponseError); ok {
1559 + return caddyhttp.Error(rre.statusCode, err)
1560 + }
1561 +
1562 // errors proxying usually mean there is a problem with the upstream(s)
1563 statusCode := http.StatusBadGateway
1564
@@ -1558,13 +1610,15 @@ type LoadBalancing struct {
1610 // to spin if all backends are down and latency is very low.
1611 TryInterval caddy.Duration `json:"try_interval,omitempty"`
1612
1561 - // A list of matcher sets that restricts with which requests retries are
1562 - // allowed. A request must match any of the given matcher sets in order
1563 - // to be retried if the connection to the upstream succeeded but the
1564 - // subsequent round-trip failed. If the connection to the upstream failed,
1565 - // a retry is always allowed. If unspecified, only GET requests will be
1566 - // allowed to be retried. Note that a retry is done with the next available
1567 - // host according to the load balancing policy.
1613 + // A list of matcher sets that controls retry behavior. Matcher sets
1614 + // without expression matchers (e.g. method, path) restrict which
1615 + // requests are retried on transport errors - if unspecified, only
1616 + // GET requests will be retried. Matcher sets with CEL expression
1617 + // matchers are evaluated against upstream responses and can
1618 + // reference {rp.status_code}, {rp.header.*}, and
1619 + // {rp.is_transport_error}. Dial errors are always retried
1620 + // regardless of this setting. Retries use the next available
1621 + // upstream per the load balancing policy
1622 RetryMatchRaw caddyhttp.RawMatcherSets `json:"retry_match,omitempty" caddy:"namespace=http.matchers"`
1623
1624 SelectionPolicy Selector `json:"-"`
@@ -1662,10 +1716,34 @@ type RequestHeaderOpsTransport interface {
1716 RequestHeaderOps() *headers.HeaderOps
1717 }
1718
1719 +// matcherSetHasExpressionMatcher reports whether a matcher set contains
1720 +// at least one expression matcher. Expression matchers can reference
1721 +// response data via placeholders like {rp.status_code}. Matcher sets
1722 +// without expression matchers only test request properties and should
1723 +// not be evaluated for response-based retry decisions
1724 +func matcherSetHasExpressionMatcher(matcherSet caddyhttp.MatcherSet) bool {
1725 + for _, m := range matcherSet {
1726 + if _, ok := m.(*caddyhttp.MatchExpression); ok {
1727 + return true
1728 + }
1729 + }
1730 + return false
1731 +}
1732 +
1733 // roundtripSucceededError is an error type that is returned if the
1734 // roundtrip succeeded, but an error occurred after-the-fact.
1735 type roundtripSucceededError struct{ error }
1736
1737 +// retryableResponseError is returned when the upstream response matched
1738 +// a retry_match entry, indicating the request should be retried with the
1739 +// next upstream. It preserves the original status code so that if retries
1740 +// are exhausted, the actual upstream status is reported instead of a
1741 +// generic 502
1742 +type retryableResponseError struct {
1743 + error
1744 + statusCode int
1745 +}
1746 +
1747 // bodyReadCloser is a reader that, upon closing, will return
1748 // its buffer to the pool and close the underlying body reader.
1749 type bodyReadCloser struct {
replacer.go
+12
@@ -121,6 +121,18 @@ func (r *Replacer) Delete(variable string) {
121 r.mapMutex.Unlock()
122 }
123
124 +// DeleteByPrefix removes all static variables with
125 +// keys starting with the given prefix
126 +func (r *Replacer) DeleteByPrefix(prefix string) {
127 + r.mapMutex.Lock()
128 + for key := range r.static {
129 + if strings.HasPrefix(key, prefix) {
130 + delete(r.static, key)
131 + }
132 + }
133 + r.mapMutex.Unlock()
134 +}
135 +
136 // fromStatic provides values from r.static.
137 func (r *Replacer) fromStatic(key string) (any, bool) {
138 r.mapMutex.RLock()