From 061aeafad7b4a5138e25f438c85a7ffb5d1c43fd Mon Sep 17 00:00:00 2001 From: Adam Talbot Date: Fri, 11 Jul 2025 13:10:18 +0100 Subject: [PATCH] When using WaitAuthorization or WaitOrder is not practical due to its blocking, for example in Kubernetes controllers, the GetAuthorization can be used. This change adds the RetryAfter field to the Authorization, Order and Challenge objects returned by GetAuthorization, GetOrder and GetChallenge so it can be used by these implementations that use their own polling mechanism. The new RetryAfter field is populated by the "Retry-After" header in the HTTP response. Fixes golang/go#74454 Signed-off-by: Adam Talbot --- acme/acme.go | 12 +++++++----- acme/rfc8555.go | 1 + acme/types.go | 38 +++++++++++++++++++++++++++++++------- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/acme/acme.go b/acme/acme.go index 7a51284f91..19d9ac465b 100644 --- a/acme/acme.go +++ b/acme/acme.go @@ -381,7 +381,7 @@ func (c *Client) authorize(ctx context.Context, typ, val string) (*Authorization if v.Status != StatusPending && v.Status != StatusValid { return nil, fmt.Errorf("acme: unexpected status: %s", v.Status) } - return v.authorization(res.Header.Get("Location")), nil + return v.authorization(res.Header.Get("Location"), 0), nil } // GetAuthorization retrieves an authorization identified by the given URL. @@ -402,7 +402,8 @@ func (c *Client) GetAuthorization(ctx context.Context, url string) (*Authorizati if err := json.NewDecoder(res.Body).Decode(&v); err != nil { return nil, fmt.Errorf("acme: invalid response: %v", err) } - return v.authorization(url), nil + d := retryAfter(res.Header.Get("Retry-After")) + return v.authorization(url, d), nil } // RevokeAuthorization relinquishes an existing authorization identified @@ -460,7 +461,7 @@ func (c *Client) WaitAuthorization(ctx context.Context, url string) (*Authorizat case err != nil: // Skip and retry. case raw.Status == StatusValid: - return raw.authorization(url), nil + return raw.authorization(url, 0), nil case raw.Status == StatusInvalid: return nil, raw.error(url) } @@ -505,7 +506,8 @@ func (c *Client) GetChallenge(ctx context.Context, url string) (*Challenge, erro if err := json.NewDecoder(res.Body).Decode(&v); err != nil { return nil, fmt.Errorf("acme: invalid response: %v", err) } - return v.challenge(), nil + d := retryAfter(res.Header.Get("Retry-After")) + return v.challenge(d), nil } // Accept informs the server that the client accepts one of its challenges @@ -534,7 +536,7 @@ func (c *Client) Accept(ctx context.Context, chal *Challenge) (*Challenge, error if err := json.NewDecoder(res.Body).Decode(&v); err != nil { return nil, fmt.Errorf("acme: invalid response: %v", err) } - return v.challenge(), nil + return v.challenge(0), nil } // DNS01ChallengeRecord returns a DNS record value for a dns-01 challenge response. diff --git a/acme/rfc8555.go b/acme/rfc8555.go index 3152e531b6..42e76c44ee 100644 --- a/acme/rfc8555.go +++ b/acme/rfc8555.go @@ -318,6 +318,7 @@ func responseOrder(res *http.Response) (*Order, error) { AuthzURLs: v.Authorizations, FinalizeURL: v.Finalize, CertURL: v.Certificate, + RetryAfter: retryAfter(res.Header.Get("Retry-After")), } for _, id := range v.Identifiers { o.Identifiers = append(o.Identifiers, AuthzID{Type: id.Type, Value: id.Value}) diff --git a/acme/types.go b/acme/types.go index c466645ca1..da7c6a579c 100644 --- a/acme/types.go +++ b/acme/types.go @@ -366,6 +366,13 @@ type Order struct { // The error that occurred while processing the order as received from a CA, if any. Error *Error + + // RetryAfter specifies how long the client should wait before polling the order again, + // based on the Retry-After header provided by the server while the order is in the + // StatusProcessing state. + // + // See RFC 8555 Section 7.4. + RetryAfter time.Duration } // OrderOption allows customizing Client.AuthorizeOrder call. @@ -426,6 +433,14 @@ type Authorization struct { // // This field is unused in RFC 8555. Combinations [][]int + + // RetryAfter specifies how long the client should wait before polling the + // authorization resource again, if indicated by the server. + // This corresponds to the optional Retry-After HTTP header included in a + // 200 (OK) response when the authorization is still StatusPending. + // + // See RFC 8555 Section 7.5.1. + RetryAfter time.Duration } // AuthzID is an identifier that an account is authorized to represent. @@ -471,7 +486,7 @@ type wireAuthz struct { Error *wireError } -func (z *wireAuthz) authorization(uri string) *Authorization { +func (z *wireAuthz) authorization(uri string, retryAfter time.Duration) *Authorization { a := &Authorization{ URI: uri, Status: z.Status, @@ -480,9 +495,10 @@ func (z *wireAuthz) authorization(uri string) *Authorization { Wildcard: z.Wildcard, Challenges: make([]*Challenge, len(z.Challenges)), Combinations: z.Combinations, // shallow copy + RetryAfter: retryAfter, } for i, v := range z.Challenges { - a.Challenges[i] = v.challenge() + a.Challenges[i] = v.challenge(0) } return a } @@ -542,6 +558,13 @@ type Challenge struct { // where the client must send additional data for the server to validate // the challenge. Payload json.RawMessage + + // RetryAfter specifies how long the client should wait before polling the + // challenge again, based on the Retry-After header provided by the server + // while the challenge is in the StatusProcessing state. + // + // See RFC 8555 Section 8.2. + RetryAfter time.Duration } // wireChallenge is ACME JSON challenge representation. @@ -555,12 +578,13 @@ type wireChallenge struct { Error *wireError } -func (c *wireChallenge) challenge() *Challenge { +func (c *wireChallenge) challenge(retryAfter time.Duration) *Challenge { v := &Challenge{ - URI: c.URL, - Type: c.Type, - Token: c.Token, - Status: c.Status, + URI: c.URL, + Type: c.Type, + Token: c.Token, + Status: c.Status, + RetryAfter: retryAfter, } if v.URI == "" { v.URI = c.URI // c.URL was empty; use legacy