Class: Hook0::RetryPolicy
- Inherits:
-
Object
- Object
- Hook0::RetryPolicy
- Defined in:
- lib/hook0/client.rb
Overview
How a client spaces out the attempts of a single send.
The delay before a retry doubles from #initial_backoff and is capped by #max_backoff; the delay actually waited is then drawn anywhere between zero and that ceiling, so that emitters which failed at the same moment do not come back at the same moment. Retrying stops as soon as the delays of the send would add up to more than #max_total_delay.
The defaults are four attempts spread over at most five seconds: three retries absorb the blips a webhook emitter meets in production — a connection reset, a rolling deployment answering 503 — without holding the caller for long, and the five-second budget bounds what the worst send costs whatever the individual delays turn out to be.
Constant Summary collapse
- MAX_ATTEMPTS_CAP =
Most attempts a policy can ever make, whatever #max_attempts says.
A policy is configuration, and configuration can be wrong; this cap keeps a mistyped
max_attemptsfrom turning one send into an unbounded series of requests. 16- MAX_BACKOFF_DOUBLINGS =
Beyond this many doublings any backoff has long since reached its ceiling.
30- DEFAULT_INITIAL_BACKOFF =
What each duration of a policy is where a caller named none, in seconds.
Declared here rather than written into the signature below, because they are also what a duration falls back to when a caller names one no schedule could be built on: a fallback spelled out a second time is one that will disagree with the default the first time either moves.
0.1- DEFAULT_MAX_BACKOFF =
2.0- DEFAULT_MAX_TOTAL_DELAY =
5.0
Instance Attribute Summary collapse
-
#initial_backoff ⇒ Float
readonly
Ceiling of the delay before the first retry, in seconds.
-
#max_attempts ⇒ Integer
readonly
Attempts a single send makes at most, the first one included.
-
#max_backoff ⇒ Float
readonly
Ceiling no single delay ever exceeds, in seconds.
-
#max_total_delay ⇒ Float
readonly
Budget all the delays of one send share, in seconds.
Class Method Summary collapse
-
.disabled ⇒ RetryPolicy
A policy that never retries: one attempt, and the caller hears what it answered.
-
.draw(draws, index) ⇒ Float
The draw for one retry, brought back inside
[0, 1]whatever the randomness gave. -
.in_force(seconds, fallback) ⇒ Float
A number of seconds a caller set, brought back to something a schedule can be built on.
Instance Method Summary collapse
-
#attempts ⇒ Integer
Attempts this policy actually makes: #max_attempts, brought inside
1..MAX_ATTEMPTS_CAP. -
#backoff_ceiling(retry_number) ⇒ Float
Ceiling of the delay before retry number
retry_number, where1is the first retry. -
#delays(draws) ⇒ Array<Float>
The delays this policy waits between the attempts of one send, one per retry.
-
#initial_backoff_in_force ⇒ Float
The delay before the first retry this policy is in force with, in seconds.
-
#initialize(max_attempts: 4, initial_backoff: DEFAULT_INITIAL_BACKOFF, max_backoff: DEFAULT_MAX_BACKOFF, max_total_delay: DEFAULT_MAX_TOTAL_DELAY) ⇒ RetryPolicy
constructor
A new instance of RetryPolicy.
-
#max_backoff_in_force ⇒ Float
The ceiling no single delay of this policy exceeds, in seconds.
-
#max_total_delay_in_force ⇒ Float
The budget all the delays of one send share, in seconds.
Constructor Details
#initialize(max_attempts: 4, initial_backoff: DEFAULT_INITIAL_BACKOFF, max_backoff: DEFAULT_MAX_BACKOFF, max_total_delay: DEFAULT_MAX_TOTAL_DELAY) ⇒ RetryPolicy
Returns a new instance of RetryPolicy.
59 60 61 62 63 64 65 66 |
# File 'lib/hook0/client.rb', line 59 def initialize(max_attempts: 4, initial_backoff: DEFAULT_INITIAL_BACKOFF, max_backoff: DEFAULT_MAX_BACKOFF, max_total_delay: DEFAULT_MAX_TOTAL_DELAY) @max_attempts = max_attempts @initial_backoff = initial_backoff @max_backoff = max_backoff @max_total_delay = max_total_delay freeze end |
Instance Attribute Details
#initial_backoff ⇒ Float (readonly)
Returns ceiling of the delay before the first retry, in seconds.
37 38 39 |
# File 'lib/hook0/client.rb', line 37 def initial_backoff @initial_backoff end |
#max_attempts ⇒ Integer (readonly)
Returns attempts a single send makes at most, the first one included.
34 35 36 |
# File 'lib/hook0/client.rb', line 34 def max_attempts @max_attempts end |
#max_backoff ⇒ Float (readonly)
Returns ceiling no single delay ever exceeds, in seconds.
40 41 42 |
# File 'lib/hook0/client.rb', line 40 def max_backoff @max_backoff end |
#max_total_delay ⇒ Float (readonly)
Returns budget all the delays of one send share, in seconds.
43 44 45 |
# File 'lib/hook0/client.rb', line 43 def max_total_delay @max_total_delay end |
Class Method Details
.disabled ⇒ RetryPolicy
A policy that never retries: one attempt, and the caller hears what it answered.
71 72 73 |
# File 'lib/hook0/client.rb', line 71 def self.disabled new(max_attempts: 1, initial_backoff: 0.0, max_backoff: 0.0, max_total_delay: 0.0) end |
.draw(draws, index) ⇒ Float
The draw for one retry, brought back inside [0, 1] whatever the randomness gave.
175 176 177 178 179 180 181 |
# File 'lib/hook0/client.rb', line 175 def self.draw(draws, index) drawn = draws[index] return 1.0 unless drawn.is_a?(Numeric) return 1.0 unless drawn.to_f.finite? drawn.to_f.clamp(0.0, 1.0) end |
.in_force(seconds, fallback) ⇒ Float
A number of seconds a caller set, brought back to something a schedule can be built on.
A value that is not a finite number names no duration at all, and it is read as the one an unconfigured policy holds. Nothing is the tempting reading and the wrong one: a policy whose delays collapse to zero fires its whole schedule back to back, which is the burst a client states its policy so that an instance could recognise — it would manufacture the very traffic the header exists to explain. Unbounded is worse: a send that never comes back. The default is bounded, is what every client falls back to, and leaves the client behaving the way an unconfigured one does, which is what an unusable value should buy.
A negative number is a real duration somebody wrote rather than an unusable one, and keeps
being read as nothing. Float::NAN never reaches an ordering here, which is what used to
raise: it answers false to every comparison, so clamp and max refuse it.
136 137 138 139 140 141 |
# File 'lib/hook0/client.rb', line 136 def self.in_force(seconds, fallback) number = seconds.to_f return fallback unless number.finite? [number, 0.0].max end |
Instance Method Details
#attempts ⇒ Integer
Attempts this policy actually makes: #max_attempts, brought inside 1..MAX_ATTEMPTS_CAP.
78 79 80 |
# File 'lib/hook0/client.rb', line 78 def attempts @max_attempts.to_i.clamp(1, MAX_ATTEMPTS_CAP) end |
#backoff_ceiling(retry_number) ⇒ Float
Ceiling of the delay before retry number retry_number, where 1 is the first retry.
It doubles from #initial_backoff and never exceeds #max_backoff, so the ceilings of successive retries never decrease.
89 90 91 92 93 |
# File 'lib/hook0/client.rb', line 89 def backoff_ceiling(retry_number) doublings = (retry_number - 1).clamp(0, MAX_BACKOFF_DOUBLINGS) ceiling = max_backoff_in_force (initial_backoff_in_force * (2**doublings)).clamp(0.0, ceiling) end |
#delays(draws) ⇒ Array<Float>
The delays this policy waits between the attempts of one send, one per retry.
Each delay lands between zero and the ceiling of its retry, and the schedule is cut short as
soon as the next delay would spend more than #max_total_delay. There are therefore at most
attempts - 1 delays, and they add up to at most max_total_delay.
A draw that is missing or is not a finite number is read as 1, which asks for the whole
ceiling: an unusable source of randomness makes the client wait longer, never less.
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/hook0/client.rb', line 154 def delays(draws) budget = max_total_delay_in_force waits = [] spent = 0.0 1.upto(attempts - 1) do |retry_number| delay = backoff_ceiling(retry_number) * self.class.draw(draws, retry_number - 1) break if spent + delay > budget spent += delay waits << delay end waits end |
#initial_backoff_in_force ⇒ Float
The delay before the first retry this policy is in force with, in seconds.
What a send waits and what a request states are both read from here, so the two cannot come to describe different policies.
101 102 103 |
# File 'lib/hook0/client.rb', line 101 def initial_backoff_in_force self.class.in_force(@initial_backoff, DEFAULT_INITIAL_BACKOFF) end |
#max_backoff_in_force ⇒ Float
The ceiling no single delay of this policy exceeds, in seconds.
108 109 110 |
# File 'lib/hook0/client.rb', line 108 def max_backoff_in_force self.class.in_force(@max_backoff, DEFAULT_MAX_BACKOFF) end |
#max_total_delay_in_force ⇒ Float
The budget all the delays of one send share, in seconds.
115 116 117 |
# File 'lib/hook0/client.rb', line 115 def max_total_delay_in_force self.class.in_force(@max_total_delay, DEFAULT_MAX_TOTAL_DELAY) end |