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.
@@ -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