tungsten-runtime 0.1.2

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,500 @@
1
+ # frozen_string_literal: true
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ module Tungsten
5
+ module Runtime
6
+ # What the consuming helpers of a stream return: the result, or when
7
+ # the stream failed, what was gathered before the failure with the
8
+ # failure as `error` (an Err); `reconnects` of the last event delivered.
9
+ Folded = Data.define(:value, :error, :reconnects) do
10
+ def ok? = error.nil?
11
+ end
12
+
13
+ # The server-sent events of an operation (`client.<resource>.<op>_stream`).
14
+ # The request is sent when the enumeration starts; an error ends it
15
+ # (`error` is then the Err). A dropped stream is reconnected with
16
+ # `Last-Event-ID` when the call is safe to repeat, and an event whose id
17
+ # was delivered is never delivered twice. `cancel` ends it for good,
18
+ # without an error, from any thread.
19
+ class EventStream
20
+ include Enumerable
21
+
22
+ # Like the transport deadline, this must cross the call pipeline's
23
+ # StandardError handlers before collect turns it into a diagnostic.
24
+ class CollectionDeadline < Exception; end # rubocop:disable Lint/InheritException
25
+
26
+ # The connection being read and where the stream is.
27
+ Open = Struct.new(:body, :parser, :decoder, :queue, :ended, :oversize, :meta, :delivered_count, :key, :key_header, :check,
28
+ :secrets, :timeout_ms, :repeatable, :reconnects, :last_id, :last_retry, :delivered, :replaying,
29
+ keyword_init: true)
30
+
31
+ attr_reader :error
32
+
33
+ def initialize(core, op, args, opts, decode)
34
+ @core = core
35
+ @op = op
36
+ @args = args
37
+ @opts = opts
38
+ @decode = decode
39
+ @state = :idle
40
+ @error = nil
41
+ @cancelled = false
42
+ @lock = Mutex.new
43
+ @open = nil
44
+ end
45
+
46
+ # The operation's descriptor.
47
+ def operation = @op
48
+
49
+ # End the stream for good, without an error and without a reconnect.
50
+ def cancel
51
+ body = @lock.synchronize do
52
+ @cancelled = true
53
+ @open&.body
54
+ end
55
+ body&.close
56
+ nil
57
+ end
58
+
59
+ def cancelled? = @lock.synchronize { @cancelled }
60
+
61
+ # Close the connection.
62
+ def close
63
+ @open&.body&.close
64
+ set_open(nil)
65
+ @state = :done
66
+ nil
67
+ end
68
+
69
+ # Every event, in order; stops at the end, at a failure (`error`) or
70
+ # after `cancel`.
71
+ def each
72
+ return enum_for(:each) unless block_given?
73
+
74
+ while (event = next_event)
75
+ yield event
76
+ end
77
+ self
78
+ end
79
+
80
+ # Read the stream to its end, calling the handler named like each
81
+ # event (`"message"` for one without a name), else the one named `"*"`;
82
+ # `value` is the number of events delivered.
83
+ def on(handlers)
84
+ count = 0
85
+ reconnects = 0
86
+ handlers = handlers.transform_keys(&:to_s)
87
+ each do |event|
88
+ reconnects = event.reconnects
89
+ count += 1
90
+ (handlers[event.event] || handlers["*"])&.call(event)
91
+ end
92
+ Folded.new(value: count, error: @error, reconnects: reconnects)
93
+ ensure
94
+ close
95
+ end
96
+
97
+ # Read the stream to its end and fold its events into one value.
98
+ def reduce(initial)
99
+ acc = initial
100
+ reconnects = 0
101
+ each do |event|
102
+ reconnects = event.reconnects
103
+ acc = yield(acc, event)
104
+ end
105
+ Folded.new(value: acc, error: @error, reconnects: reconnects)
106
+ ensure
107
+ close
108
+ end
109
+
110
+ # The first event for which the block holds (any event without a
111
+ # block), then close the stream; `value` is nil when none did.
112
+ def first(*)
113
+ reconnects = 0
114
+ each do |event|
115
+ reconnects = event.reconnects
116
+ return Folded.new(value: event, error: nil, reconnects: reconnects) if !block_given? || yield(event)
117
+ end
118
+ Folded.new(value: nil, error: @error, reconnects: reconnects)
119
+ ensure
120
+ close
121
+ end
122
+
123
+ # Every event in order and the final failure, if any: [events, Err or
124
+ # nil]. The collection is bounded: it ends with UNEXPECTED_RESPONSE
125
+ # once the kept events hold more than ClientOptions `max_collect_bytes`
126
+ # of JSON or it ran longer than `max_collect_ms`.
127
+ def collect
128
+ return [[], @error] unless @core
129
+
130
+ events = []
131
+ size = 0
132
+ Timeout.timeout(@core.max_collect_ms / 1000.0, CollectionDeadline) do
133
+ each do |event|
134
+ size += Json.text(event.raw).bytesize
135
+ if size > @core.max_collect_bytes
136
+ return [events, @core.send(:over_budget, @op, true, events.length)]
137
+ end
138
+ events << event
139
+ end
140
+ end
141
+ [events, @error]
142
+ rescue CollectionDeadline
143
+ @error = @core.send(:over_budget, @op, false, events.length)
144
+ [events, @error]
145
+ ensure
146
+ close
147
+ end
148
+
149
+ private
150
+
151
+ def set_open(open)
152
+ cancelled = @lock.synchronize do
153
+ @open = open
154
+ @cancelled
155
+ end
156
+ open.body.close if cancelled && open
157
+ end
158
+
159
+ def fail_with(err)
160
+ @error = err
161
+ close
162
+ nil
163
+ end
164
+
165
+ # The next event, or nil at the end.
166
+ def next_event
167
+ loop do
168
+ case @state
169
+ when :done then return nil
170
+ when :idle
171
+ if cancelled?
172
+ @state = :done
173
+ return nil
174
+ end
175
+ open = @core.send(:open_stream, @op, @args, @opts, nil)
176
+ return fail_with(open) if open.is_a?(Err)
177
+
178
+ set_open(open)
179
+ @state = :open
180
+ if cancelled?
181
+ close
182
+ return nil
183
+ end
184
+ next
185
+ end
186
+ o = @open
187
+ if cancelled?
188
+ close
189
+ return nil
190
+ end
191
+ unless o.queue.empty?
192
+ ev = o.queue.shift
193
+ item = @core.send(:accept_event, @op, o, ev, @decode)
194
+ return fail_with(item) if item.is_a?(Err)
195
+ if item == :done
196
+ close
197
+ return nil
198
+ end
199
+ next if item.nil?
200
+
201
+ return item
202
+ end
203
+ return fail_with(@core.send(:oversize, @op, o)) if o.oversize
204
+
205
+ why = nil
206
+ if o.ended
207
+ # A stream that names its terminal event has not finished without it.
208
+ if @op.stream["done"].nil?
209
+ close
210
+ return nil
211
+ end
212
+ why = :early_end
213
+ else
214
+ kind, chunk = o.body.read
215
+ if cancelled?
216
+ close
217
+ return nil
218
+ end
219
+ case kind
220
+ when :chunk
221
+ o.queue.concat(o.parser.push(o.decoder.decode(chunk)))
222
+ o.oversize = o.parser.exceeded?
223
+ next
224
+ when :end
225
+ o.queue.concat(o.parser.push(o.decoder.finish))
226
+ o.queue.concat(o.parser.finish)
227
+ o.oversize = o.parser.exceeded?
228
+ o.ended = true
229
+ next
230
+ when :timeout
231
+ return fail_with(@core.send(:interrupted, @op, o, nil)) unless @core.idle_timeout_ms
232
+
233
+ why = :idle
234
+ else
235
+ why = :lost
236
+ end
237
+ end
238
+ unless @core.send(:may_reconnect?, o)
239
+ # Without reconnects a clean end stays a clean end.
240
+ if why == :early_end && o.reconnects.zero?
241
+ close
242
+ return nil
243
+ end
244
+ return fail_with(@core.send(:interrupted, @op, o, why))
245
+ end
246
+ fresh = @core.send(:reconnect, @op, @args, @opts, o)
247
+ if cancelled?
248
+ fresh.body.close if fresh.is_a?(Open)
249
+ close
250
+ return nil
251
+ end
252
+ return fail_with(fresh) if fresh.is_a?(Err)
253
+
254
+ set_open(fresh)
255
+ end
256
+ end
257
+ end
258
+
259
+ class ClientCore
260
+ # The wait before a reconnect when the server sent no retry value.
261
+ DEFAULT_RECONNECT_MS = 250
262
+
263
+ # The server-sent events of an operation (an id or a descriptor).
264
+ # `decode` turns each event's JSON data into the SDK's type.
265
+ #
266
+ # @return [EventStream]
267
+ def stream(op, args = {}, opts = nil, decode: nil)
268
+ d = op.is_a?(OperationDescriptor) ? op : operation(op.to_s)
269
+ unless d&.stream
270
+ id = op.is_a?(OperationDescriptor) ? op.id : op.to_s
271
+ err = Runtime.diag(id, "VALIDATION_FAILED").param("operation").expected("an operation with an event stream")
272
+ .remedy("#{id} has no event stream (its success response is not text/event-stream); use call instead. Nothing was sent.").err
273
+ return FailedStream.new(err)
274
+ end
275
+ o = begin
276
+ CallOptions.from(opts)
277
+ rescue ArgumentError => e
278
+ return FailedStream.new(Runtime.diag(d.id, "VALIDATION_FAILED").param("opts").expected("call options")
279
+ .remedy("Pass the call options as a Hash of idempotency_key, confirm, timeout_ms, verify and headers (#{e.message}).").err)
280
+ end
281
+ EventStream.new(self, d, args, o, decode)
282
+ end
283
+
284
+ # A stream that fails before it starts.
285
+ class FailedStream < EventStream
286
+ def initialize(err)
287
+ super(nil, nil, nil, nil, nil)
288
+ @error = err
289
+ @state = :done
290
+ end
291
+ end
292
+
293
+ private
294
+
295
+ # The stream's request flag set to true, in a merged body field or
296
+ # inside an object body argument.
297
+ def with_stream_flag(op, args)
298
+ flag = op.stream["flag"]
299
+ return args if flag.nil? || op.body.nil? || !args.is_a?(Hash)
300
+
301
+ out = args.dup
302
+ if op.body.arg.nil?
303
+ field = op.body.fields.find { |_, wire| wire == flag }
304
+ out[field[0]] = true if field
305
+ elsif args[op.body.arg].is_a?(Hash)
306
+ out[op.body.arg] = args[op.body.arg].merge(flag => true)
307
+ end
308
+ out
309
+ end
310
+
311
+ def open_stream(op, args, opts, resume_from)
312
+ headers = {}
313
+ accept = ""
314
+ [[false, @headers], [true, (opts.headers || {}).to_h.transform_keys(&:to_s)]].each do |own, layer|
315
+ layer.each do |name, value|
316
+ if name.casecmp?("accept")
317
+ accept = value.to_s
318
+ elsif own
319
+ headers[name] = value
320
+ end
321
+ end
322
+ end
323
+ headers["Accept"] = Util.merge_accept(accept)
324
+ if resume_from
325
+ headers.delete_if { |name, _| name.casecmp?("last-event-id") }
326
+ headers["Last-Event-ID"] = resume_from
327
+ end
328
+ stream_opts = opts.with(headers: headers)
329
+ converted =
330
+ begin
331
+ Json.from_ruby(args.nil? ? {} : args)
332
+ rescue ArgumentError
333
+ args
334
+ end
335
+ p = prepare(op, with_stream_flag(op, converted), stream_opts, :call, nil, nil)
336
+ return p if p.is_a?(Err)
337
+
338
+ result = send_with(p, stream_opts, true)
339
+ return result if result.is_a?(Err)
340
+
341
+ if result.is_a?(Ok)
342
+ type = result.meta.headers["content-type"]
343
+ after = op.mutation? ? " The call took effect; do not repeat it." : ""
344
+ return Runtime.diag(op.id, "UNEXPECTED_RESPONSE").status(result.meta.status).request_id(result.meta.request_id)
345
+ .param("response").received(type ? Util.envelope_value(type, false) : nil)
346
+ .expected("a text/event-stream body")
347
+ .remedy("The success response is not an event stream (Content-Type #{type || 'missing'}).#{after}")
348
+ .retrying("never").attempts(result.meta.attempts).err
349
+ end
350
+ _, meta, body = result
351
+ check = op.mutation? && !Classify.replay_protection?(op, !p.key.nil?) ? outcome_check(op, p.args) : nil
352
+ EventStream::Open.new(body: body, parser: SseParser.new(@max_event_bytes), decoder: Utf8Decoder.new, queue: [],
353
+ ended: false, oversize: false, meta: meta, delivered_count: 0, key: p.key, key_header: p.key_header,
354
+ check: check, secrets: p.secrets.dup, timeout_ms: attempt_timeout(stream_opts),
355
+ repeatable: !op.mutation? || Classify.replay_protection?(op, !p.key.nil?), reconnects: 0,
356
+ last_id: nil, last_retry: nil, delivered: {}, replaying: false)
357
+ end
358
+
359
+ # A dropped stream may be reconnected when the call is safe to repeat,
360
+ # reconnects are left, and an event id to resume from is known (or no
361
+ # event was delivered yet).
362
+ def may_reconnect?(o)
363
+ o.repeatable && o.reconnects < @max_reconnects &&
364
+ (o.delivered_count.zero? || (!o.last_id.nil? && Serialize.valid_header_value?(o.last_id)))
365
+ end
366
+
367
+ # Wait as the server asked (capped, shortened by up to 25 % at random),
368
+ # then send the request again with Last-Event-ID.
369
+ def reconnect(op, args, opts, old)
370
+ old.body.close
371
+ ceiling = [old.last_retry || DEFAULT_RECONNECT_MS, @reconnect_max_ms].min.to_f
372
+ sleep([ceiling - (random * 0.25 * ceiling), 0].max / 1000.0)
373
+ fresh = open_stream(op, args, opts, old.last_id)
374
+ return fresh if fresh.is_a?(Err)
375
+
376
+ fresh.delivered_count = old.delivered_count
377
+ fresh.reconnects = old.reconnects + 1
378
+ fresh.last_id = old.last_id
379
+ fresh.last_retry = old.last_retry
380
+ fresh.delivered = old.delivered
381
+ fresh.replaying = true
382
+ fresh
383
+ end
384
+
385
+ def plural(n, one, many) = n == 1 ? one : many
386
+
387
+ # The final error of a stream that broke after `o.delivered_count` events; `why`
388
+ # is :lost, :idle, :early_end, or nil for a silence past the attempt
389
+ # timeout.
390
+ def interrupted(op, o, why)
391
+ after = "after #{o.delivered_count} #{plural(o.delivered_count, 'event', 'events')}"
392
+ retried =
393
+ case o.reconnects
394
+ when 0 then ""
395
+ when 1 then " (1 reconnect made)"
396
+ else " (#{o.reconnects} reconnects made)"
397
+ end
398
+ idle = why == :idle
399
+ code = idle ? "STREAM_IDLE" : nil
400
+ status = o.meta.status
401
+ ctx = Classify::CallContext.new(api: @api, op: op, key: o.key, key_header: o.key_header, attempts: o.meta.attempts, check: o.check)
402
+ d =
403
+ if why.nil? || idle
404
+ silence = "No event arrived within #{o.timeout_ms} ms #{after}"
405
+ option = "timeout_ms"
406
+ if idle
407
+ silence = "No bytes arrived within #{@idle_timeout_ms} ms #{after}#{retried}"
408
+ option = "idle_timeout_ms"
409
+ end
410
+ cause = "#{silence}; the stream was abandoned."
411
+ if op.mutation?
412
+ Classify.outcome_unknown(ctx, cause, http_status: status, code: code, request_id: o.meta.request_id)
413
+ else
414
+ Runtime.diag(op.id, "UPSTREAM_UNAVAILABLE").status(status).code(code).request_id(o.meta.request_id)
415
+ .remedy("#{cause} This read has no side effects; call again later or with a larger #{option}.")
416
+ .attempts(o.meta.attempts).build
417
+ end
418
+ else
419
+ cause = "The event stream was cut off #{after}#{retried}: the connection failed before it ended."
420
+ if op.mutation?
421
+ Classify.outcome_unknown(ctx, cause, http_status: status, request_id: o.meta.request_id)
422
+ else
423
+ Runtime.diag(op.id, "TRANSPORT_FAILED").status(status).request_id(o.meta.request_id)
424
+ .remedy("#{cause} This read has no side effects; call again.").attempts(o.meta.attempts).build
425
+ end
426
+ end
427
+ Err.new(error: Util.scrub_diagnostic(d, o.secrets))
428
+ end
429
+
430
+ def over_budget(op, bytes, kept)
431
+ what = bytes ? "at most #{@max_collect_bytes} bytes of events" : "at most #{@max_collect_ms} ms to collect the events"
432
+ after = op.mutation? ? " The call took effect; do not repeat it." : ""
433
+ Runtime.diag(op.id, "UNEXPECTED_RESPONSE").param("events").expected(what)
434
+ .remedy("The stream was abandoned after #{kept} #{plural(kept, 'event', 'events')} (#{what}; max_collect_bytes and max_collect_ms).#{after} " \
435
+ "The events collected so far precede this error.")
436
+ .retrying("never").err
437
+ end
438
+
439
+ def oversize(op, o)
440
+ limit = @max_event_bytes
441
+ after = op.mutation? ? " The call took effect; do not repeat it." : ""
442
+ d = Runtime.diag(op.id, "UNEXPECTED_RESPONSE").status(o.meta.status).request_id(o.meta.request_id)
443
+ .param("events[#{o.delivered_count}]").expected("an event of at most #{limit} bytes")
444
+ .remedy("Event #{o.delivered_count} of the stream is larger than the limit of #{limit} bytes (max_event_bytes); the stream was abandoned.#{after} " \
445
+ "Events before it were delivered. Raise max_event_bytes if the server sends events this large on purpose.")
446
+ .retrying("never").attempts(o.meta.attempts).build
447
+ Err.new(error: Util.scrub_diagnostic(d, o.secrets))
448
+ end
449
+
450
+ # Decode, check and number one event: a StreamEvent, :done for the done
451
+ # sentinel, nil for a replayed event, or an Err.
452
+ def accept_event(op, o, ev, decode)
453
+ return :done if !op.stream["done"].nil? && op.stream["done"] == ev.data
454
+
455
+ if o.replaying
456
+ return nil if !ev.id.nil? && o.delivered[ev.id]
457
+
458
+ o.replaying = false
459
+ end
460
+ index = o.delivered_count
461
+ after_effect = op.mutation? ? " The call took effect; do not repeat it." : ""
462
+ failure = lambda do |b|
463
+ Err.new(error: Util.scrub_diagnostic(b.status(o.meta.status).request_id(o.meta.request_id).retrying("never")
464
+ .attempts(o.meta.attempts).build, o.secrets))
465
+ end
466
+ value = Json.try_parse(ev.data)
467
+ if UNSET.equal?(value)
468
+ return failure.call(Runtime.diag(op.id, "UNEXPECTED_RESPONSE").param("events[#{index}]")
469
+ .received(Util.envelope_value(ev.data, false)).expected("JSON in the data of every event")
470
+ .remedy("Event #{index} of the stream (event \"#{ev.event}\") does not carry JSON in its data.#{after_effect} Events before it were delivered."))
471
+ end
472
+ if @validate_responses != "off" && op.stream["event"]
473
+ issues = judge(op.stream["event"], value)
474
+ unless issues.empty?
475
+ issue = issues.first
476
+ path_text = Runtime.issue_path(issue.path)
477
+ redacted = Util.redact_paths(value, op.agent["sensitive_response_fields"])
478
+ received = Util.get_path(redacted, issue.path)
479
+ sensitive = issue.path.any? { |s| s.is_a?(String) && Util.looks_sensitive?(s) }
480
+ e = failure.call(Runtime.diag(op.id, "UNEXPECTED_RESPONSE").param("events[#{index}]#{path_text}")
481
+ .received(Util.envelope_value(received, sensitive)).expected(issue.message)
482
+ .remedy("Event #{index} of the stream (event \"#{ev.event}\") does not match the API description at events[#{index}]#{path_text} (#{issue.message}).#{after_effect}"))
483
+ return e if @validate_responses == "strict"
484
+
485
+ emit(e.error)
486
+ end
487
+ end
488
+ o.delivered_count += 1
489
+ unless ev.id.nil?
490
+ o.delivered[ev.id] = true
491
+ o.last_id = ev.id
492
+ end
493
+ o.last_retry = ev.retry_ms unless ev.retry_ms.nil?
494
+ typed = decode ? decode.call(value) : value
495
+ StreamEvent.new(value: typed, raw: value, event: ev.event, id: o.last_id, retry_ms: o.last_retry, meta: o.meta,
496
+ reconnects: o.reconnects)
497
+ end
498
+ end
499
+ end
500
+ end