Skip to content

Commit f43b4cd

Browse files
nckslvrmnunixcharlesbenburkert
authored
Expose Retry-After header on all ACME responses (#264)
* Expose Retry-After header on errors and resources * move parser to http middleware and always cast to time like certbot * fix tests * add a new field that always casts to a time object instead of changing original * Add bigdecimal to the development gemspec * Rebase: Ari improvements complete (#269) * ARI related improvements * Fix ARI tests: extract replaces from order response, re-record cassettes with Pebble - Add :replaces to extract_attributes in attributes_from_order_response (bug: replaces was sent in requests but never parsed from responses) - Re-record VCR cassettes against Pebble with real ARI data: - renewal_info_supported: real suggested window + retry-after - new_order_with_replaces: order response includes replaces field - new_order_already_replaced: real 409 alreadyReplaced error - Update certificate_chain.pem fixture with cert from Pebble - Remove pending() from replaces tests in order_spec and renewal_info_spec --------- Co-authored-by: Ben Burkert <ben@benburkert.com> Co-authored-by: Nick Silverman <nckslvrmn@gmail.com> * test fixes --------- Co-authored-by: Charles Barbier <unixcharles@gmail.com> Co-authored-by: Charles Barbier <github@charlesbarbier.com> Co-authored-by: Ben Burkert <ben@benburkert.com>
1 parent 890d60a commit f43b4cd

File tree

15 files changed

+414
-28
lines changed

15 files changed

+414
-28
lines changed

acme-client.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Gem::Specification.new do |spec|
1818

1919
spec.add_development_dependency 'rake', '~> 13.0'
2020
spec.add_development_dependency 'rspec', '~> 3.9'
21-
spec.add_development_dependency 'vcr', '~> 2.9'
21+
spec.add_development_dependency 'vcr', '~> 6.0'
2222
spec.add_development_dependency 'bigdecimal'
2323
spec.add_development_dependency 'webmock', '~> 3.8'
2424
spec.add_development_dependency 'webrick', '~> 1.7'

lib/acme/client.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,15 +326,20 @@ def attributes_from_order_response(response)
326326
)
327327

328328
attributes[:url] = response.headers[:location] if response.headers[:location]
329+
attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
329330
attributes
330331
end
331332

332333
def attributes_from_authorization_response(response)
333-
extract_attributes(response.body, :identifier, :status, :expires, :challenges, :wildcard)
334+
attributes = extract_attributes(response.body, :identifier, :status, :expires, :challenges, :wildcard)
335+
attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
336+
attributes
334337
end
335338

336339
def attributes_from_challenge_response(response)
337-
extract_attributes(response.body, :status, :url, :token, :type, :error, :validated)
340+
attributes = extract_attributes(response.body, :status, :url, :token, :type, :error, :validated)
341+
attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
342+
attributes
338343
end
339344

340345
def attributes_from_renewal_info_response(response)

lib/acme/client/error.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
class Acme::Client::Error < StandardError
2-
attr_reader :subproblems
3-
attr_reader :acme_error_body
4-
2+
attr_reader :retry_after, :retry_after_time, :subproblems, :acme_error_body
53

64
Subproblem = Struct.new(:type, :detail, :identifier, keyword_init: true) do
75
def to_h
86
{ type: type, detail: detail, identifier: identifier }
97
end
108
end
119

12-
def initialize(message = nil, acme_error_body: nil, subproblems: nil)
10+
def initialize(message = nil, retry_after: nil, acme_error_body: nil, subproblems: nil)
1311
super(message)
12+
@retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
13+
@retry_after = @retry_after_time ? [(@retry_after_time - Time.now).ceil, 0].max : nil
1414
@acme_error_body = acme_error_body
1515
@subproblems = parse_subproblems(subproblems)
1616
end
Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
class Acme::Client::Error::RateLimited < Acme::Client::Error::ServerError
2-
attr_reader :retry_after
3-
42
DEFAULT_MESSAGE = 'Error message: urn:ietf:params:acme:error:rateLimited'
3+
DEFAULT_RETRY_SECONDS = 10
54

6-
def initialize(message = DEFAULT_MESSAGE, retry_after = 10, acme_error_body: nil, subproblems: nil)
7-
super(message, acme_error_body: acme_error_body, subproblems: subproblems)
8-
@retry_after = retry_after.nil? ? 10 : retry_after.to_i
5+
def initialize(message = DEFAULT_MESSAGE, retry_after = nil, acme_error_body: nil, subproblems: nil)
6+
retry_after_time = case retry_after
7+
when Time then retry_after
8+
when nil then Time.now + DEFAULT_RETRY_SECONDS
9+
else Acme::Client::Util.parse_retry_after(retry_after) || Time.now + DEFAULT_RETRY_SECONDS
10+
end
11+
int_retry_after = retry_after.nil? ? DEFAULT_RETRY_SECONDS : [(retry_after_time - Time.now).ceil, 0].max
12+
super(message, retry_after: int_retry_after, acme_error_body: acme_error_body, subproblems: subproblems)
13+
@retry_after_time = retry_after_time
914
end
1015
end

lib/acme/client/http_client.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,13 @@ def raise_on_not_found!
101101
end
102102

103103
def raise_on_error!
104+
retry_after = env.response_headers['retry-after']
104105
body = env.body.is_a?(Hash) ? env.body : nil
105106
subproblems = error_subproblems
106107
if error_class == Acme::Client::Error::RateLimited
107-
raise error_class.new(error_message, env.response_headers['Retry-After'], acme_error_body: body, subproblems: subproblems)
108+
raise error_class.new(error_message, retry_after, acme_error_body: body, subproblems: subproblems)
108109
end
109-
raise error_class.new(error_message, acme_error_body: body, subproblems: subproblems)
110+
raise error_class.new(error_message, retry_after: retry_after, acme_error_body: body, subproblems: subproblems)
110111
end
111112

112113
def error_message

lib/acme/client/resources/authorization.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
class Acme::Client::Resources::Authorization
4-
attr_reader :url, :identifier, :domain, :expires, :status, :wildcard
4+
attr_reader :url, :identifier, :domain, :expires, :status, :wildcard, :retry_after, :retry_after_time
55

66
def initialize(client, **arguments)
77
@client = client
@@ -52,7 +52,8 @@ def to_h
5252
status: status,
5353
expires: expires,
5454
challenges: @challenges,
55-
wildcard: wildcard
55+
wildcard: wildcard,
56+
retry_after: retry_after
5657
}
5758
end
5859

