Skip to content

Commit 74f73e5

Browse files
committed
Feat: Add Retry Logic on Failed 509x related errors to certmanager(vault manager client)
1 parent 1151eed commit 74f73e5

2 files changed

Lines changed: 222 additions & 2 deletions

File tree

pkg/certmanager/retry.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package certmanager
2+
3+
import (
4+
"context"
5+
"crypto/x509"
6+
"errors"
7+
"fmt"
8+
"net/http"
9+
"strings"
10+
"time"
11+
)
12+
13+
// IsCertRelatedTLSError reports whether err is (or wraps) a TLS/x509 failure
14+
// that is likely to be fixed by rotating or reloading local certificate
15+
// material. Non-TLS errors (connection refused, timeouts, HTTP 5xx, etc.)
16+
// return false so callers do not stampede Vault on unrelated failures.
17+
func IsCertRelatedTLSError(err error) bool {
18+
if err == nil {
19+
return false
20+
}
21+
22+
var (
23+
unknownAuth x509.UnknownAuthorityError
24+
hostnameErr x509.HostnameError
25+
invalidCert x509.CertificateInvalidError
26+
)
27+
if errors.As(err, &unknownAuth) ||
28+
errors.As(err, &hostnameErr) ||
29+
errors.As(err, &invalidCert) {
30+
return true
31+
}
32+
33+
msg := strings.ToLower(err.Error())
34+
for _, n := range []string{
35+
"certificate",
36+
"tls:",
37+
"x509:",
38+
"bad certificate",
39+
"certificate required",
40+
"certificate signed by unknown authority",
41+
"expired certificate",
42+
"authentication handshake failed",
43+
"remote error: tls:",
44+
} {
45+
if strings.Contains(msg, n) {
46+
return true
47+
}
48+
}
49+
return false
50+
}
51+
52+
// WithCertRetry runs fn. On cert-related TLS failures it calls m.ForceRotate
53+
// and retries up to maxAttempts times with a short linear backoff.
54+
// Non-TLS errors are returned immediately. maxAttempts <= 0 defaults to 3.
55+
//
56+
// Typical use (gRPC dial, one-shot RPC, custom client):
57+
//
58+
// err := certmanager.WithCertRetry(ctx, mgr, 3, func(ctx context.Context) error {
59+
// return dialOrCall(ctx)
60+
// })
61+
func WithCertRetry(ctx context.Context, m *Manager, maxAttempts int, fn func(context.Context) error) error {
62+
if m == nil {
63+
return errors.New("certmanager: WithCertRetry called with nil Manager")
64+
}
65+
if fn == nil {
66+
return errors.New("certmanager: WithCertRetry called with nil fn")
67+
}
68+
if maxAttempts <= 0 {
69+
maxAttempts = 3
70+
}
71+
72+
var last error
73+
for attempt := 1; attempt <= maxAttempts; attempt++ {
74+
last = fn(ctx)
75+
if last == nil {
76+
return nil
77+
}
78+
if !IsCertRelatedTLSError(last) {
79+
return last
80+
}
81+
82+
m.logger.WithError(last).WithField("attempt", attempt).
83+
Warn("TLS error; forcing certificate rotation before retry")
84+
85+
if err := m.ForceRotate(ctx); err != nil {
86+
m.logger.WithError(err).Warn("ForceRotate failed; still retrying")
87+
}
88+
89+
if attempt == maxAttempts {
90+
break
91+
}
92+
select {
93+
case <-ctx.Done():
94+
return ctx.Err()
95+
case <-time.After(time.Duration(attempt) * 300 * time.Millisecond):
96+
}
97+
}
98+
return fmt.Errorf("certmanager: call failed after %d attempts: %w", maxAttempts, last)
99+
}
100+
101+
// CertRetryTransport is an http.RoundTripper that retries requests when the
102+
// underlying RoundTrip fails with a cert-related TLS error. On each such
103+
// failure it calls Manager.ForceRotate and optionally invokes AfterRotate
104+
// (e.g. to reload on-disk material into a tls.Config holder and close idle
105+
// connections).
106+
//
107+
// Base must be non-nil. AfterRotate may be nil. MaxAttempts <= 0 defaults to 3.
108+
//
109+
// Request bodies are only retried when req.GetBody is set (httputil.ReverseProxy
110+
// and most standard clients set this). Otherwise the transport returns the
111+
// TLS error without retrying to avoid sending a consumed body.
112+
type CertRetryTransport struct {
113+
Base http.RoundTripper
114+
Manager *Manager
115+
MaxAttempts int
116+
// AfterRotate is called after a successful or attempted ForceRotate,
117+
// before the next attempt. Use it to Reload cert files and
118+
// CloseIdleConnections on the underlying *http.Transport.
119+
AfterRotate func()
120+
}
121+
122+
func (t *CertRetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
123+
if t.Base == nil {
124+
return nil, errors.New("certmanager: CertRetryTransport.Base is nil")
125+
}
126+
max := t.MaxAttempts
127+
if max <= 0 {
128+
max = 3
129+
}
130+
131+
var last error
132+
for attempt := 1; attempt <= max; attempt++ {
133+
resp, err := t.Base.RoundTrip(req)
134+
if err == nil {
135+
return resp, nil
136+
}
137+
last = err
138+
139+
if !IsCertRelatedTLSError(err) {
140+
return nil, err
141+
}
142+
143+
if t.Manager != nil {
144+
t.Manager.logger.WithError(err).WithField("attempt", attempt).
145+
Warn("HTTP TLS error; forcing certificate rotation before retry")
146+
if ferr := t.Manager.ForceRotate(req.Context()); ferr != nil {
147+
t.Manager.logger.WithError(ferr).Warn("ForceRotate failed; still retrying")
148+
}
149+
}
150+
if t.AfterRotate != nil {
151+
t.AfterRotate()
152+
}
153+
154+
if attempt == max {
155+
break
156+
}
157+
158+
if req.Body != nil && req.GetBody == nil {
159+
return nil, fmt.Errorf("certmanager: tls retry aborted (request body not replayable): %w", last)
160+
}
161+
if req.GetBody != nil {
162+
body, berr := req.GetBody()
163+
if berr != nil {
164+
return nil, fmt.Errorf("certmanager: tls retry aborted (GetBody): %v (last: %w)", berr, last)
165+
}
166+
req.Body = body
167+
}
168+
169+
select {
170+
case <-req.Context().Done():
171+
return nil, req.Context().Err()
172+
case <-time.After(time.Duration(attempt) * 300 * time.Millisecond):
173+
}
174+
}
175+
return nil, fmt.Errorf("certmanager: upstream failed after %d tls retries: %w", max, last)
176+
}

