ipn/localapi,client/local: honour Retry-After on cert rate-limit (#20315)

* ipn/localapi,ipnlocal,feature/acme,client/local: honour Retry-After on cert rate-limit

serveCert now responds with 429 + Retry-After when the underlying ACME
error is a rate limit, instead of a generic 500. client/local surfaces
this as a typed RateLimitedError with the parsed hint so callers can
back off intelligently.

Updates tailscale/corp#42164

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>

* tsweb,feature/acme,ipn/localapi,ipnlocal: generalise cert error → HTTP mapping via tsweb.HTTPStatuser

Introduces a tsweb.HTTPStatuser interface, any error can implement
to describe its intended HTTP response (code, message, headers).
Moves CertRateLimitedError from ipnlocal to feature/acme where it's
constructed, and it now uses HTTPStatuser to return 429 + Retry-After.

serveCert now checks for tsweb.HTTPStatuser rather than the specific
error type, so it no longer needs to know about the ACME rate-limit
type.

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>

---------

Signed-off-by: chaosinthecrd <tom@tmlabs.co.uk>
This commit is contained in:
Tom Meadows
2026-07-08 13:34:40 +01:00
committed by GitHub
parent 9106b237eb
commit 87b3d7b7e5
11 changed files with 163 additions and 36 deletions
+54
View File
@@ -10,13 +10,55 @@ import (
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go4.org/mem"
)
// rateLimitedError is returned from cert-fetching methods when the
// upstream ACME CA reported a rate limit. Callers should unpack it via
// [RateLimitRetryAfter].
type rateLimitedError struct {
retryAfter time.Duration
underlying error
}
func (e rateLimitedError) Error() string { return e.underlying.Error() }
func (e rateLimitedError) Unwrap() error { return e.underlying }
// RateLimitRetryAfter reports whether err was a rate-limit failure from
// the upstream ACME CA and, if so, returns the CA's suggested wait
// (zero if none was provided).
func RateLimitRetryAfter(err error) (retryAfter time.Duration, ok bool) {
var rl rateLimitedError
if errors.As(err, &rl) {
return rl.retryAfter, true
}
return 0, false
}
// retryAfterFromHeader parses a Retry-After header, matching the
// delta-seconds + HTTP-date pattern in tempfork/acme/http.go.
func retryAfterFromHeader(h http.Header) time.Duration {
v := h.Get("Retry-After")
if i, err := strconv.Atoi(v); err == nil {
return time.Duration(i) * time.Second
}
t, err := http.ParseTime(v)
if err != nil {
return 0
}
d := time.Until(t)
if d < 0 {
return 0
}
return d
}
// SetDNS adds a DNS TXT record for the given domain name, containing
// the provided TXT value. The intended use case is answering
// LetsEncrypt/ACME dns-01 challenges.
@@ -43,6 +85,8 @@ func (lc *Client) SetDNS(ctx context.Context, name, value string) error {
//
// It returns a cached certificate from disk if it's still valid.
//
// Rate-limit failures can be identified via [RateLimitRetryAfter].
//
// Deprecated: use [Client.CertPair].
func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return defaultClient.CertPair(ctx, domain)
@@ -52,6 +96,8 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e
//
// It returns a cached certificate from disk if it's still valid.
//
// Rate-limit failures can be identified via [RateLimitRetryAfter].
//
// API maturity: this is considered a stable API.
func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) {
return lc.CertPairWithValidity(ctx, domain, 0)
@@ -65,10 +111,18 @@ func (lc *Client) CertPair(ctx context.Context, domain string) (certPEM, keyPEM
// least the given duration, if permitted by the CA. If the certificate is
// valid, but for less than minValidity, it will be synchronously renewed.
//
// Rate-limit failures can be identified via [RateLimitRetryAfter].
//
// API maturity: this is considered a stable API.
func (lc *Client) CertPairWithValidity(ctx context.Context, domain string, minValidity time.Duration) (certPEM, keyPEM []byte, err error) {
res, err := lc.send(ctx, "GET", fmt.Sprintf("/localapi/v0/cert/%s?type=pair&min_validity=%s", domain, minValidity), 200, nil)
if err != nil {
if hse, ok := errors.AsType[httpStatusError](err); ok && hse.HTTPStatus == http.StatusTooManyRequests {
return nil, nil, rateLimitedError{
retryAfter: retryAfterFromHeader(hse.Header),
underlying: err,
}
}
return nil, nil, err
}
// with ?type=pair, the response PEM is first the one private
+2 -1
View File
@@ -280,7 +280,7 @@ func (lc *Client) sendWithHeaders(
}
if res.StatusCode != wantStatus {
err = fmt.Errorf("%v: %s", res.Status, bytes.TrimSpace(slurp))
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode}
return nil, nil, httpStatusError{bestError(err, slurp), res.StatusCode, res.Header}
}
return slurp, res.Header, nil
}
@@ -288,6 +288,7 @@ func (lc *Client) sendWithHeaders(
type httpStatusError struct {
error
HTTPStatus int
Header http.Header
}
func (lc *Client) get200(ctx context.Context, path string) ([]byte, error) {