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()