pkg/certmanager/vault.go

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const (
2424
rotationFractionNumerator = 80
2525
rotationFractionDenominator = 100
2626
minRotationWait = 30 * time.Second
27+
minForceRotateGap = 15 * time.Second // anti-thundering-herd
2728
)
2829

2930
type Config struct {
@@ -56,6 +57,10 @@ type Manager struct {
5657

5758
mu sync.RWMutex
5859
current certMeta
60+
61+
// serializes scheduled rotation + ForceRotate
62+
rotateMu sync.Mutex
63+
lastForce time.Time
5964
}
6065

6166
type certMeta struct {
@@ -187,20 +192,29 @@ func (m *Manager) rotationLoop(ctx context.Context) {
187192
wait = minRotationWait
188193
}
189194

190-
m.logger.WithField("next_rotation", renewAt.UTC().Format(time.RFC3339)).Info("Next certificate rotation scheduled")
195+
m.logger.WithField("next_rotation", renewAt.UTC().Format(time.RFC3339)).
196+
Info("Next certificate rotation scheduled")
191197

192198
select {
193199
case <-ctx.Done():
194200
return
195201
case <-time.After(wait):
196202
}
197203

204+
m.rotateMu.Lock()
198205
cli, err := m.newVaultClient()
199206
if err != nil {
207+
m.rotateMu.Unlock()
200208
m.logger.WithError(err).Warn("Vault not reachable during rotation window; retrying later")
201209
continue
202210
}
203-
if err := m.issueAndPersist(ctx, cli); err != nil {
211+
err = m.issueAndPersist(ctx, cli)
212+
if err == nil {
213+
m.lastForce = time.Now() // treat success as a recent rotation
214+
}
215+
m.rotateMu.Unlock()
216+
217+
if err != nil {
204218
m.logger.WithError(err).Warn("Certificate rotation failed; retrying later")
205219
continue
206220
}
@@ -341,6 +355,36 @@ func (m *Manager) issueAndPersist(ctx context.Context, client *vault.Client) err
341355
return nil
342356
}
343357

358+
// ForceRotate re-issues a certificate from Vault immediately and writes it
359+
// to disk. Concurrent calls are serialized; calls within minForceRotateGap
360+
// of a successful force are no-ops so many clients failing at once do not
361+
// stampede Vault.
362+
func (m *Manager) ForceRotate(ctx context.Context) error {
363+
if !m.cfg.TLSEnabled || !m.cfg.VaultEnabled {
364+
return fmt.Errorf("force rotate: vault TLS cert manager is not enabled")
365+
}
366+
367+
m.rotateMu.Lock()
368+
defer m.rotateMu.Unlock()
369+
370+
if time.Since(m.lastForce) < minForceRotateGap {
371+
m.logger.Debug("ForceRotate skipped; recent rotation already occurred")
372+
return nil
373+
}
374+
375+
cli, err := m.newVaultClient()
376+
if err != nil {
377+
return fmt.Errorf("force rotate: vault client: %w", err)
378+
}
379+
if err := m.issueAndPersist(ctx, cli); err != nil {
380+
return fmt.Errorf("force rotate: issue: %w", err)
381+
}
382+
383+
m.lastForce = time.Now()
384+
m.logger.Info("ForceRotate completed; new certificate installed")
385+
return nil
386+
}
387+
344388
func (m *Manager) detectSANs() ([]string, []string) {
345389
dnsSet := map[string]struct{}{}
346390
ipSet := map[string]struct{}{}

0 commit comments

Comments
 (0)