chronos-ruby 0.1.0.pre.2 → 0.3.0.pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +51 -21
- data/docs/adr/ADR-005-sanitize-before-backlog.md +2 -2
- data/docs/adr/ADR-011-bounded-resilience-and-remote-control.md +27 -0
- data/docs/architecture.md +5 -4
- data/docs/compatibility.md +6 -6
- data/docs/configuration.md +27 -1
- data/docs/data-collected.md +10 -7
- data/docs/examples/plain-ruby.md +14 -0
- data/docs/modules/async-queue.md +6 -2
- data/docs/modules/notice-pipeline.md +6 -3
- data/docs/modules/remote-configuration.md +54 -0
- data/docs/modules/retry-backlog.md +60 -0
- data/docs/modules/sanitization.md +19 -0
- data/docs/modules/serialization.md +9 -0
- data/docs/modules/transport.md +5 -3
- data/docs/performance.md +39 -2
- data/docs/privacy-lgpd.md +80 -10
- data/docs/troubleshooting.md +2 -2
- data/lib/chronos/adapters/net_http_transport.rb +21 -3
- data/lib/chronos/agent.rb +16 -8
- data/lib/chronos/application/capture_exception.rb +20 -14
- data/lib/chronos/application/circuit_breaker.rb +70 -0
- data/lib/chronos/application/delivery_pipeline.rb +248 -0
- data/lib/chronos/application/remote_configuration.rb +173 -0
- data/lib/chronos/application/retry_policy.rb +57 -0
- data/lib/chronos/configuration.rb +178 -21
- data/lib/chronos/core/notice.rb +1 -1
- data/lib/chronos/core/payload_serializer.rb +39 -89
- data/lib/chronos/core/runtime_info.rb +1 -1
- data/lib/chronos/core/safe_serializer.rb +119 -0
- data/lib/chronos/core/sanitizer.rb +161 -0
- data/lib/chronos/core/sensitive_value_filter.rb +118 -0
- data/lib/chronos/internal/bounded_queue.rb +1 -1
- data/lib/chronos/internal/memory_backlog.rb +65 -0
- data/lib/chronos/internal/worker_pool.rb +5 -6
- data/lib/chronos/ports/transport.rb +4 -3
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +9 -1
- metadata +30 -11
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Application
|
|
3
|
+
# Coordinates bounded queueing, retry, backlog, circuit state, and delivery.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Move serialized events through explicit delivery states.
|
|
6
|
+
# @motivation Keep resilience policy out of transports, workers, and the public facade.
|
|
7
|
+
# @limits It stores only sanitized SerializedEvent objects and never writes to disk.
|
|
8
|
+
# @collaborators RetryPolicy, CircuitBreaker, RemoteConfiguration, WorkerPool, and Transport.
|
|
9
|
+
# @thread_safety Mutable state, backlog, queue, and circuit transitions are synchronized.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6; independent of frameworks.
|
|
11
|
+
# @example
|
|
12
|
+
# pipeline.enqueue(serialized_event)
|
|
13
|
+
# pipeline.flush(2.0)
|
|
14
|
+
# @errors Transport and policy failures are contained and diagnosed.
|
|
15
|
+
# @performance Queue, retry count, backlog, and state counters are strictly bounded.
|
|
16
|
+
class DeliveryPipeline
|
|
17
|
+
STATES = [:accepted, :queued, :serialized, :sent, :retried, :dropped, :rejected].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :remote_configuration
|
|
20
|
+
|
|
21
|
+
def initialize(config, transport, logger = nil, options = {})
|
|
22
|
+
@config = config
|
|
23
|
+
@transport = transport
|
|
24
|
+
@logger = logger || Internal::SafeLogger.new(config.logger)
|
|
25
|
+
initialize_resilience(options)
|
|
26
|
+
initialize_storage(options)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def enqueue(event)
|
|
30
|
+
record_pre_delivery(event)
|
|
31
|
+
if @worker_pool.enqueue(event)
|
|
32
|
+
transition(:queued)
|
|
33
|
+
true
|
|
34
|
+
else
|
|
35
|
+
transition(:dropped)
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => error
|
|
39
|
+
diagnose(error)
|
|
40
|
+
transition(:dropped)
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def deliver_sync(event)
|
|
45
|
+
record_pre_delivery(event)
|
|
46
|
+
result = deliver_with_backlog(event)
|
|
47
|
+
result && result.success?
|
|
48
|
+
rescue StandardError => error
|
|
49
|
+
diagnose(error)
|
|
50
|
+
store_in_backlog(event)
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# WorkerPool delivery entry point. Events were counted when accepted into the queue.
|
|
55
|
+
def send_event(event)
|
|
56
|
+
deliver_with_backlog(event)
|
|
57
|
+
rescue StandardError => error
|
|
58
|
+
diagnose(error)
|
|
59
|
+
store_in_backlog(event)
|
|
60
|
+
Ports::TransportResult.new(:network_error, :error => error.class.name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def capture_allowed?(event_type, fingerprint = nil)
|
|
64
|
+
@remote_configuration.capture?(event_type, fingerprint)
|
|
65
|
+
rescue StandardError => error
|
|
66
|
+
diagnose(error)
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def max_payload_size
|
|
71
|
+
@remote_configuration.max_payload_size
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def flush(timeout)
|
|
75
|
+
@worker_pool.flush(timeout)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def close(timeout)
|
|
79
|
+
return true if closed?
|
|
80
|
+
|
|
81
|
+
@state_mutex.synchronize { @closed = true }
|
|
82
|
+
flushed = @worker_pool.close(timeout)
|
|
83
|
+
@transport.close
|
|
84
|
+
flushed
|
|
85
|
+
rescue StandardError => error
|
|
86
|
+
diagnose(error)
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def diagnostics
|
|
91
|
+
{
|
|
92
|
+
:states => state_counts,
|
|
93
|
+
:queue => @queue.stats,
|
|
94
|
+
:backlog => @backlog.stats,
|
|
95
|
+
:circuit => @circuit_breaker.state,
|
|
96
|
+
:remote_configuration => @remote_configuration.to_h
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def initialize_resilience(options)
|
|
103
|
+
@clock = options[:clock] || method(:monotonic_time)
|
|
104
|
+
@sleeper = options[:sleeper] || proc { |delay| sleep(delay) }
|
|
105
|
+
@remote_configuration = options[:remote_configuration] || RemoteConfiguration.new(@config)
|
|
106
|
+
@retry_policy = options[:retry_policy] || build_retry_policy(options[:random])
|
|
107
|
+
@circuit_breaker = options[:circuit_breaker] || CircuitBreaker.new(
|
|
108
|
+
@config.circuit_failure_threshold,
|
|
109
|
+
@config.circuit_reset_timeout,
|
|
110
|
+
@clock
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def initialize_storage(options)
|
|
115
|
+
@backlog = options[:backlog] || Internal::MemoryBacklog.new(@config.backlog_size)
|
|
116
|
+
@queue = options[:queue] || Internal::BoundedQueue.new(@config.queue_size)
|
|
117
|
+
@state_mutex = Mutex.new
|
|
118
|
+
@states = STATES.each_with_object({}) { |state, counts| counts[state] = 0 }
|
|
119
|
+
@send_mutex = Mutex.new
|
|
120
|
+
@last_send_at = nil
|
|
121
|
+
@closed = false
|
|
122
|
+
@worker_pool = options[:worker_pool] || Internal::WorkerPool.new(@queue, self, @config.workers, @logger)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_retry_policy(random)
|
|
126
|
+
RetryPolicy.new(
|
|
127
|
+
:max_retries => @config.max_retries,
|
|
128
|
+
:base_interval => @config.retry_base_interval,
|
|
129
|
+
:max_interval => @config.retry_max_interval,
|
|
130
|
+
:jitter => @config.retry_jitter,
|
|
131
|
+
:random => random
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def record_pre_delivery(event)
|
|
136
|
+
raise ArgumentError, "delivery requires a SerializedEvent" unless event.is_a?(Core::SerializedEvent)
|
|
137
|
+
raise Error, "delivery pipeline is closed" if closed?
|
|
138
|
+
|
|
139
|
+
transition(:accepted)
|
|
140
|
+
transition(:serialized)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def deliver_with_backlog(event)
|
|
144
|
+
drain_one_backlog unless @backlog.empty?
|
|
145
|
+
attempt_delivery(event)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def drain_one_backlog
|
|
149
|
+
event = @backlog.shift
|
|
150
|
+
attempt_delivery(event) if event
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def attempt_delivery(event)
|
|
154
|
+
unless @circuit_breaker.allow_request?
|
|
155
|
+
transition(:retried)
|
|
156
|
+
store_in_backlog(event)
|
|
157
|
+
return Ports::TransportResult.new(:circuit_open)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
retries = 0
|
|
161
|
+
loop do
|
|
162
|
+
wait_for_send_interval
|
|
163
|
+
result = @transport.send_event(event)
|
|
164
|
+
return finish_success(result) if result.success?
|
|
165
|
+
return finish_permanent_failure(result) unless result.retryable?
|
|
166
|
+
|
|
167
|
+
@circuit_breaker.record_failure
|
|
168
|
+
unless @retry_policy.retry?(result, retries) && @circuit_breaker.allow_request?
|
|
169
|
+
store_in_backlog(event)
|
|
170
|
+
return result
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
retries += 1
|
|
174
|
+
transition(:retried)
|
|
175
|
+
@sleeper.call(@retry_policy.delay(retries, result))
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def finish_success(result)
|
|
180
|
+
@circuit_breaker.record_success
|
|
181
|
+
apply_remote_configuration(result)
|
|
182
|
+
transition(:sent)
|
|
183
|
+
result
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def finish_permanent_failure(result)
|
|
187
|
+
@circuit_breaker.record_success
|
|
188
|
+
transition(result.status == :client_error ? :rejected : :dropped)
|
|
189
|
+
result
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def store_in_backlog(event)
|
|
193
|
+
accepted = @backlog.push(event)
|
|
194
|
+
transition(:dropped) unless accepted
|
|
195
|
+
accepted
|
|
196
|
+
rescue StandardError => error
|
|
197
|
+
diagnose(error)
|
|
198
|
+
transition(:dropped)
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def apply_remote_configuration(result)
|
|
203
|
+
values = result.remote_configuration
|
|
204
|
+
return unless values && @config.remote_configuration
|
|
205
|
+
|
|
206
|
+
@remote_configuration.apply(values)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def wait_for_send_interval
|
|
210
|
+
interval = @remote_configuration.send_interval
|
|
211
|
+
return if interval <= 0.0
|
|
212
|
+
|
|
213
|
+
@send_mutex.synchronize do
|
|
214
|
+
now = @clock.call
|
|
215
|
+
if @last_send_at
|
|
216
|
+
remaining = interval - (now - @last_send_at)
|
|
217
|
+
@sleeper.call(remaining) if remaining > 0.0
|
|
218
|
+
end
|
|
219
|
+
@last_send_at = @clock.call
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def transition(state)
|
|
224
|
+
@state_mutex.synchronize { @states[state] += 1 }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def state_counts
|
|
228
|
+
@state_mutex.synchronize { @states.dup }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def closed?
|
|
232
|
+
@state_mutex.synchronize { @closed }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def diagnose(error)
|
|
236
|
+
@logger.warn("Chronos delivery failed: #{error.class}")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def monotonic_time
|
|
240
|
+
if Process.respond_to?(:clock_gettime) && defined?(Process::CLOCK_MONOTONIC)
|
|
241
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
242
|
+
else
|
|
243
|
+
Time.now.to_f
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Application
|
|
3
|
+
# Runtime policy restricted to server-authorized scalar options.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Validate and apply bounded remote delivery controls.
|
|
6
|
+
# @motivation Let operators reduce telemetry safely without redeploying an application.
|
|
7
|
+
# @limits It cannot change host, credentials, TLS, code, or regular expressions.
|
|
8
|
+
# @collaborators Configuration snapshot, CaptureException, and DeliveryPipeline.
|
|
9
|
+
# @thread_safety A mutex protects immutable state replacements.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
11
|
+
# @example
|
|
12
|
+
# remote.apply("sampling_rate" => 0.5, "kill_switch" => false)
|
|
13
|
+
# @errors Invalid documents are ignored and return false.
|
|
14
|
+
# @performance Validation is bounded by configured document and list limits.
|
|
15
|
+
class RemoteConfiguration
|
|
16
|
+
SUPPORTED_EVENT_TYPES = ["exception"].freeze
|
|
17
|
+
MAX_IGNORED_FINGERPRINTS = 100
|
|
18
|
+
MAX_FINGERPRINT_BYTES = 256
|
|
19
|
+
MIN_PAYLOAD_SIZE = 256
|
|
20
|
+
|
|
21
|
+
attr_reader :ignored_fingerprints
|
|
22
|
+
|
|
23
|
+
def initialize(config, options = {})
|
|
24
|
+
@config = config
|
|
25
|
+
@random = options[:random] || proc { rand }
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
@local_event_types = allowed_event_types(config.enabled_event_types)
|
|
28
|
+
@values = {
|
|
29
|
+
"sampling_rate" => config.sampling_rate.to_f,
|
|
30
|
+
"enabled_event_types" => @local_event_types,
|
|
31
|
+
"max_payload_size" => config.max_payload_size,
|
|
32
|
+
"ignored_fingerprints" => [],
|
|
33
|
+
"send_interval" => 0.0,
|
|
34
|
+
"kill_switch" => false
|
|
35
|
+
}
|
|
36
|
+
@ignored_fingerprints = [].freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def apply(document)
|
|
40
|
+
normalized = normalize(document)
|
|
41
|
+
return false unless normalized
|
|
42
|
+
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
@values = @values.merge(normalized)
|
|
45
|
+
@ignored_fingerprints = @values["ignored_fingerprints"].dup.freeze
|
|
46
|
+
end
|
|
47
|
+
true
|
|
48
|
+
rescue StandardError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def capture?(event_type, fingerprint = nil)
|
|
53
|
+
values = snapshot
|
|
54
|
+
return false if values["kill_switch"]
|
|
55
|
+
return false unless values["enabled_event_types"].include?(event_type.to_s)
|
|
56
|
+
return false if fingerprint && values["ignored_fingerprints"].include?(fingerprint.to_s)
|
|
57
|
+
|
|
58
|
+
rate = values["sampling_rate"]
|
|
59
|
+
rate >= 1.0 || (rate > 0.0 && @random.call.to_f < rate)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def enabled_event?(event_type)
|
|
63
|
+
snapshot["enabled_event_types"].include?(event_type.to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ignored_fingerprint?(fingerprint)
|
|
67
|
+
snapshot["ignored_fingerprints"].include?(fingerprint.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sampling_rate
|
|
71
|
+
snapshot["sampling_rate"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def max_payload_size
|
|
75
|
+
snapshot["max_payload_size"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def send_interval
|
|
79
|
+
snapshot["send_interval"]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def kill_switch?
|
|
83
|
+
snapshot["kill_switch"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_h
|
|
87
|
+
snapshot
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def normalize(document)
|
|
93
|
+
return nil unless document.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
normalized = {}
|
|
96
|
+
document.each do |key, value|
|
|
97
|
+
name = key.to_s
|
|
98
|
+
next unless @values.key?(name)
|
|
99
|
+
|
|
100
|
+
normalized_value = normalize_value(name, value)
|
|
101
|
+
return nil if normalized_value.nil? && value != false
|
|
102
|
+
normalized[name] = normalized_value
|
|
103
|
+
end
|
|
104
|
+
normalized.empty? ? nil : normalized
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def normalize_value(name, value)
|
|
108
|
+
case name
|
|
109
|
+
when "sampling_rate"
|
|
110
|
+
bounded_rate(value)
|
|
111
|
+
when "enabled_event_types"
|
|
112
|
+
normalize_event_types(value)
|
|
113
|
+
when "max_payload_size"
|
|
114
|
+
normalize_payload_size(value)
|
|
115
|
+
when "ignored_fingerprints"
|
|
116
|
+
normalize_fingerprints(value)
|
|
117
|
+
when "send_interval"
|
|
118
|
+
normalize_send_interval(value)
|
|
119
|
+
when "kill_switch"
|
|
120
|
+
value if [true, false].include?(value)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def bounded_rate(value)
|
|
125
|
+
return nil unless value.is_a?(Numeric) && value >= 0.0 && value <= @config.sampling_rate
|
|
126
|
+
|
|
127
|
+
value.to_f
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def normalize_event_types(value)
|
|
131
|
+
return nil unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) }
|
|
132
|
+
|
|
133
|
+
allowed_event_types(value)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def allowed_event_types(values)
|
|
137
|
+
supported = values.map(&:to_s).select { |value| SUPPORTED_EVENT_TYPES.include?(value) }
|
|
138
|
+
supported.select { |value| local_event_types.include?(value) }.uniq.freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def local_event_types
|
|
142
|
+
@local_event_types || SUPPORTED_EVENT_TYPES
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def normalize_payload_size(value)
|
|
146
|
+
return nil unless value.is_a?(Integer) && value >= MIN_PAYLOAD_SIZE && value <= @config.max_payload_size
|
|
147
|
+
|
|
148
|
+
value
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def normalize_fingerprints(value)
|
|
152
|
+
return nil unless value.is_a?(Array) && value.length <= MAX_IGNORED_FINGERPRINTS
|
|
153
|
+
return nil unless value.all? { |item| item.is_a?(String) && item.bytesize <= MAX_FINGERPRINT_BYTES }
|
|
154
|
+
|
|
155
|
+
value.map(&:dup).freeze
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def normalize_send_interval(value)
|
|
159
|
+
return nil unless value.is_a?(Numeric) && value >= 0.0 && value <= @config.max_remote_send_interval
|
|
160
|
+
|
|
161
|
+
value.to_f
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def snapshot
|
|
165
|
+
@mutex.synchronize do
|
|
166
|
+
@values.each_with_object({}) do |(key, value), copy|
|
|
167
|
+
copy[key] = value.is_a?(Array) ? value.dup : value
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Chronos
|
|
4
|
+
module Application
|
|
5
|
+
# Bounded retry timing policy with exponential backoff and jitter.
|
|
6
|
+
#
|
|
7
|
+
# @responsibility Decide whether and when a retry may occur.
|
|
8
|
+
# @motivation Centralize retry limits independently from HTTP and worker code.
|
|
9
|
+
# @limits It does not sleep, send events, or retain payloads.
|
|
10
|
+
# @collaborators TransportResult and DeliveryPipeline.
|
|
11
|
+
# @thread_safety Immutable after construction when the random callable is thread-safe.
|
|
12
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
13
|
+
# @example
|
|
14
|
+
# policy.delay(2, result)
|
|
15
|
+
# @errors Invalid configuration is rejected by Configuration.
|
|
16
|
+
# @performance Constant time per decision.
|
|
17
|
+
class RetryPolicy
|
|
18
|
+
def initialize(options)
|
|
19
|
+
@max_retries = options[:max_retries]
|
|
20
|
+
@base_interval = options[:base_interval]
|
|
21
|
+
@max_interval = options[:max_interval]
|
|
22
|
+
@jitter = options[:jitter]
|
|
23
|
+
@random = options[:random] || proc { rand }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def retry?(result, retries)
|
|
27
|
+
result.retryable? && retries < @max_retries
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delay(retry_number, result = nil)
|
|
31
|
+
retry_after = retry_after_seconds(result)
|
|
32
|
+
return [retry_after, @max_interval].min if retry_after
|
|
33
|
+
|
|
34
|
+
base = @base_interval * (2**(retry_number - 1))
|
|
35
|
+
jittered = base * (1.0 + (@random.call.to_f * @jitter))
|
|
36
|
+
[jittered, @max_interval].min
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def retry_after_seconds(result)
|
|
42
|
+
return nil unless result && result.retry_after
|
|
43
|
+
|
|
44
|
+
value = result.retry_after.to_s
|
|
45
|
+
seconds = Float(value)
|
|
46
|
+
seconds >= 0 ? seconds : nil
|
|
47
|
+
rescue ArgumentError, TypeError
|
|
48
|
+
begin
|
|
49
|
+
seconds = Time.httpdate(value) - Time.now
|
|
50
|
+
seconds > 0 ? seconds : nil
|
|
51
|
+
rescue ArgumentError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|