Skip to content

Commit d0bac7a

Browse files
authored
Merge pull request #10 from userlist/error-handling
Improve error handling
2 parents 9dcf386 + 9d16e8a commit d0bac7a

12 files changed

Lines changed: 156 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
## Unreleased (main)
44

5-
- Updates ActiveJob Worker to retry on Timeout::Error with polynomially longer wait times, up to 10 attempts
5+
- Updates ActiveJob Worker to retry on errors with polynomially longer wait times, up to 10 attempts
6+
- Improve internal error handling to rely on exceptions
67

78
## v0.9.0 (2024-03-19)
89

lib/userlist.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class ConfigurationError < Error
1818
def initialize(key)
1919
@key = key.to_sym
2020

21-
super <<~MESSAGE
21+
super(<<~MESSAGE)
2222
Missing required configuration value for `#{key}`
2323
2424
Please set a value for `#{key}` using an environment variable:
@@ -34,6 +34,22 @@ def initialize(key)
3434
end
3535
end
3636

37+
class TimeoutError < Error; end
38+
39+
class RequestError < Error
40+
attr_reader :response
41+
42+
def initialize(response)
43+
super("Request failed with status #{response.code}: #{response.body}")
44+
45+
@response = response
46+
end
47+
48+
def status
49+
@response.code.to_i
50+
end
51+
end
52+
3753
class << self
3854
def config
3955
@config ||= Userlist::Config.new

lib/userlist/push/client.rb

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,35 @@ def http
4949
end
5050

5151
def request(method, path, payload = nil)
52+
request = build_request(method, path, payload)
53+
54+
logger.debug "Sending #{request.method} to #{URI.join(endpoint, request.path)} with body #{request.body}"
55+
56+
response = process_request(request)
57+
58+
logger.debug "Recieved #{response.code} #{response.message} with body #{response.body}"
59+
60+
handle_response(response)
61+
end
62+
63+
def build_request(method, path, payload)
5264
request = method.new(path)
5365
request['Accept'] = 'application/json'
5466
request['Authorization'] = "Push #{token}"
5567
request['Content-Type'] = 'application/json; charset=UTF-8'
5668
request.body = JSON.generate(payload) if payload
69+
request
70+
end
5771

58-
logger.debug "Sending #{request.method} to #{URI.join(endpoint, request.path)} with body #{request.body}"
59-
72+
def process_request(request)
6073
http.start unless http.started?
61-
response = http.request(request)
74+
http.request(request)
75+
rescue Timeout::Error => e
76+
raise Userlist::TimeoutError, e.message
77+
end
6278

63-
logger.debug "Recieved #{response.code} #{response.message} with body #{response.body}"
79+
def handle_response(response)
80+
raise(Userlist::RequestError, response) if response.code.to_i >= 400
6481

6582
response
6683
end

lib/userlist/push/strategies/active_job/worker.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class Push
55
module Strategies
66
class ActiveJob
77
class Worker < ::ActiveJob::Base
8-
retry_on Timeout::Error, wait: :polynomially_longer, attempts: 10
8+
retry_on Userlist::TimeoutError, Userlist::RequestError, wait: :polynomially_longer, attempts: 10
99

1010
def perform(method, *args)
1111
client = Userlist::Push::Client.new

lib/userlist/push/strategies/direct.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ def client
1919
end
2020

2121
def retryable
22-
@retryable ||= Userlist::Retryable.new do |response|
23-
status = response.code.to_i
24-
25-
status >= 500 || status == 429
26-
end
22+
@retryable ||= Userlist::Retryable.new
2723
end
2824
end
2925
end

lib/userlist/push/strategies/threaded/worker.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,7 @@ def client
4646
end
4747

4848
def retryable
49-
@retryable ||= Userlist::Retryable.new do |response|
50-
status = response.code.to_i
51-
52-
status >= 500 || status == 429
53-
end
49+
@retryable ||= Userlist::Retryable.new
5450
end
5551
end
5652
end

lib/userlist/retryable.rb

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,24 @@ class Retryable
77
MULTIPLIER = 2
88
MAX_DELAY = 10_000
99

10+
DEFAULT_RETRY_CHECK = lambda do |error|
11+
case error
12+
when Userlist::RequestError
13+
status = error.status
14+
status >= 500 || status == 429
15+
when Userlist::TimeoutError
16+
true
17+
else
18+
false
19+
end
20+
end
21+
1022
def initialize(retries: RETRIES, delay: DELAY, max_delay: MAX_DELAY, multiplier: MULTIPLIER, &retry_check)
1123
@retries = retries
1224
@delay = delay
1325
@max_delay = max_delay
1426
@multiplier = multiplier
15-
@retry_check = retry_check
27+
@retry_check = retry_check || DEFAULT_RETRY_CHECK
1628
end
1729

1830
def retry?(value)
@@ -21,15 +33,17 @@ def retry?(value)
2133