@@ -69,13 +70,15 @@ def initialize_challenge(attributes)
6970
Acme::Client::Resources::Challenges.new(@client, **arguments)
7071
end
7172

72-
def assign_attributes(url:, status:, expires:, challenges:, identifier:, wildcard: false)
73+
def assign_attributes(url:, status:, expires:, challenges:, identifier:, wildcard: false, retry_after: nil)
7374
@url = url
7475
@identifier = identifier
7576
@domain = identifier.fetch('value')
7677
@status = status
7778
@expires = expires
7879
@challenges = challenges
7980
@wildcard = wildcard
81+
@retry_after = retry_after
82+
@retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
8083
end
8184
end

lib/acme/client/resources/challenges/base.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
class Acme::Client::Resources::Challenges::Base
4-
attr_reader :status, :url, :token, :error, :validated
4+
attr_reader :status, :url, :token, :error, :validated, :retry_after, :retry_after_time
55

66
def initialize(client, **arguments)
77
@client = client
@@ -38,7 +38,7 @@ def typed_error
3838
end
3939

4040
def to_h
41-
{ status: status, url: url, token: token, error: error, validated: validated }
41+
{ status: status, url: url, token: token, error: error, validated: validated, retry_after: retry_after }
4242
end
4343

4444
private
@@ -49,11 +49,13 @@ def send_challenge_validation(url:)
4949
).to_h
5050
end
5151

52-
def assign_attributes(status:, url:, token:, error: nil, validated: nil)
52+
def assign_attributes(status:, url:, token:, error: nil, validated: nil, retry_after: nil)
5353
@status = status
5454
@url = url
5555
@token = token
5656
@error = error
5757
@validated = validated
58+
@retry_after = retry_after
59+
@retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
5860
end
5961
end

lib/acme/client/resources/order.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
class Acme::Client::Resources::Order
4-
attr_reader :url, :status, :contact, :finalize_url, :identifiers, :authorization_urls, :expires, :certificate_url, :profile, :replaces
4+
attr_reader :url, :status, :contact, :finalize_url, :identifiers, :authorization_urls, :expires, :certificate_url, :profile, :replaces, :retry_after, :retry_after_time
55

66
def initialize(client, **arguments)
77
@client = client
@@ -56,13 +56,14 @@ def to_h
5656
identifiers: identifiers,
5757
certificate_url: certificate_url,
5858
profile: profile,
59-
replaces: replaces
59+
replaces: replaces,
60+
retry_after: retry_after
6061
}
6162
end
6263

6364
private
6465

65-
def assign_attributes(url: nil, status:, expires:, finalize_url:, authorization_urls:, identifiers:, certificate_url: nil, profile: nil, replaces: nil) # rubocop:disable Layout/LineLength,Metrics/ParameterLists
66+
def assign_attributes(url: nil, status:, expires:, finalize_url:, authorization_urls:, identifiers:, certificate_url: nil, profile: nil, replaces: nil, retry_after: nil) # rubocop:disable Layout/LineLength,Metrics/ParameterLists
6667
@url = url unless url.nil?
6768
@status = status
6869
@expires = expires
@@ -72,5 +73,7 @@ def assign_attributes(url: nil, status:, expires:, finalize_url:, authorization_
7273
@certificate_url = certificate_url
7374
@profile = profile
7475
@replaces = replaces
76+
@retry_after = retry_after
77+
@retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
7578
end
7679
end

lib/acme/client/resources/renewal_info.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
class Acme::Client::Resources::RenewalInfo
4-
attr_reader :ari_id, :suggested_window, :explanation_url, :retry_after
4+
attr_reader :ari_id, :suggested_window, :explanation_url, :retry_after, :retry_after_time
55

66
def initialize(client, **arguments)
77
@client = client
@@ -49,5 +49,6 @@ def assign_attributes(ari_id:, suggested_window:, explanation_url: nil, retry_af
4949
@suggested_window = suggested_window
5050
@explanation_url = explanation_url
5151
@retry_after = retry_after
52+
@retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
5253
end
5354
end

lib/acme/client/util.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
1+
require 'time'
2+
13
module Acme::Client::Util
24
extend self
35

6+
# Parses a Retry-After header value into a Time.
7+
# RFC 7231 §7.1.3: the value is either delay-seconds or an HTTP-date.
8+
# Returns a Time, or nil if the value is nil or unparseable.
9+
def parse_retry_after(value)
10+
return nil if value.nil?
11+
12+
value = value.to_s
13+
Integer(value, 10).then { |seconds| Time.now + seconds }
14+
rescue ArgumentError, RangeError
15+
begin
16+
Time.httpdate(value)
17+
rescue ArgumentError
18+
nil
19+
end
20+
end
21+
422
def urlsafe_base64(data)
523
Base64.urlsafe_encode64(data).sub(/[\s=]*\z/, '')
624
end

0 commit comments

Comments
 (0)