hook0-client 2.0.0
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 +7 -0
- data/README.md +235 -0
- data/assets/ruby-flow.svg +153 -0
- data/lib/hook0/client.rb +691 -0
- data/lib/hook0/errors.rb +78 -0
- data/lib/hook0/generated/all.rb +7 -0
- data/lib/hook0/generated/api.rb +1097 -0
- data/lib/hook0/generated/errors.rb +235 -0
- data/lib/hook0/generated/models.rb +3093 -0
- data/lib/hook0/runtime.rb +299 -0
- data/lib/hook0/signature.rb +294 -0
- data/lib/hook0/transport.rb +359 -0
- data/lib/hook0/version.rb +6 -0
- data/lib/hook0.rb +23 -0
- metadata +90 -0
data/lib/hook0/client.rb
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "transport"
|
|
9
|
+
|
|
10
|
+
# Sending events to Hook0, idempotently and under bounds the caller sets.
|
|
11
|
+
module Hook0
|
|
12
|
+
# How a client spaces out the attempts of a single send.
|
|
13
|
+
#
|
|
14
|
+
# The delay before a retry doubles from {#initial_backoff} and is capped by {#max_backoff}; the
|
|
15
|
+
# delay actually waited is then drawn anywhere between zero and that ceiling, so that emitters
|
|
16
|
+
# which failed at the same moment do not come back at the same moment. Retrying stops as soon as
|
|
17
|
+
# the delays of the send would add up to more than {#max_total_delay}.
|
|
18
|
+
#
|
|
19
|
+
# The defaults are four attempts spread over at most five seconds: three retries absorb the blips
|
|
20
|
+
# a webhook emitter meets in production — a connection reset, a rolling deployment answering 503 —
|
|
21
|
+
# without holding the caller for long, and the five-second budget bounds what the worst send costs
|
|
22
|
+
# whatever the individual delays turn out to be.
|
|
23
|
+
class RetryPolicy
|
|
24
|
+
# Most attempts a policy can ever make, whatever {#max_attempts} says.
|
|
25
|
+
#
|
|
26
|
+
# A policy is configuration, and configuration can be wrong; this cap keeps a mistyped
|
|
27
|
+
# `max_attempts` from turning one send into an unbounded series of requests.
|
|
28
|
+
MAX_ATTEMPTS_CAP = 16
|
|
29
|
+
|
|
30
|
+
# Beyond this many doublings any backoff has long since reached its ceiling.
|
|
31
|
+
MAX_BACKOFF_DOUBLINGS = 30
|
|
32
|
+
|
|
33
|
+
# @return [Integer] attempts a single send makes at most, the first one included
|
|
34
|
+
attr_reader :max_attempts
|
|
35
|
+
|
|
36
|
+
# @return [Float] ceiling of the delay before the first retry, in seconds
|
|
37
|
+
attr_reader :initial_backoff
|
|
38
|
+
|
|
39
|
+
# @return [Float] ceiling no single delay ever exceeds, in seconds
|
|
40
|
+
attr_reader :max_backoff
|
|
41
|
+
|
|
42
|
+
# @return [Float] budget all the delays of one send share, in seconds
|
|
43
|
+
attr_reader :max_total_delay
|
|
44
|
+
|
|
45
|
+
# What each duration of a policy is where a caller named none, in seconds.
|
|
46
|
+
#
|
|
47
|
+
# Declared here rather than written into the signature below, because they are also what a
|
|
48
|
+
# duration falls back to when a caller names one no schedule could be built on: a fallback
|
|
49
|
+
# spelled out a second time is one that will disagree with the default the first time either
|
|
50
|
+
# moves.
|
|
51
|
+
DEFAULT_INITIAL_BACKOFF = 0.1
|
|
52
|
+
DEFAULT_MAX_BACKOFF = 2.0
|
|
53
|
+
DEFAULT_MAX_TOTAL_DELAY = 5.0
|
|
54
|
+
|
|
55
|
+
# @param max_attempts [Integer] `1` disables retrying
|
|
56
|
+
# @param initial_backoff [Float]
|
|
57
|
+
# @param max_backoff [Float]
|
|
58
|
+
# @param max_total_delay [Float]
|
|
59
|
+
def initialize(max_attempts: 4, initial_backoff: DEFAULT_INITIAL_BACKOFF,
|
|
60
|
+
max_backoff: DEFAULT_MAX_BACKOFF, max_total_delay: DEFAULT_MAX_TOTAL_DELAY)
|
|
61
|
+
@max_attempts = max_attempts
|
|
62
|
+
@initial_backoff = initial_backoff
|
|
63
|
+
@max_backoff = max_backoff
|
|
64
|
+
@max_total_delay = max_total_delay
|
|
65
|
+
freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A policy that never retries: one attempt, and the caller hears what it answered.
|
|
69
|
+
#
|
|
70
|
+
# @return [RetryPolicy]
|
|
71
|
+
def self.disabled
|
|
72
|
+
new(max_attempts: 1, initial_backoff: 0.0, max_backoff: 0.0, max_total_delay: 0.0)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Attempts this policy actually makes: {#max_attempts}, brought inside `1..MAX_ATTEMPTS_CAP`.
|
|
76
|
+
#
|
|
77
|
+
# @return [Integer]
|
|
78
|
+
def attempts
|
|
79
|
+
@max_attempts.to_i.clamp(1, MAX_ATTEMPTS_CAP)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Ceiling of the delay before retry number `retry_number`, where `1` is the first retry.
|
|
83
|
+
#
|
|
84
|
+
# It doubles from {#initial_backoff} and never exceeds {#max_backoff}, so the ceilings of
|
|
85
|
+
# successive retries never decrease.
|
|
86
|
+
#
|
|
87
|
+
# @param retry_number [Integer]
|
|
88
|
+
# @return [Float]
|
|
89
|
+
def backoff_ceiling(retry_number)
|
|
90
|
+
doublings = (retry_number - 1).clamp(0, MAX_BACKOFF_DOUBLINGS)
|
|
91
|
+
ceiling = max_backoff_in_force
|
|
92
|
+
(initial_backoff_in_force * (2**doublings)).clamp(0.0, ceiling)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The delay before the first retry this policy is in force with, in seconds.
|
|
96
|
+
#
|
|
97
|
+
# What a send waits and what a request states are both read from here, so the two cannot come to
|
|
98
|
+
# describe different policies.
|
|
99
|
+
#
|
|
100
|
+
# @return [Float]
|
|
101
|
+
def initial_backoff_in_force
|
|
102
|
+
self.class.in_force(@initial_backoff, DEFAULT_INITIAL_BACKOFF)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The ceiling no single delay of this policy exceeds, in seconds.
|
|
106
|
+
#
|
|
107
|
+
# @return [Float]
|
|
108
|
+
def max_backoff_in_force
|
|
109
|
+
self.class.in_force(@max_backoff, DEFAULT_MAX_BACKOFF)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The budget all the delays of one send share, in seconds.
|
|
113
|
+
#
|
|
114
|
+
# @return [Float]
|
|
115
|
+
def max_total_delay_in_force
|
|
116
|
+
self.class.in_force(@max_total_delay, DEFAULT_MAX_TOTAL_DELAY)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A number of seconds a caller set, brought back to something a schedule can be built on.
|
|
120
|
+
#
|
|
121
|
+
# A value that is not a finite number names no duration at all, and it is read as the one an
|
|
122
|
+
# unconfigured policy holds. Nothing is the tempting reading and the wrong one: a policy whose
|
|
123
|
+
# delays collapse to zero fires its whole schedule back to back, which is the burst a client
|
|
124
|
+
# states its policy so that an instance could recognise — it would manufacture the very traffic
|
|
125
|
+
# the header exists to explain. Unbounded is worse: a send that never comes back. The default is
|
|
126
|
+
# bounded, is what every client falls back to, and leaves the client behaving the way an
|
|
127
|
+
# unconfigured one does, which is what an unusable value should buy.
|
|
128
|
+
#
|
|
129
|
+
# A negative number is a real duration somebody wrote rather than an unusable one, and keeps
|
|
130
|
+
# being read as nothing. `Float::NAN` never reaches an ordering here, which is what used to
|
|
131
|
+
# raise: it answers false to every comparison, so `clamp` and `max` refuse it.
|
|
132
|
+
#
|
|
133
|
+
# @param seconds [Numeric]
|
|
134
|
+
# @param fallback [Float]
|
|
135
|
+
# @return [Float]
|
|
136
|
+
def self.in_force(seconds, fallback)
|
|
137
|
+
number = seconds.to_f
|
|
138
|
+
return fallback unless number.finite?
|
|
139
|
+
|
|
140
|
+
[number, 0.0].max
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The delays this policy waits between the attempts of one send, one per retry.
|
|
144
|
+
#
|
|
145
|
+
# Each delay lands between zero and the ceiling of its retry, and the schedule is cut short as
|
|
146
|
+
# soon as the next delay would spend more than {#max_total_delay}. There are therefore at most
|
|
147
|
+
# `attempts - 1` delays, and they add up to at most `max_total_delay`.
|
|
148
|
+
#
|
|
149
|
+
# A draw that is missing or is not a finite number is read as `1`, which asks for the whole
|
|
150
|
+
# ceiling: an unusable source of randomness makes the client wait longer, never less.
|
|
151
|
+
#
|
|
152
|
+
# @param draws [Array<Float>] one draw in `[0, 1)` per retry
|
|
153
|
+
# @return [Array<Float>]
|
|
154
|
+
def delays(draws)
|
|
155
|
+
budget = max_total_delay_in_force
|
|
156
|
+
waits = []
|
|
157
|
+
spent = 0.0
|
|
158
|
+
|
|
159
|
+
1.upto(attempts - 1) do |retry_number|
|
|
160
|
+
delay = backoff_ceiling(retry_number) * self.class.draw(draws, retry_number - 1)
|
|
161
|
+
break if spent + delay > budget
|
|
162
|
+
|
|
163
|
+
spent += delay
|
|
164
|
+
waits << delay
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
waits
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The draw for one retry, brought back inside `[0, 1]` whatever the randomness gave.
|
|
171
|
+
#
|
|
172
|
+
# @param draws [Array<Float>]
|
|
173
|
+
# @param index [Integer]
|
|
174
|
+
# @return [Float]
|
|
175
|
+
def self.draw(draws, index)
|
|
176
|
+
drawn = draws[index]
|
|
177
|
+
return 1.0 unless drawn.is_a?(Numeric)
|
|
178
|
+
return 1.0 unless drawn.to_f.finite?
|
|
179
|
+
|
|
180
|
+
drawn.to_f.clamp(0.0, 1.0)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Every bound a client applies to one send.
|
|
185
|
+
class Options
|
|
186
|
+
# Largest event payload the client agrees to send, in bytes.
|
|
187
|
+
#
|
|
188
|
+
# Hook0's API refuses request bodies above 2 MiB, so a payload above 1 MiB is already at risk of
|
|
189
|
+
# being refused once the JSON envelope around it — metadata, labels, identifiers — is counted.
|
|
190
|
+
# The client rules such an event out rather than spending a round trip, and every retry after
|
|
191
|
+
# it, on a request that cannot be accepted.
|
|
192
|
+
DEFAULT_MAX_PAYLOAD_BYTES = 1024 * 1024
|
|
193
|
+
|
|
194
|
+
# @return [RetryPolicy] how the attempts of one send are spaced out
|
|
195
|
+
attr_reader :retry_policy
|
|
196
|
+
|
|
197
|
+
# @return [Float] how long one attempt is given, in seconds
|
|
198
|
+
attr_reader :request_timeout
|
|
199
|
+
|
|
200
|
+
# @return [Integer] the largest payload sent, refused before a socket is opened
|
|
201
|
+
attr_reader :max_payload_bytes
|
|
202
|
+
|
|
203
|
+
# @return [Integer] the largest answer read off a socket
|
|
204
|
+
attr_reader :max_response_bytes
|
|
205
|
+
|
|
206
|
+
# @return [Integer] how many header lines an answer may carry
|
|
207
|
+
attr_reader :max_response_headers
|
|
208
|
+
|
|
209
|
+
# @return [Integer] the longest one header line may be
|
|
210
|
+
attr_reader :max_header_bytes
|
|
211
|
+
|
|
212
|
+
# @return [Integer] the largest whole head, every line counted together
|
|
213
|
+
attr_reader :max_head_bytes
|
|
214
|
+
|
|
215
|
+
# @param retry_policy [RetryPolicy]
|
|
216
|
+
# @param request_timeout [Float]
|
|
217
|
+
# @param max_payload_bytes [Integer]
|
|
218
|
+
# @param max_response_bytes [Integer]
|
|
219
|
+
# @param max_response_headers [Integer]
|
|
220
|
+
# @param max_header_bytes [Integer]
|
|
221
|
+
# @param max_head_bytes [Integer]
|
|
222
|
+
def initialize(
|
|
223
|
+
retry_policy: RetryPolicy.new,
|
|
224
|
+
request_timeout: Transport::DEFAULT_REQUEST_TIMEOUT,
|
|
225
|
+
max_payload_bytes: DEFAULT_MAX_PAYLOAD_BYTES,
|
|
226
|
+
max_response_bytes: Transport::DEFAULT_MAX_RESPONSE_BYTES,
|
|
227
|
+
max_response_headers: Transport::DEFAULT_MAX_RESPONSE_HEADERS,
|
|
228
|
+
max_header_bytes: Transport::DEFAULT_MAX_HEADER_BYTES,
|
|
229
|
+
max_head_bytes: Transport::DEFAULT_MAX_HEAD_BYTES
|
|
230
|
+
)
|
|
231
|
+
@retry_policy = retry_policy
|
|
232
|
+
@request_timeout = request_timeout
|
|
233
|
+
@max_payload_bytes = max_payload_bytes
|
|
234
|
+
@max_response_bytes = max_response_bytes
|
|
235
|
+
@max_response_headers = max_response_headers
|
|
236
|
+
@max_header_bytes = max_header_bytes
|
|
237
|
+
@max_head_bytes = max_head_bytes
|
|
238
|
+
freeze
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# An event to send to Hook0.
|
|
243
|
+
#
|
|
244
|
+
# `event_id` is the caller's to set when it already has one to key the event on. Left unset, the
|
|
245
|
+
# client generates a UUIDv7, sends it and answers it — which is what lets it repeat a request
|
|
246
|
+
# without risking a second copy of the event being ingested and delivered to every subscriber.
|
|
247
|
+
class Event
|
|
248
|
+
# @return [String] the type of the event, as the application declares it
|
|
249
|
+
attr_reader :event_type
|
|
250
|
+
|
|
251
|
+
# @return [String] what the event carries
|
|
252
|
+
attr_reader :payload
|
|
253
|
+
|
|
254
|
+
# @return [String] how to read the payload
|
|
255
|
+
attr_reader :payload_content_type
|
|
256
|
+
|
|
257
|
+
# @return [Hash{String => String}] what Hook0 routes the event by
|
|
258
|
+
attr_reader :labels
|
|
259
|
+
|
|
260
|
+
# @return [Hash{String => String}, nil] anything else worth carrying
|
|
261
|
+
attr_reader :metadata
|
|
262
|
+
|
|
263
|
+
# @return [Time, nil] when the event happened; the current moment when unset
|
|
264
|
+
attr_reader :occurred_at
|
|
265
|
+
|
|
266
|
+
# @return [String, nil] what to key the event on; the client chooses when unset
|
|
267
|
+
attr_reader :event_id
|
|
268
|
+
|
|
269
|
+
# @param event_type [String]
|
|
270
|
+
# @param payload [String]
|
|
271
|
+
# @param payload_content_type [String]
|
|
272
|
+
# @param labels [Hash{String => String}]
|
|
273
|
+
# @param metadata [Hash{String => String}, nil]
|
|
274
|
+
# @param occurred_at [Time, nil]
|
|
275
|
+
# @param event_id [String, nil]
|
|
276
|
+
def initialize(
|
|
277
|
+
event_type:,
|
|
278
|
+
payload:,
|
|
279
|
+
payload_content_type:,
|
|
280
|
+
labels: {},
|
|
281
|
+
metadata: nil,
|
|
282
|
+
occurred_at: nil,
|
|
283
|
+
event_id: nil
|
|
284
|
+
)
|
|
285
|
+
@event_type = event_type
|
|
286
|
+
@payload = payload
|
|
287
|
+
@payload_content_type = payload_content_type
|
|
288
|
+
@labels = labels
|
|
289
|
+
@metadata = metadata
|
|
290
|
+
@occurred_at = occurred_at
|
|
291
|
+
@event_id = event_id
|
|
292
|
+
freeze
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# An event type, read out of the `service.resource_type.verb` it is written as.
|
|
297
|
+
class EventType
|
|
298
|
+
# What an event type reads as.
|
|
299
|
+
PATTERN = /\A([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\z/
|
|
300
|
+
|
|
301
|
+
# @return [String] the leading segment
|
|
302
|
+
attr_reader :service
|
|
303
|
+
|
|
304
|
+
# @return [String] the middle segment
|
|
305
|
+
attr_reader :resource_type
|
|
306
|
+
|
|
307
|
+
# @return [String] the trailing segment
|
|
308
|
+
attr_reader :verb
|
|
309
|
+
|
|
310
|
+
# @param service [String]
|
|
311
|
+
# @param resource_type [String]
|
|
312
|
+
# @param verb [String]
|
|
313
|
+
def initialize(service, resource_type, verb)
|
|
314
|
+
@service = service
|
|
315
|
+
@resource_type = resource_type
|
|
316
|
+
@verb = verb
|
|
317
|
+
freeze
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Reads an event type, refusing one that does not name all three of its parts.
|
|
321
|
+
#
|
|
322
|
+
# @param written [String]
|
|
323
|
+
# @return [EventType]
|
|
324
|
+
# @raise [ClientError]
|
|
325
|
+
def self.parse(written)
|
|
326
|
+
read = PATTERN.match(written.to_s)
|
|
327
|
+
raise ClientError.invalid_event_type(written) if read.nil?
|
|
328
|
+
|
|
329
|
+
new(read[1], read[2], read[3])
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# @return [String] the event type as the API reads one
|
|
333
|
+
def to_s
|
|
334
|
+
"#{@service}.#{@resource_type}.#{@verb}"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# A UUIDv7, the shape of identifier Hook0 mints when it is the one choosing.
|
|
339
|
+
#
|
|
340
|
+
# Its leading 48 bits are the current time in milliseconds, so identifiers generated in sequence
|
|
341
|
+
# are ordered, which is what keeps the index they end up in from being written all over. Written
|
|
342
|
+
# here rather than taken from the standard library, which has had `SecureRandom.uuid_v7` only
|
|
343
|
+
# since Ruby 3.3 and this gem supports older.
|
|
344
|
+
#
|
|
345
|
+
# @return [String]
|
|
346
|
+
def self.generate_event_id
|
|
347
|
+
drawn = SecureRandom.random_bytes(16).unpack("C*")
|
|
348
|
+
|
|
349
|
+
milliseconds = (Time.now.to_f * 1000).floor
|
|
350
|
+
6.times { |index| drawn[index] = (milliseconds >> (8 * (5 - index))) & 0xFF }
|
|
351
|
+
drawn[6] = (drawn[6] & 0x0F) | 0x70
|
|
352
|
+
drawn[8] = (drawn[8] & 0x3F) | 0x80
|
|
353
|
+
|
|
354
|
+
written = drawn.pack("C*").unpack1("H*")
|
|
355
|
+
[written[0, 8], written[8, 4], written[12, 4], written[16, 4], written[20, 12]].join("-")
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# The Hook0 client, built once and shared wherever an application sends events.
|
|
359
|
+
#
|
|
360
|
+
# Every event is sent under an identifier this client knows: the one set on the {Event}, or a
|
|
361
|
+
# UUIDv7 it generates when the event carries none. Passing none does not mean the identifier comes
|
|
362
|
+
# from Hook0 — the value comes from here, travels with the request, and is what {#send_event}
|
|
363
|
+
# answers.
|
|
364
|
+
#
|
|
365
|
+
# That is what makes retrying safe. Hook0 keys events on that identifier, so a request repeated
|
|
366
|
+
# after a network failure or a server error ingests the event once rather than twice; without a
|
|
367
|
+
# client-chosen identifier, a repeated request would create a second event and deliver it to every
|
|
368
|
+
# subscriber. It also gives the answer to a retry its meaning: `EventAlreadyIngested` in reply to
|
|
369
|
+
# a *repeated* request says an earlier attempt of that same send reached the API, so the send
|
|
370
|
+
# succeeded. The same answer to a *first* attempt is a genuine conflict and is reported as one.
|
|
371
|
+
#
|
|
372
|
+
# Only what could end differently is retried: a request that got no answer, a server error, and an
|
|
373
|
+
# instance saying it is being reached faster than it accepts. What the API refuses outright — a
|
|
374
|
+
# quota that is spent, a payload it will not read — is reported as is, since repeating it would
|
|
375
|
+
# only spend the same round trip again. The verdict for every problem the API can report is
|
|
376
|
+
# written down in the conformance corpus committed beside this gem, which the suite here reads.
|
|
377
|
+
#
|
|
378
|
+
# A send is bounded on five axes, each of them the caller's to set: the size of the payload, which
|
|
379
|
+
# is refused before a socket is opened; how long one attempt is given; how many attempts are made;
|
|
380
|
+
# how long a single wait between them may be; and how long every wait of one send may add up to.
|
|
381
|
+
class Client
|
|
382
|
+
# The identifier Hook0 gives the problem it answers when an event identifier is already taken.
|
|
383
|
+
ALREADY_INGESTED = "EventAlreadyIngested"
|
|
384
|
+
|
|
385
|
+
# The identifier Hook0 gives the problem it answers when requests are reaching the instance
|
|
386
|
+
# faster than it accepts them.
|
|
387
|
+
#
|
|
388
|
+
# It shares its status with the quota problems, and is the only one of them worth repeating: a
|
|
389
|
+
# quota clears when a plan changes or a day turns, neither of which happens inside the seconds a
|
|
390
|
+
# send is given, while pacing clears on its own and the answer says when.
|
|
391
|
+
RATE_LIMITED = "RateLimited"
|
|
392
|
+
|
|
393
|
+
# What Hook0 answers when the event identifier a request carries is already taken.
|
|
394
|
+
CONFLICT = 409
|
|
395
|
+
|
|
396
|
+
# What Hook0 answers both when a quota is spent and when requests are coming in faster than the
|
|
397
|
+
# instance accepts them. Which of the two it is only the problem the body names can say, which
|
|
398
|
+
# is why this status alone decides nothing.
|
|
399
|
+
PACED = 429
|
|
400
|
+
|
|
401
|
+
# First status saying the failure is on Hook0's side, and so could clear on its own.
|
|
402
|
+
LOWEST_SERVER_ERROR = 500
|
|
403
|
+
|
|
404
|
+
# What the API names the delay before the request becomes servable in, in whole seconds.
|
|
405
|
+
DELAY_HEADER = "retry-after"
|
|
406
|
+
|
|
407
|
+
# Longest value of that header read, and the largest delay it may name. A header written by the
|
|
408
|
+
# other end is bounded before it is turned into a number, and a delay above this is one nobody
|
|
409
|
+
# meant.
|
|
410
|
+
MAX_DELAY_HEADER_BYTES = 32
|
|
411
|
+
MAX_NAMED_DELAY_SECONDS = (2**31) - 1
|
|
412
|
+
|
|
413
|
+
# What a whole number of seconds reads as, which is the one form of that header this client
|
|
414
|
+
# honours.
|
|
415
|
+
WHOLE_SECONDS = /\A\d+\z/
|
|
416
|
+
|
|
417
|
+
# Where an event is ingested, under the API URL.
|
|
418
|
+
EVENT_PATH = "event"
|
|
419
|
+
|
|
420
|
+
# Where event types are read and created, under the API URL.
|
|
421
|
+
EVENT_TYPES_PATH = "event_types"
|
|
422
|
+
|
|
423
|
+
# @return [String] the base API URL this client reaches
|
|
424
|
+
attr_reader :api_url
|
|
425
|
+
|
|
426
|
+
# @return [String] the application events are sent to
|
|
427
|
+
attr_reader :application_id
|
|
428
|
+
|
|
429
|
+
# @return [Options] the bounds one send is held to
|
|
430
|
+
attr_reader :options
|
|
431
|
+
|
|
432
|
+
# @return [Transport] what this client issues its requests through, which is also what a
|
|
433
|
+
# generated operation group is built on
|
|
434
|
+
attr_reader :transport
|
|
435
|
+
|
|
436
|
+
# @param api_url [String] base API URL of a Hook0 instance, such as https://app.hook0.com/api/v1
|
|
437
|
+
# @param application_id [String] identifier of the Hook0 application events are sent to
|
|
438
|
+
# @param token [String] an authentication token valid for that application
|
|
439
|
+
# @param options [Options] the bounds one send is held to
|
|
440
|
+
def initialize(api_url, application_id, token, options = Options.new)
|
|
441
|
+
@api_url = api_url
|
|
442
|
+
@application_id = application_id
|
|
443
|
+
@options = options
|
|
444
|
+
@transport = Transport.new(
|
|
445
|
+
api_url,
|
|
446
|
+
token,
|
|
447
|
+
timeout: options.request_timeout,
|
|
448
|
+
max_response_bytes: options.max_response_bytes,
|
|
449
|
+
max_response_headers: options.max_response_headers,
|
|
450
|
+
max_header_bytes: options.max_header_bytes,
|
|
451
|
+
max_head_bytes: options.max_head_bytes,
|
|
452
|
+
retry_policy: options.retry_policy
|
|
453
|
+
)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Sends an event, and answers the identifier it was sent under.
|
|
457
|
+
#
|
|
458
|
+
# @param event [Event]
|
|
459
|
+
# @return [String]
|
|
460
|
+
# @raise [ClientError] when the event was not ingested
|
|
461
|
+
def send_event(event)
|
|
462
|
+
event_id = identifier_of(event)
|
|
463
|
+
refuse_oversized(event, event_id)
|
|
464
|
+
|
|
465
|
+
body = full_event(event, event_id)
|
|
466
|
+
policy = @options.retry_policy
|
|
467
|
+
delays = policy.delays(jitter_draws(policy.attempts - 1))
|
|
468
|
+
|
|
469
|
+
issued = 0
|
|
470
|
+
waited = 0.0
|
|
471
|
+
loop do
|
|
472
|
+
issued += 1
|
|
473
|
+
outcome = attempt(body)
|
|
474
|
+
|
|
475
|
+
return outcome.ingested unless outcome.ingested.nil?
|
|
476
|
+
return event_id if outcome.already_ingested && issued > 1
|
|
477
|
+
raise ClientError.event_sending(event_id, outcome.detail) if outcome.already_ingested
|
|
478
|
+
|
|
479
|
+
scheduled = outcome.retryable ? delays[issued - 1] : nil
|
|
480
|
+
raise given_up(event_id, issued, waited, outcome.detail) if scheduled.nil?
|
|
481
|
+
|
|
482
|
+
waiting = wait_for(outcome, scheduled, policy.max_total_delay_in_force - waited)
|
|
483
|
+
sleep(waiting)
|
|
484
|
+
waited += waiting
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Creates the event types the application does not declare yet, and answers those.
|
|
489
|
+
#
|
|
490
|
+
# @param event_types [Array<String>]
|
|
491
|
+
# @return [Array<String>]
|
|
492
|
+
# @raise [ClientError]
|
|
493
|
+
def upsert_event_types(event_types)
|
|
494
|
+
wanted = event_types.map { |written| EventType.parse(written) }
|
|
495
|
+
return [] if wanted.empty?
|
|
496
|
+
|
|
497
|
+
declared = declared_event_types
|
|
498
|
+
wanted.reject { |event_type| declared.include?(event_type.to_s) }.map do |event_type|
|
|
499
|
+
create_event_type(event_type)
|
|
500
|
+
event_type.to_s
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# What one attempt at sending an event ended with, `retry_after` being how long the answer said
|
|
505
|
+
# to wait before repeating the request, in seconds, when it said.
|
|
506
|
+
Attempt = Struct.new(:ingested, :already_ingested, :detail, :retryable, :retry_after)
|
|
507
|
+
private_constant :Attempt
|
|
508
|
+
|
|
509
|
+
private
|
|
510
|
+
|
|
511
|
+
# The identifier an event is sent under: the one it carries, or one generated for it.
|
|
512
|
+
def identifier_of(event)
|
|
513
|
+
carried = event.event_id
|
|
514
|
+
return carried if carried.is_a?(String) && !carried.empty?
|
|
515
|
+
|
|
516
|
+
Hook0.generate_event_id
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Rules an oversized payload out, before a socket is opened for it.
|
|
520
|
+
def refuse_oversized(event, event_id)
|
|
521
|
+
size = event.payload.to_s.bytesize
|
|
522
|
+
return if size <= @options.max_payload_bytes
|
|
523
|
+
|
|
524
|
+
raise ClientError.payload_too_large(event_id, size, @options.max_payload_bytes)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# An event as the API reads one.
|
|
528
|
+
def full_event(event, event_id)
|
|
529
|
+
occurred_at = event.occurred_at.nil? ? Time.now.utc : event.occurred_at
|
|
530
|
+
body = {
|
|
531
|
+
"event_id" => event_id,
|
|
532
|
+
"application_id" => @application_id,
|
|
533
|
+
"event_type" => event.event_type,
|
|
534
|
+
"payload" => event.payload,
|
|
535
|
+
"payload_content_type" => event.payload_content_type,
|
|
536
|
+
"occurred_at" => occurred_at.iso8601,
|
|
537
|
+
"labels" => event.labels.to_h
|
|
538
|
+
}
|
|
539
|
+
body["metadata"] = event.metadata.to_h unless event.metadata.nil?
|
|
540
|
+
body
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# One attempt at sending an already-bounded event.
|
|
544
|
+
def attempt(body)
|
|
545
|
+
status, headers, payload = @transport.deliver("POST", EVENT_PATH, [], body)
|
|
546
|
+
read_attempt(status, headers, payload)
|
|
547
|
+
rescue TransportError => e
|
|
548
|
+
Attempt.new(nil, false, e.message, e.retryable?, nil)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# What the API answered one attempt, and whether repeating it could end differently.
|
|
552
|
+
def read_attempt(status, headers, payload)
|
|
553
|
+
body = payload.to_s
|
|
554
|
+
|
|
555
|
+
if status >= 200 && status < 300
|
|
556
|
+
ingested = ingested_id(body)
|
|
557
|
+
# The API accepted the event but answered something this client cannot read; repeating the
|
|
558
|
+
# request would meet the same answer.
|
|
559
|
+
return Attempt.new(nil, false, "Hook0 answered #{status} without an event id", false, nil) if ingested.nil?
|
|
560
|
+
|
|
561
|
+
return Attempt.new(ingested, false, "", false, nil)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
problem = problem_id(body)
|
|
565
|
+
return Attempt.new(nil, true, body, false, nil) if status == CONFLICT && problem == ALREADY_INGESTED
|
|
566
|
+
|
|
567
|
+
Attempt.new(nil, false, body, retryable?(status, problem), named_delay(headers))
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Whether repeating a request the API answered that way could end differently.
|
|
571
|
+
#
|
|
572
|
+
# The status decides on its own everywhere but under the one it answers both a spent quota and a
|
|
573
|
+
# paced instance with: a quota clears when a plan changes or a day turns, and neither is
|
|
574
|
+
# something a send spending seconds can wait for. Only the problem the body names tells the two
|
|
575
|
+
# apart, and a body naming a problem this client has never heard of falls back to what the
|
|
576
|
+
# status says.
|
|
577
|
+
def retryable?(status, problem)
|
|
578
|
+
return problem == RATE_LIMITED if status == PACED
|
|
579
|
+
|
|
580
|
+
status >= LOWEST_SERVER_ERROR
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# The delay the API named before the request becomes servable, in seconds.
|
|
584
|
+
#
|
|
585
|
+
# Only a whole number of seconds is read. The header may also carry a date, which is a clock
|
|
586
|
+
# this client would be comparing against its own, and anything else is a header nobody meant:
|
|
587
|
+
# both leave the client's own schedule in place rather than being guessed at.
|
|
588
|
+
def named_delay(headers)
|
|
589
|
+
written = headers.to_h.fetch(DELAY_HEADER, "").to_s.strip
|
|
590
|
+
return nil if written.empty? || written.bytesize > MAX_DELAY_HEADER_BYTES
|
|
591
|
+
return nil unless WHOLE_SECONDS.match?(written)
|
|
592
|
+
|
|
593
|
+
seconds = Integer(written, 10)
|
|
594
|
+
seconds > MAX_NAMED_DELAY_SECONDS ? nil : seconds.to_f
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# How long to wait before the next attempt.
|
|
598
|
+
#
|
|
599
|
+
# It is what the API asked for when it asked for anything, and the client's own schedule
|
|
600
|
+
# otherwise. Either way it is cut down to what is left of the budget every delay of one send
|
|
601
|
+
# shares, so a delay written by the other end cannot stretch a send past what the caller allowed
|
|
602
|
+
# for it.
|
|
603
|
+
def wait_for(outcome, scheduled, remaining)
|
|
604
|
+
wanted = outcome.retry_after.nil? ? scheduled : outcome.retry_after
|
|
605
|
+
wanted.clamp(0.0, [remaining, 0.0].max)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# The identifier the API says it ingested the event under.
|
|
609
|
+
def ingested_id(body)
|
|
610
|
+
answered = parsed(body)
|
|
611
|
+
return nil unless answered.is_a?(Hash)
|
|
612
|
+
|
|
613
|
+
ingested = answered["event_id"]
|
|
614
|
+
ingested.is_a?(String) ? ingested : nil
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# The problem a refusal names, unset when the body names none this client can read.
|
|
618
|
+
def problem_id(body)
|
|
619
|
+
problem = parsed(body)
|
|
620
|
+
return nil unless problem.is_a?(Hash)
|
|
621
|
+
|
|
622
|
+
named = problem["id"]
|
|
623
|
+
named.is_a?(String) ? named : nil
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def parsed(body)
|
|
627
|
+
JSON.parse(body, max_nesting: Runtime::MAX_PAYLOAD_NESTING)
|
|
628
|
+
rescue JSON::ParserError, EncodingError
|
|
629
|
+
nil
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# What to raise when a send is being given up on.
|
|
633
|
+
def given_up(event_id, attempts, waited, detail)
|
|
634
|
+
return ClientError.event_sending(event_id, detail) if attempts <= 1
|
|
635
|
+
|
|
636
|
+
ClientError.retries_exhausted(event_id, attempts, waited, detail)
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# The randomness used to jitter the delays of one send.
|
|
640
|
+
#
|
|
641
|
+
# Jitter only has to keep emitters that failed together from coming back together; it does not
|
|
642
|
+
# have to be unpredictable, so the platform's own generator is enough.
|
|
643
|
+
def jitter_draws(count)
|
|
644
|
+
Array.new([count, 0].max) { Random.rand }
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# The event types an application already declares, out of what the API answered.
|
|
648
|
+
def declared_event_types
|
|
649
|
+
begin
|
|
650
|
+
status, payload = @transport.request("GET", EVENT_TYPES_PATH, [["application_id", @application_id]])
|
|
651
|
+
rescue TransportError => e
|
|
652
|
+
raise ClientError.available_event_types(e.message)
|
|
653
|
+
end
|
|
654
|
+
raise ClientError.available_event_types(payload.to_s) unless status >= 200 && status < 300
|
|
655
|
+
|
|
656
|
+
answered = parsed(payload.to_s)
|
|
657
|
+
unless answered.is_a?(Array)
|
|
658
|
+
raise ClientError.available_event_types("the API did not answer a list of event types")
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
answered.filter_map { |entry| declared_name(entry) }
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# The name one entry of the list the API answered declares, when it declares one.
|
|
665
|
+
def declared_name(entry)
|
|
666
|
+
return nil unless entry.is_a?(Hash)
|
|
667
|
+
|
|
668
|
+
name = entry["event_type_name"]
|
|
669
|
+
name.is_a?(String) ? name : nil
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Declares one event type on the application.
|
|
673
|
+
def create_event_type(event_type)
|
|
674
|
+
body = {
|
|
675
|
+
"application_id" => @application_id,
|
|
676
|
+
"service" => event_type.service,
|
|
677
|
+
"resource_type" => event_type.resource_type,
|
|
678
|
+
"verb" => event_type.verb
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
begin
|
|
682
|
+
status, payload = @transport.request("POST", EVENT_TYPES_PATH, [], body)
|
|
683
|
+
rescue TransportError => e
|
|
684
|
+
raise ClientError.creating_event_type(event_type.to_s, e.message)
|
|
685
|
+
end
|
|
686
|
+
return if status >= 200 && status < 300
|
|
687
|
+
|
|
688
|
+
raise ClientError.creating_event_type(event_type.to_s, payload.to_s)
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
end
|