2234
def attempt
2335
(0..@retries).each do |attempt|
24-
if attempt.positive?
25-
milliseconds = delay(attempt)
26-
logger.debug "Retrying in #{milliseconds}ms, #{@retries - attempt} retries left"
27-
sleep(milliseconds / 1000.0)
36+
begin
37+
if attempt.positive?
38+
milliseconds = delay(attempt)
39+
logger.debug "Retrying in #{milliseconds}ms, #{@retries - attempt} retries left"
40+
sleep(milliseconds / 1000.0)
41+
end
42+
43+
return yield
44+
rescue Userlist::Error => e
45+
raise e unless retry?(e)
2846
end
29-
30-
result = yield
31-
32-
return result unless retry?(result)
3347
end
3448

3549
logger.debug 'Retries exhausted, giving up'

spec/userlist/push/client_spec.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@
6161

6262
subject.get('/events')
6363
end
64+
65+
it 'should raise an error when the request fails' do
66+
stub_request(:get, 'https://endpoint.test.local/events')
67+
.to_return(status: 500)
68+
69+
expect { subject.get('/events') }.to raise_error(Userlist::RequestError)
70+
end
71+
72+
it 'should raise a timeout error when the request times out' do
73+
stub_request(:get, 'https://endpoint.test.local/events')
74+
.to_timeout
75+
76+
expect { subject.get('/events') }.to raise_error(Userlist::TimeoutError)
77+
end
6478
end
6579

6680
describe '#post' do
@@ -80,6 +94,20 @@
8094

8195
subject.post('/events', payload)
8296
end
97+
98+
it 'should raise an error when the request fails' do
99+
stub_request(:post, 'https://endpoint.test.local/events')
100+
.to_return(status: 500)
101+
102+
expect { subject.post('/events') }.to raise_error(Userlist::RequestError)
103+
end
104+
105+
it 'should raise a timeout error when the request times out' do
106+
stub_request(:post, 'https://endpoint.test.local/events')
107+
.to_timeout
108+
109+
expect { subject.post('/events') }.to raise_error(Userlist::TimeoutError)
110+
end
83111
end
84112

85113
describe '#put' do
@@ -99,6 +127,20 @@
99127

100128
subject.put('/events', payload)
101129
end
130+
131+
it 'should raise an error when the request fails' do
132+
stub_request(:put, 'https://endpoint.test.local/events')
133+
.to_return(status: 500)
134+
135+
expect { subject.put('/events') }.to raise_error(Userlist::RequestError)
136+
end
137+
138+
it 'should raise a timeout error when the request times out' do
139+
stub_request(:put, 'https://endpoint.test.local/events')
140+
.to_timeout
141+
142+
expect { subject.put('/events') }.to raise_error(Userlist::TimeoutError)
143+
end
102144
end
103145

104146
describe '#delete' do
@@ -118,5 +160,19 @@
118160

119161
subject.delete('/events', payload)
120162
end
163+
164+
it 'should raise an error when the request fails' do
165+
stub_request(:delete, 'https://endpoint.test.local/events')
166+
.to_return(status: 500)
167+
168+
expect { subject.delete('/events') }.to raise_error(Userlist::RequestError)
169+
end
170+
171+
it 'should raise a timeout error when the request times out' do
172+
stub_request(:delete, 'https://endpoint.test.local/events')
173+
.to_timeout
174+
175+
expect { subject.delete('/events') }.to raise_error(Userlist::TimeoutError)
176+
end
121177
end
122178
end

spec/userlist/push/strategies/active_job/worker_spec.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,14 @@
3030
end
3131

3232
it 'should retry the job on failure' do
33-
allow(client).to receive(:post).and_raise(Timeout::Error)
33+
allow(client).to receive(:post).and_raise(Userlist::RequestError.new(double(code: '500', body: 'Internal Error')))
34+
35+
expect { described_class.perform_now('post', '/events', payload) }
36+
.to change(described_class.queue_adapter.enqueued_jobs, :size).by(1)
37+
end
38+
39+
it 'should retry the job on timeouts' do
40+
allow(client).to receive(:post).and_raise(Userlist::TimeoutError)
3441

3542
expect { described_class.perform_now('post', '/events', payload) }
3643
.to change(described_class.queue_adapter.enqueued_jobs, :size).by(1)

spec/userlist/push/strategies/direct_spec.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,16 @@
3232
it 'should retry failed responses' do
3333
payload = { foo: :bar }
3434

35-
expect(client).to receive(:post) { double(code: '500') }.exactly(11).times
35+
expect(client).to receive(:post) { raise Userlist::RequestError, double(code: '500', body: 'Internal Error') }.exactly(11).times
36+
expect_any_instance_of(Userlist::Retryable).to receive(:sleep).exactly(10).times
37+
38+
subject.call(:post, '/users', payload)
39+
end
40+
41+
it 'should retry on timeouts' do
42+
payload = { foo: :bar }
43+
44+
expect(client).to receive(:post) { raise Userlist::TimeoutError }.exactly(11).times
3645
expect_any_instance_of(Userlist::Retryable).to receive(:sleep).exactly(10).times
3746

3847
subject.call(:post, '/users', payload)

0 commit comments

Comments
 (0)