Skip to content

Commit 4a4b593

Browse files
authored
Expose RFC7807 sub-problems on errors (#265)
1 parent cd3bc62 commit 4a4b593

File tree

4 files changed

+187
-4
lines changed

4 files changed

+187
-4
lines changed

lib/acme/client/error.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,33 @@
11
class Acme::Client::Error < StandardError
2+
attr_reader :subproblems
3+
4+
Subproblem = Struct.new(:type, :detail, :identifier, keyword_init: true) do
5+
def to_h
6+
{ type: type, detail: detail, identifier: identifier }
7+
end
8+
end
9+
10+
def initialize(message = nil, subproblems: nil)
11+
super(message)
12+
@subproblems = parse_subproblems(subproblems)
13+
end
14+
15+
private
16+
17+
def parse_subproblems(raw)
18+
return [] if raw.nil? || !raw.is_a?(Array)
19+
20+
raw.map do |sp|
21+
Subproblem.new(
22+
type: sp['type'],
23+
detail: sp['detail'],
24+
identifier: sp['identifier']
25+
)
26+
end
27+
end
28+
29+
public
30+
231
class Timeout < Acme::Client::Error; end
332

433
class ClientError < Acme::Client::Error; end

lib/acme/client/error/rate_limited.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ class Acme::Client::Error::RateLimited < Acme::Client::Error::ServerError
33

44
DEFAULT_MESSAGE = 'Error message: urn:ietf:params:acme:error:rateLimited'
55

6-
def initialize(message = DEFAULT_MESSAGE, retry_after = 10)
7-
super(message)
6+
def initialize(message = DEFAULT_MESSAGE, retry_after = 10, subproblems: nil)
7+
super(message, subproblems: subproblems)
88
@retry_after = retry_after.nil? ? 10 : retry_after.to_i
99
end
1010
end

lib/acme/client/http_client.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,11 @@ def raise_on_not_found!
101101
end
102102

103103
def raise_on_error!
104+
subproblems = error_subproblems
104105
if error_class == Acme::Client::Error::RateLimited
105-
raise error_class.new(error_message, env.response_headers['Retry-After'])
106+
raise error_class.new(error_message, env.response_headers['Retry-After'], subproblems: subproblems)
106107
end
107-
raise error_class, error_message
108+
raise error_class.new(error_message, subproblems: subproblems)
108109
end
109110

110111
def error_message
@@ -125,6 +126,11 @@ def error_name
125126
env.body['type']
126127
end
127128

129+
def error_subproblems
130+
return unless env.body.is_a?(Hash)
131+
env.body['subproblems']
132+
end
133+
128134
def decode_body
129135
content_type = env.response_headers['Content-Type'].to_s
130136

spec/subproblems_spec.rb

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# frozen_string_literal: true
2+
3+
$LOAD_PATH.unshift File.join(__dir__, '../lib')
4+
5+
require 'acme/client'
6+
7+
RSpec.describe 'RFC 7807 subproblems support' do
8+
let(:raw_subproblems) do
9+
[
10+
{
11+
'type' => 'urn:ietf:params:acme:error:rejectedIdentifier',
12+
'detail' => 'This CA will not issue for "example.net"',
13+
'identifier' => { 'type' => 'dns', 'value' => 'example.net' }
14+
},
15+
{
16+
'type' => 'urn:ietf:params:acme:error:caa',
17+
'detail' => 'CAA record for "example.org" prevents issuance',
18+
'identifier' => { 'type' => 'dns', 'value' => 'example.org' }
19+
}
20+
]
21+
end
22+
23+
describe Acme::Client::Error do
24+
it 'exposes parsed subproblems when provided' do
25+
error = Acme::Client::Error.new('multi-identifier failure', subproblems: raw_subproblems)
26+
expect(error.subproblems.length).to eq(2)
27+
end
28+
29+
it 'returns Subproblem structs with type, detail, and identifier' do
30+
error = Acme::Client::Error.new('failure', subproblems: raw_subproblems)
31+
sp = error.subproblems.first
32+
33+
expect(sp).to be_a(Acme::Client::Error::Subproblem)
34+
expect(sp.type).to eq('urn:ietf:params:acme:error:rejectedIdentifier')
35+
expect(sp.detail).to eq('This CA will not issue for "example.net"')
36+
expect(sp.identifier).to eq({ 'type' => 'dns', 'value' => 'example.net' })
37+
end
38+
39+
it 'defaults to an empty array when no subproblems' do
40+
error = Acme::Client::Error.new('simple error')
41+
expect(error.subproblems).to eq([])
42+
end
43+
44+
it 'defaults to an empty array when subproblems is nil' do
45+
error = Acme::Client::Error.new('simple error', subproblems: nil)
46+
expect(error.subproblems).to eq([])
47+
end
48+
49+
it 'handles non-array subproblems gracefully' do
50+
error = Acme::Client::Error.new('bad data', subproblems: 'not an array')
51+
expect(error.subproblems).to eq([])
52+
end
53+
54+
it 'works with no arguments' do
55+
error = Acme::Client::Error.new
56+
expect(error.subproblems).to eq([])
57+
end
58+
end
59+
60+
describe Acme::Client::Error::Subproblem do
61+
it 'supports keyword initialization' do
62+
sp = Acme::Client::Error::Subproblem.new(
63+
type: 'urn:ietf:params:acme:error:dns',
64+
detail: 'DNS lookup failed',
65+
identifier: { 'type' => 'dns', 'value' => 'example.com' }
66+
)
67+
68+
expect(sp.type).to eq('urn:ietf:params:acme:error:dns')
69+
expect(sp.detail).to eq('DNS lookup failed')
70+
expect(sp.identifier).to eq({ 'type' => 'dns', 'value' => 'example.com' })
71+
end
72+
73+
it 'supports to_h' do
74+
sp = Acme::Client::Error::Subproblem.new(
75+
type: 'urn:ietf:params:acme:error:dns',
76+
detail: 'DNS lookup failed',
77+
identifier: { 'type' => 'dns', 'value' => 'example.com' }
78+
)
79+
80+
expect(sp.to_h).to eq({
81+
type: 'urn:ietf:params:acme:error:dns',
82+
detail: 'DNS lookup failed',
83+
identifier: { 'type' => 'dns', 'value' => 'example.com' }
84+
})
85+
end
86+
87+
it 'handles nil fields' do
88+
sp = Acme::Client::Error::Subproblem.new(
89+
type: 'urn:ietf:params:acme:error:dns',
90+
detail: nil,
91+
identifier: nil
92+
)
93+
94+
expect(sp.type).to eq('urn:ietf:params:acme:error:dns')
95+
expect(sp.detail).to be_nil
96+
expect(sp.identifier).to be_nil
97+
end
98+
end
99+
100+
describe 'subproblems on error subclasses' do
101+
it 'works on Malformed errors (common multi-identifier parent)' do
102+
error = Acme::Client::Error::Malformed.new(
103+
'Some of the identifiers requested were rejected',
104+
subproblems: raw_subproblems
105+
)
106+
expect(error.subproblems.length).to eq(2)
107+
expect(error.subproblems.first.type).to eq('urn:ietf:params:acme:error:rejectedIdentifier')
108+
end
109+
110+
it 'works on Unauthorized errors' do
111+
error = Acme::Client::Error::Unauthorized.new('unauthorized', subproblems: raw_subproblems)
112+
expect(error.subproblems.length).to eq(2)
113+
end
114+
115+
it 'works on RateLimited with positional args preserved' do
116+
error = Acme::Client::Error::RateLimited.new('rate limited', 60, subproblems: raw_subproblems)
117+
expect(error.retry_after).to eq(60)
118+
expect(error.subproblems.length).to eq(2)
119+
end
120+
121+
it 'works on RateLimited with defaults' do
122+
error = Acme::Client::Error::RateLimited.new
123+
expect(error.retry_after).to eq(10)
124+
expect(error.subproblems).to eq([])
125+
end
126+
127+
it 'all ACME_ERRORS subclasses accept subproblems keyword' do
128+
Acme::Client::Error::ACME_ERRORS.each_value do |error_class|
129+
next if error_class == Acme::Client::Error::RateLimited
130+
131+
error = error_class.new('test', subproblems: raw_subproblems)
132+
expect(error.subproblems.length).to eq(2),
133+
"#{error_class} did not accept subproblems correctly"
134+
end
135+
end
136+
end
137+
138+
describe 'single subproblem' do
139+
it 'handles a response with one subproblem' do
140+
error = Acme::Client::Error::Malformed.new(
141+
'identifier rejected',
142+
subproblems: [raw_subproblems.first]
143+
)
144+
expect(error.subproblems.length).to eq(1)
145+
expect(error.subproblems.first.detail).to eq('This CA will not issue for "example.net"')
146+
end
147+
end
148+
end

0 commit comments

Comments
 (0)