Class: Hook0::RetryPolicy

Inherits:
Object
  • Object
show all
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_attempts from 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

Class Method Summary collapse

Instance Method Summary collapse

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.

Parameters:

  • max_attempts (Integer) (defaults to: 4)

    1 disables retrying

  • initial_backoff (Float) (defaults to: DEFAULT_INITIAL_BACKOFF)
  • max_backoff (Float) (defaults to: DEFAULT_MAX_BACKOFF)
  • max_total_delay (Float) (defaults to: DEFAULT_MAX_TOTAL_DELAY)


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_backoffFloat (readonly)

Returns ceiling of the delay before the first retry, in seconds.

Returns:

  • (Float)

    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_attemptsInteger (readonly)

Returns attempts a single send makes at most, the first one included.

Returns:

  • (Integer)

    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_backoffFloat (readonly)

Returns ceiling no single delay ever exceeds, in seconds.

Returns:

  • (Float)

    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_delayFloat (readonly)

Returns budget all the delays of one send share, in seconds.

Returns:

  • (Float)

    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

.disabledRetryPolicy

A policy that never retries: one attempt, and the caller hears what it answered.

Returns:



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.

Parameters:

  • draws (Array<Float>)
  • index (Integer)

Returns:

  • (Float)


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.

Parameters:

  • seconds (Numeric)
  • fallback (Float)

Returns:

  • (Float)


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

#attemptsInteger

Attempts this policy actually makes: #max_attempts, brought inside 1..MAX_ATTEMPTS_CAP.

Returns:

  • (Integer)


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.

Parameters:

  • retry_number (Integer)

Returns:

  • (Float)


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.

Parameters:

  • draws (Array<Float>)

    one draw in [0, 1) per retry

Returns:

  • (Array<Float>)


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_forceFloat

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.

Returns:

  • (Float)


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_forceFloat

The ceiling no single delay of this policy exceeds, in seconds.

Returns:

  • (Float)


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_forceFloat

The budget all the delays of one send share, in seconds.

Returns:

  • (Float)


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