gapic-common 1.4.1 → 1.5.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,855 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Google LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require "uri"
18
+ require "gapic/logging_concerns"
19
+ require "gapic/rest/error"
20
+ require "gapic/rest/resumable_upload/core"
21
+ require "gapic/rest/resumable_upload/data_types"
22
+ require "gapic/rest/resumable_upload/errors"
23
+ require "gapic/rest/resumable_upload/events"
24
+ require "gapic/rest/resumable_upload/instructions"
25
+ require "gapic/rest/resumable_upload/retry_policies"
26
+ require "gapic/rest/resumable_upload/driver/retry_decider"
27
+ require "gapic/rest/resumable_upload/driver/upload_log"
28
+
29
+ module Gapic
30
+ module Rest
31
+ module ResumableUpload
32
+ ##
33
+ # @private
34
+ # Synchronous execution engine for the Resumable Upload Protocol.
35
+ # Coordinates HTTP network operations, stream buffering, monotonic deadlines,
36
+ # and delegates state transitions to Core.
37
+ #
38
+ # The outer tier of the three-tier design. All side effects live here; all protocol decisions live in
39
+ # {Rules}, which carries the state graph and the error category taxonomy. Every retry decision is made
40
+ # here by {RetryDecider}; `ClientStub` is handed a never-retry policy. See
41
+ # `design/resumable_upload/implementation-guide.md` section 2.7 for the buffer and stream position
42
+ # invariants, and section 6.3 for the deadline model.
43
+ #
44
+ # rubocop:disable Metrics/ClassLength
45
+ class Driver
46
+ include Gapic::LoggingConcerns
47
+
48
+ ##
49
+ # @private
50
+ # Minimum assumed upload throughput in bytes per second (1 MB/s).
51
+ # @return [Integer]
52
+ MIN_ASSUMED_THROUGHPUT = 1_048_576
53
+
54
+ ##
55
+ # @private
56
+ # Default base timeout in seconds (1 hour).
57
+ # @return [Integer]
58
+ BASE_TIMEOUT = 3_600
59
+
60
+ ##
61
+ # @private
62
+ # Retry policy handed to `ClientStub` for every request, so that each `ClientStub` call is exactly one
63
+ # attempt and {RetryDecider} makes every retry decision. `Gapic::CallOptions` accepts a Proc policy.
64
+ # @return [Proc]
65
+ CLIENT_STUB_NO_RETRY = ->(_error) { false }
66
+
67
+ # @private
68
+ # @return [Core]
69
+ attr_reader :core
70
+
71
+ ##
72
+ # @private
73
+ # Returns a {ResumeHandle} representing the current upload session parameters.
74
+ # Reading this property mid-run provides a best-effort snapshot of the current session state.
75
+ # Completed uploads (`:success`), rejected uploads (`:rejected`), and cancelled uploads
76
+ # (`:cancelled`) are finalized and not resumable, returning `nil`.
77
+ #
78
+ # @return [ResumeHandle, nil] Resume handle if upload URL is established and resumable, or nil
79
+ def resume_handle
80
+ Rules.resume_handle_from @core.state
81
+ end
82
+
83
+ ##
84
+ # @private
85
+ # Returns the raw upload session URL from protocol state, regardless of lifecycle status.
86
+ #
87
+ # @return [String, nil] Session upload URL if established, or nil
88
+ def upload_url
89
+ @core.state.upload_url
90
+ end
91
+
92
+ ##
93
+ # @private
94
+ # Initializes a new Resumable Upload Driver.
95
+ #
96
+ # @param client_stub [Gapic::Rest::ClientStub] Underlying REST client stub
97
+ # @param config [StartUploadConfig, ResumeUploadConfig] Configuration for this upload session
98
+ # @param core [Core, nil] Optional Core state machine (defaults to new Core with config)
99
+ # @param logger [Logger, nil] Optional logger override
100
+ # @param method_name [String, nil] RPC name this upload was started from, prefixed onto the
101
+ # per-request logging names (`"create_media_upload.start"`, `"create_media_upload.upload"`, and
102
+ # so on). Defaults to `"ResumableUpload"`.
103
+ def initialize client_stub:, config:, core: nil, logger: nil, method_name: nil
104
+ @client_stub = client_stub
105
+ @config = config
106
+ @core = core || Core.new(config)
107
+ @buffer = "".b
108
+ @buffer_start_offset = 0
109
+ @method_name_prefix = method_name || "ResumableUpload"
110
+
111
+ endpoint = client_stub.respond_to?(:endpoint) ? client_stub.endpoint : nil
112
+ setup_logging logger: logger || (client_stub.respond_to?(:logger) ? client_stub.logger : nil),
113
+ system_name: "gapic-common",
114
+ service: "ResumableUpload",
115
+ endpoint: endpoint,
116
+ client_id: client_stub.object_id
117
+ @upload_log = UploadLog.new stub_logger, upload_id: "unstarted"
118
+
119
+ # Only an initiating run carries a start policy; a resumed run issues no initiation request.
120
+ configured_start_policy = config.is_a?(StartUploadConfig) ? config.start_retry_policy : nil
121
+ @start_retry_policy = resolve_retry_policy configured_start_policy, RetryPolicies::START_DEFAULTS
122
+
123
+ @control_plane_retry_policy = resolve_retry_policy config.control_plane_retry_policy,
124
+ RetryPolicies::CONTROL_PLANE_DEFAULTS
125
+ @data_plane_retry_policy = resolve_retry_policy config.data_plane_retry_policy,
126
+ RetryPolicies::DATA_PLANE_DEFAULTS
127
+ # Started copy of the control plane policy for the open recovery episode; see #execute_send_query.
128
+ @recovery_policy = nil
129
+ end
130
+
131
+ ##
132
+ # @private
133
+ # Default retry policy for session initiation requests (start).
134
+ #
135
+ # @return [Gapic::Common::RetryPolicy]
136
+ def self.default_start_retry_policy
137
+ RetryPolicies.default_start
138
+ end
139
+
140
+ ##
141
+ # @private
142
+ # Default retry policy for control plane requests (query, cancel).
143
+ #
144
+ # @return [Gapic::Common::RetryPolicy]
145
+ def self.default_control_plane_retry_policy
146
+ RetryPolicies.default_control_plane
147
+ end
148
+
149
+ ##
150
+ # @private
151
+ # Default retry policy for data plane requests (upload, finalize).
152
+ #
153
+ # @return [Gapic::Common::RetryPolicy]
154
+ def self.default_data_plane_retry_policy
155
+ RetryPolicies.default_data_plane
156
+ end
157
+
158
+ ##
159
+ # @private
160
+ # Executes event loop until terminal state.
161
+ # Establishes a guaranteed monotonic deadline at the start of execution
162
+ # so the upload cannot stall indefinitely.
163
+ #
164
+ # Enforces the trampoline loop invariant: each dispatched instruction batch
165
+ # is validated by {#validate_batch} before any instruction executes, ensuring it
166
+ # produces either a single continuation event or terminates the session
167
+ # (via {Instruction::TerminateSuccess} or {Instruction::TerminateFailure}).
168
+ # Side-effect instructions ({Instruction::NotifyProgress},
169
+ # {Instruction::RealignBuffer}) explicitly return `nil` by construction,
170
+ # so only {Instruction::FillBuffer} and `Send*` instructions produce
171
+ # continuation events.
172
+ #
173
+ # @return [String, nil] Final response body
174
+ def run
175
+ @upload_log = UploadLog.new stub_logger, upload_id: LoggingConcerns.random_uuid4
176
+ @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + resolve_timeout
177
+ pending_event = initial_event
178
+
179
+ loop do
180
+ instructions = dispatch_event pending_event
181
+
182
+ if deadline_exceeded? && !terminal_instructions?(instructions)
183
+ instructions = dispatch_event Event::GlobalDeadlineExceeded.new
184
+ end
185
+
186
+ pending_event, terminal_result = execute_batch instructions
187
+ return terminal_result if pending_event.nil?
188
+ end
189
+ end
190
+
191
+ private
192
+
193
+ ##
194
+ # @private
195
+ # Executes an instruction batch and enforces the single-continuation-event invariant.
196
+ #
197
+ # @param instructions [Array<Object>] Emitted instructions
198
+ # @return [Array<Object, nil>] Tuple of [pending_event, terminal_result]
199
+ #
200
+ def execute_batch instructions
201
+ recipe = @core.last_decision&.recipe
202
+ validate_batch instructions, recipe
203
+
204
+ pending_event = nil
205
+ instructions.each do |instruction|
206
+ result = dispatch_instruction instruction
207
+ return [nil, result] if instruction.is_a? Instruction::TerminateSuccess
208
+ pending_event = result if Instruction::CONTINUATION.any? { |klass| instruction.is_a? klass }
209
+ end
210
+
211
+ unless pending_event_type? pending_event
212
+ raise InternalError,
213
+ "Resumable upload internal error: recipe :#{recipe} continuation instruction " \
214
+ "returned #{pending_event.class} instead of an event"
215
+ end
216
+ [pending_event, nil]
217
+ end
218
+
219
+ ##
220
+ # @private
221
+ # Validates that an instruction batch satisfies the trampoline invariant before execution.
222
+ #
223
+ # @param instructions [Array<Object>] Emitted instructions
224
+ # @param recipe [Symbol, nil] Recipe symbol from last decision
225
+ # @return [void]
226
+ # @raise [InternalError] If the batch is malformed or contains an unclassified instruction
227
+ #
228
+ def validate_batch instructions, recipe
229
+ continuation = 0
230
+ terminal = 0
231
+ instructions.each do |instruction|
232
+ case instruction
233
+ when *Instruction::CONTINUATION then continuation += 1
234
+ when *Instruction::TERMINAL then terminal += 1
235
+ when *Instruction::SIDE_EFFECT then nil
236
+ else
237
+ raise InternalError,
238
+ "Resumable upload internal error: recipe :#{recipe} emitted " \
239
+ "unclassified instruction #{instruction.class}"
240
+ end
241
+ end
242
+ return if continuation + terminal == 1
243
+
244
+ raise InternalError, batch_shape_message(recipe, continuation, terminal)
245
+ end
246
+
247
+ ##
248
+ # @private
249
+ # Formats diagnostic error message for a malformed instruction batch.
250
+ #
251
+ # @param recipe [Symbol, nil] Recipe symbol from last decision
252
+ # @param continuation [Integer] Number of continuation instructions
253
+ # @param terminal [Integer] Number of terminal instructions
254
+ # @return [String] Error message
255
+ #
256
+ def batch_shape_message recipe, continuation, terminal
257
+ reason = if continuation.zero? && terminal.zero?
258
+ "produced no continuation event and did not terminate"
259
+ elsif continuation > 1 && terminal.zero?
260
+ "produced multiple continuation events"
261
+ elsif continuation.zero? && terminal > 1
262
+ "produced multiple terminal instructions"
263
+ else
264
+ "produced both a continuation event and a terminal instruction"
265
+ end
266
+ "Resumable upload internal error: recipe :#{recipe} #{reason}"
267
+ end
268
+
269
+ ##
270
+ # @private
271
+ # Dispatches an event to Core, logging decisions and transitions.
272
+ #
273
+ # @param event [Object] Input event
274
+ # @return [Array<Object>] Emitted instructions
275
+ #
276
+ def dispatch_event event
277
+ instructions = begin
278
+ @core.dispatch event
279
+ rescue InvalidTransitionError => e
280
+ @upload_log.unmatched_transition @core.state, event, e
281
+ raise
282
+ end
283
+ @upload_log.decision @core.last_decision
284
+ @upload_log.lifecycle @core.last_decision, @config
285
+ instructions
286
+ end
287
+
288
+ ##
289
+ # @private
290
+ # Checks whether an instruction execution result represents a pending event.
291
+ #
292
+ # @param obj [Object] Execution result
293
+ # @return [Boolean]
294
+ #
295
+ def pending_event_type? obj
296
+ obj.is_a?(Event::ChunkRead) || obj.is_a?(Event::HttpResponse) ||
297
+ obj.is_a?(Event::RequestFailed) || obj.is_a?(Event::GlobalDeadlineExceeded)
298
+ end
299
+
300
+ ##
301
+ # @private
302
+ # Executes an instruction emitted by the state machine.
303
+ #
304
+ # @param instruction [Object] Instruction to execute
305
+ # @return [Object, nil] Resulting event or terminal response
306
+ #
307
+ def dispatch_instruction instruction
308
+ case instruction
309
+ when Instruction::NotifyProgress then execute_notify_progress instruction
310
+ when Instruction::RealignBuffer then execute_realign_buffer instruction
311
+ when Instruction::FillBuffer then execute_fill_buffer instruction
312
+ when Instruction::SendStart then execute_send_start instruction
313
+ when Instruction::SendChunk then execute_send_chunk instruction
314
+ when Instruction::SendFinalize then execute_send_finalize instruction
315
+ when Instruction::SendQuery then execute_send_query instruction
316
+ when Instruction::SendCancel then execute_send_cancel instruction
317
+ when Instruction::TerminateSuccess
318
+ instruction.response.body
319
+ when Instruction::TerminateFailure then raise instruction.error
320
+ end
321
+ end
322
+
323
+ ##
324
+ # @private
325
+ # Resolves a configured retry policy or applies defaults.
326
+ #
327
+ # @param value [Gapic::Common::RetryPolicy, Hash, nil] Configured policy or overrides
328
+ # @param defaults [Hash] Default policy configuration
329
+ # @return [Gapic::Common::RetryPolicy]
330
+ #
331
+ def resolve_retry_policy value, defaults
332
+ case value
333
+ when Gapic::Common::RetryPolicy
334
+ value
335
+ when Hash
336
+ Gapic::Common::RetryPolicy.new(**value).apply_defaults(defaults)
337
+ when nil
338
+ Gapic::Common::RetryPolicy.new(**defaults)
339
+ else
340
+ raise ArgumentError, "Expected RetryPolicy, Hash, or nil, got #{value.class}"
341
+ end
342
+ end
343
+
344
+ ##
345
+ # @private
346
+ # Determines the initial event to dispatch based on configuration class.
347
+ #
348
+ # @return [Event::StartUpload, Event::ResumeUpload]
349
+ def initial_event
350
+ if @config.is_a? ResumeUploadConfig
351
+ Event::ResumeUpload.new
352
+ else
353
+ Event::StartUpload.new
354
+ end
355
+ end
356
+
357
+ ##
358
+ # @private
359
+ # Resolves the total upload deadline timeout in seconds.
360
+ #
361
+ # @return [Numeric] Timeout in seconds
362
+ #
363
+ def resolve_timeout
364
+ return @config.timeout if @config.timeout&.positive?
365
+
366
+ # When timeout is unset, BASE_TIMEOUT (1 hour) acts as a floor so small uploads still get
367
+ # a full hour while large uploads scale past it at MIN_ASSUMED_THROUGHPUT (1 MiB/s).
368
+ if @config.upload_size
369
+ [@config.upload_size.fdiv(MIN_ASSUMED_THROUGHPUT), BASE_TIMEOUT].max
370
+ else
371
+ BASE_TIMEOUT
372
+ end
373
+ end
374
+
375
+ ##
376
+ # @private
377
+ # Computes the per-attempt timeout: whatever is left of the global monotonic deadline, and of the
378
+ # command's own retry budget when the command has one.
379
+ #
380
+ # @param retry_policy [Gapic::Common::RetryPolicy, nil] Target command retry policy
381
+ # @param started_at [Numeric, nil] Monotonic time the command started; `nil` grants the full budget
382
+ # @return [Numeric] Effective per-attempt timeout
383
+ #
384
+ def request_timeout retry_policy, started_at: nil
385
+ remaining = if @deadline
386
+ [@deadline - monotonic_now, 0].max
387
+ else
388
+ resolve_timeout
389
+ end
390
+ return remaining unless retry_policy&.timeout
391
+
392
+ elapsed = started_at ? monotonic_now - started_at : 0
393
+ (retry_policy.timeout - elapsed).clamp 0, remaining
394
+ end
395
+
396
+ ##
397
+ # @private
398
+ # Checks whether the monotonic clock has exceeded the session deadline.
399
+ #
400
+ # @return [Boolean]
401
+ #
402
+ def deadline_exceeded?
403
+ return false unless @deadline
404
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) > @deadline
405
+ end
406
+
407
+ ##
408
+ # @private
409
+ # Determines whether the instruction list contains a terminal instruction.
410
+ #
411
+ # @param instructions [Array<Object>] Instruction list
412
+ # @return [Boolean]
413
+ #
414
+ def terminal_instructions? instructions
415
+ instructions.any? do |i|
416
+ i.is_a?(Instruction::TerminateSuccess) || i.is_a?(Instruction::TerminateFailure)
417
+ end
418
+ end
419
+
420
+ ##
421
+ # @private
422
+ # Invokes caller progress callback with snapshot.
423
+ #
424
+ # @param instruction [Instruction::NotifyProgress] Progress instruction
425
+ #
426
+ def execute_notify_progress instruction
427
+ @config.on_progress&.call instruction.progress
428
+ nil
429
+ end
430
+
431
+ ##
432
+ # @private
433
+ # Realigns in-memory buffer and underlying stream to match server offset.
434
+ #
435
+ # @param instruction [Instruction::RealignBuffer] Realign instruction
436
+ #
437
+ def execute_realign_buffer instruction
438
+ server_offset = instruction.server_offset
439
+ if @config.upload_size && server_offset > @config.upload_size
440
+ raise StreamMismatchError.new(
441
+ "Server reported offset #{server_offset} exceeds total upload size #{@config.upload_size}",
442
+ resume_handle: resume_handle
443
+ )
444
+ end
445
+
446
+ buffer_start = @buffer_start_offset
447
+ buffer_end = @buffer_start_offset + @buffer.bytesize
448
+
449
+ realign_case = if server_offset >= buffer_start && server_offset <= buffer_end
450
+ "within_buffer"
451
+ elsif server_offset < buffer_start
452
+ "rewind"
453
+ else
454
+ "fast_forward"
455
+ end
456
+
457
+ unseekable = realign_case == "rewind" && !@config.stream.respond_to?(:seek)
458
+ @upload_log.buffer_realign realign_case, server_offset: server_offset,
459
+ current_offset: buffer_start,
460
+ unseekable: unseekable
461
+
462
+ if server_offset >= buffer_start && server_offset <= buffer_end
463
+ realign_within_buffer server_offset
464
+ elsif server_offset < buffer_start
465
+ realign_rewind_stream server_offset
466
+ else
467
+ realign_fast_forward_stream server_offset, buffer_end
468
+ end
469
+
470
+ nil
471
+ end
472
+
473
+ ##
474
+ # @private
475
+ # Slices the in-memory buffer when server offset falls within current buffer range.
476
+ #
477
+ # @param server_offset [Integer] Target server offset
478
+ #
479
+ def realign_within_buffer server_offset
480
+ slice_index = server_offset - @buffer_start_offset
481
+ @buffer = @buffer.byteslice(slice_index..-1) || "".b
482
+ @buffer_start_offset = server_offset
483
+ end
484
+
485
+ ##
486
+ # @private
487
+ # Rewinds seekable stream when server offset is before current buffer window.
488
+ #
489
+ # @param server_offset [Integer] Target server offset
490
+ # @raise [UnseekableStreamError] If stream does not respond to #seek
491
+ #
492
+ def realign_rewind_stream server_offset
493
+ unless @config.stream.respond_to? :seek
494
+ raise UnseekableStreamError.new(
495
+ "Cannot rewind unseekable stream to offset #{server_offset} (buffered from #{@buffer_start_offset})",
496
+ resume_handle: resume_handle
497
+ )
498
+ end
499
+
500
+ if @config.upload_size.nil? && @config.stream.respond_to?(:size) && server_offset > @config.stream.size
501
+ raise StreamMismatchError.new(
502
+ "Server reported offset #{server_offset} exceeds stream size #{@config.stream.size}",
503
+ resume_handle: resume_handle
504
+ )
505
+ end
506
+
507
+ @config.stream.seek server_offset
508
+ @buffer = "".b
509
+ @buffer_start_offset = server_offset
510
+ end
511
+
512
+ ##
513
+ # @private
514
+ # Fast-forwards stream by seeking or discarding bytes.
515
+ #
516
+ # @param server_offset [Integer] Target server offset
517
+ # @param buffer_end [Integer] Current end offset of buffered data
518
+ #
519
+ def realign_fast_forward_stream server_offset, buffer_end
520
+ @buffer = "".b
521
+ if @config.stream.respond_to? :seek
522
+ if @config.upload_size.nil? && @config.stream.respond_to?(:size) && server_offset > @config.stream.size
523
+ raise StreamMismatchError.new(
524
+ "Server reported offset #{server_offset} exceeds stream size #{@config.stream.size}",
525
+ resume_handle: resume_handle
526
+ )
527
+ end
528
+ @config.stream.seek server_offset
529
+ else
530
+ needed_discard = server_offset - buffer_end
531
+ while needed_discard.positive?
532
+ chunk = @config.stream.read [needed_discard, 65_536].min
533
+ if chunk.nil? || chunk.empty?
534
+ raise StreamMismatchError.new(
535
+ "Stream encountered unexpected EOF during fast-forward to offset #{server_offset} " \
536
+ "(expected at least #{needed_discard} more bytes)",
537
+ resume_handle: resume_handle
538
+ )
539
+ end
540
+
541
+ needed_discard -= chunk.bytesize
542
+ end
543
+ end
544
+ @buffer_start_offset = server_offset
545
+ end
546
+
547
+ ##
548
+ # @private
549
+ # Fills internal buffer from stream up to target byte size or EOF.
550
+ #
551
+ # @param instruction [Instruction::FillBuffer] FillBuffer instruction
552
+ # @return [Event::ChunkRead] Chunk read event
553
+ #
554
+ def execute_fill_buffer instruction
555
+ target = instruction.target_bytesize
556
+ eof = false
557
+
558
+ while @buffer.bytesize < target
559
+ bytes_needed = target - @buffer.bytesize
560
+ chunk = @config.stream.read bytes_needed
561
+ if chunk.nil? || chunk.empty?
562
+ eof = true
563
+ break
564
+ end
565
+ @buffer << chunk.b
566
+ end
567
+
568
+ Event::ChunkRead.new bytes_buffered: @buffer.bytesize, eof: eof
569
+ end
570
+
571
+ ##
572
+ # @private
573
+ # Executes session initiation HTTP request.
574
+ #
575
+ # @param instruction [Instruction::SendStart] SendStart instruction
576
+ # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded]
577
+ #
578
+ def execute_send_start instruction
579
+ make_post_request instruction.url, headers: start_headers(instruction), body: instruction.body,
580
+ retry_policy: @start_retry_policy.dup.start!,
581
+ method_name: "#{@method_name_prefix}.start"
582
+ end
583
+
584
+ ##
585
+ # @private
586
+ # Builds initiation HTTP headers from instruction and config.
587
+ #
588
+ # Every header derived here is listed in {RESERVED_INITIAL_HEADERS}, and caller headers in
589
+ # that list are rejected when the config is built. The two sets are disjoint, so a plain merge
590
+ # cannot drop a driver header or duplicate one under a different casing.
591
+ #
592
+ # @param instruction [Instruction::SendStart] Start instruction
593
+ # @return [Hash<String, String>] HTTP request headers
594
+ #
595
+ def start_headers instruction
596
+ headers = { "X-Goog-Upload-Protocol" => "resumable", "X-Goog-Upload-Command" => "start" }
597
+ headers["X-Goog-Upload-Header-Content-Type"] = @config.content_type if @config.content_type
598
+ headers["X-Goog-Upload-Header-Content-Length"] = @config.upload_size.to_s if @config.upload_size
599
+ headers.merge instruction.headers || {}
600
+ end
601
+
602
+ ##
603
+ # @private
604
+ # Transmits a buffered chunk over HTTP.
605
+ #
606
+ # @param instruction [Instruction::SendChunk] SendChunk instruction
607
+ # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded]
608
+ #
609
+ def execute_send_chunk instruction
610
+ headers = {
611
+ "X-Goog-Upload-Command" => instruction.finalize ? "upload, finalize" : "upload",
612
+ "X-Goog-Upload-Offset" => instruction.offset.to_s,
613
+ "Content-Type" => @config.content_type || "application/octet-stream",
614
+ "Content-Length" => instruction.length.to_s
615
+ }
616
+ slice_index = instruction.offset - @buffer_start_offset
617
+ body = @buffer.byteslice slice_index, instruction.length
618
+
619
+ make_post_request instruction.url, headers: headers, body: body,
620
+ retry_policy: @data_plane_retry_policy.dup.start!, data_plane: true,
621
+ method_name: "#{@method_name_prefix}.upload"
622
+ end
623
+
624
+ ##
625
+ # @private
626
+ # Sends a standalone finalize command over HTTP.
627
+ #
628
+ # @param instruction [Instruction::SendFinalize] SendFinalize instruction
629
+ # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded]
630
+ #
631
+ def execute_send_finalize instruction
632
+ headers = {
633
+ "X-Goog-Upload-Command" => "finalize",
634
+ "X-Goog-Upload-Offset" => @core.state.offset.to_s,
635
+ "Content-Length" => "0"
636
+ }
637
+ make_post_request instruction.url, headers: headers, body: "",
638
+ retry_policy: @data_plane_retry_policy.dup.start!, data_plane: true,
639
+ method_name: "#{@method_name_prefix}.finalize"
640
+ end
641
+
642
+ ##
643
+ # @private
644
+ # Sends an offset query command over HTTP.
645
+ #
646
+ # Every query runs under the recovery episode's retry policy, a started copy of the control plane
647
+ # policy that lives for the whole episode rather than for one command. {RetryDecider} draws the delay
648
+ # between attempts of this query from it, and a query that continues the episode
649
+ # (`instruction.backoff`) first waits for its next delay via `RetryPolicy#perform_delay!`. So the
650
+ # backoff grows across every re-send in the episode, and the control plane `timeout` budgets the
651
+ # episode rather than each query.
652
+ #
653
+ # `perform_delay!` is used rather than `RetryPolicy#call` because `call` stops sleeping once the
654
+ # policy's deadline has passed, which would let recovery re-query with no delay at all. Once `delay`
655
+ # reaches `max_delay` it stays there, so recovery then queries once per `max_delay` until the global
656
+ # deadline.
657
+ #
658
+ # @param instruction [Instruction::SendQuery] SendQuery instruction
659
+ # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded]
660
+ #
661
+ def execute_send_query instruction
662
+ if instruction.backoff
663
+ return Event::GlobalDeadlineExceeded.new if deadline_exceeded?
664
+ @recovery_policy ||= @control_plane_retry_policy.dup.start!
665
+ @upload_log.recovery_backoff delay: @recovery_policy.delay,
666
+ attempt: @recovery_policy.perform_delay_count + 1
667
+ @recovery_policy.perform_delay!
668
+ else
669
+ @recovery_policy = @control_plane_retry_policy.dup.start!
670
+ end
671
+ headers = { "X-Goog-Upload-Command" => "query", "Content-Length" => "0" }
672
+ make_post_request instruction.url, headers: headers, body: "",
673
+ retry_policy: @recovery_policy,
674
+ method_name: "#{@method_name_prefix}.query"
675
+ end
676
+
677
+ ##
678
+ # @private
679
+ # Sends a cancellation command over HTTP.
680
+ #
681
+ # @param instruction [Instruction::SendCancel] SendCancel instruction
682
+ # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded]
683
+ #
684
+ def execute_send_cancel instruction
685
+ headers = { "X-Goog-Upload-Command" => "cancel", "Content-Length" => "0" }
686
+ make_post_request instruction.url, headers: headers, body: "",
687
+ retry_policy: @control_plane_retry_policy.dup.start!,
688
+ method_name: "#{@method_name_prefix}.cancel"
689
+ end
690
+
691
+ ##
692
+ # @private
693
+ # Sends an HTTP POST request through the client stub, re-sending it for as long as {RetryDecider} says
694
+ # so, and converts the final outcome into an event.
695
+ #
696
+ # Every attempt is logged. The event for the last attempt is returned unchanged: this method only
697
+ # chooses whether to send again, never what an outcome means; {Rules} decides that.
698
+ #
699
+ # @param url [String] Target URL
700
+ # @param headers [Hash] Request headers
701
+ # @param body [String] Request body
702
+ # @param retry_policy [Gapic::Common::RetryPolicy, nil] Started retry policy; `nil` sends once. For
703
+ # `query` this is the recovery episode's policy, started before this command, so its deadline and
704
+ # delay carry over from earlier commands in the episode. `started_at` still bounds each attempt's
705
+ # timeout and the post-delay budget check by this command's own start.
706
+ # @param data_plane [Boolean] Whether the request transmits upload bytes (`upload`, `finalize`)
707
+ # @param method_name [String, nil] RPC method name for logging
708
+ # @return [Event::HttpResponse, Event::RequestFailed, Event::GlobalDeadlineExceeded]
709
+ #
710
+ def make_post_request url, headers:, body:, retry_policy:, data_plane: false, method_name: nil
711
+ decider = retry_policy && RetryDecider.new(retry_policy, data_plane: data_plane)
712
+ started_at = monotonic_now
713
+ attempt = 1
714
+
715
+ loop do
716
+ return Event::GlobalDeadlineExceeded.new if deadline_exceeded?
717
+
718
+ timeout = request_timeout retry_policy, started_at: started_at
719
+ outcome = attempt_post_request url, headers: headers, body: body, timeout: timeout,
720
+ method_name: method_name, attempt: attempt
721
+ # If the global deadline expired during the HTTP call (e.g. Net::HTTP connection or read timeout
722
+ # triggered by request_timeout reaching 0 at @deadline), emit GlobalDeadlineExceeded rather than
723
+ # Event::RequestFailed. Otherwise, in states like Recovery where Event::RequestFailed is immediately
724
+ # terminal, the state machine would raise the underlying transport error instead of DeadlineExceededError.
725
+ return Event::GlobalDeadlineExceeded.new if outcome.is_a?(Exception) && deadline_exceeded?
726
+
727
+ event = outcome_event outcome
728
+ return event unless decider&.retry?(outcome) && command_budget_left?(retry_policy, started_at)
729
+
730
+ attempt += 1
731
+ end
732
+ end
733
+
734
+ ##
735
+ # @private
736
+ # Performs one `ClientStub` call, returning the error it raises instead of raising it.
737
+ #
738
+ # @param url [String] Target URL
739
+ # @param headers [Hash] Request headers
740
+ # @param body [String] Request body
741
+ # @param timeout [Numeric] Per-attempt timeout
742
+ # @param method_name [String, nil] RPC method name for logging
743
+ # @param attempt [Integer] 1-based attempt number, for logging
744
+ # @return [Object, StandardError] The client stub response, or the error it raised
745
+ #
746
+ def attempt_post_request url, headers:, body:, timeout:, method_name:, attempt:
747
+ options = { metadata: headers, retry_policy: CLIENT_STUB_NO_RETRY, timeout: timeout }
748
+ @upload_log.wire_send method: "POST", url: url, headers: headers,
749
+ start_attempt: attempt, body_size: body.to_s.bytesize, body: body
750
+ @client_stub.make_post_request uri: url, body: body, params: {}, options: options, method_name: method_name
751
+ rescue StandardError => e
752
+ e
753
+ end
754
+
755
+ ##
756
+ # @private
757
+ # Converts one attempt's outcome into an event and logs it.
758
+ #
759
+ # @param outcome [Object, StandardError] Client stub response or raised error
760
+ # @return [Event::HttpResponse, Event::RequestFailed]
761
+ #
762
+ def outcome_event outcome
763
+ event = if outcome.is_a? Exception
764
+ rescue_request_error outcome
765
+ else
766
+ Event::HttpResponse.new status: outcome.status, headers: outcome.headers || {}, body: outcome.body
767
+ end
768
+ if event.is_a? Event::HttpResponse
769
+ @upload_log.wire_receive event
770
+ else
771
+ @upload_log.wire_failure event
772
+ end
773
+ event
774
+ end
775
+
776
+ ##
777
+ # @private
778
+ # Whether the command's own retry budget has time left, checked after the backoff delay.
779
+ #
780
+ # `RetryPolicy#deadline` is private, so the budget is measured from when the command started.
781
+ #
782
+ # @param retry_policy [Gapic::Common::RetryPolicy] Command retry policy
783
+ # @param started_at [Numeric] Monotonic time the command started
784
+ # @return [Boolean]
785
+ #
786
+ def command_budget_left? retry_policy, started_at
787
+ monotonic_now - started_at < retry_policy.timeout
788
+ end
789
+
790
+ ##
791
+ # @private
792
+ # Current monotonic clock reading.
793
+ #
794
+ # @return [Float]
795
+ #
796
+ def monotonic_now
797
+ Process.clock_gettime Process::CLOCK_MONOTONIC
798
+ end
799
+
800
+ ##
801
+ # @private
802
+ # Converts client stub transport exceptions into canonical events.
803
+ #
804
+ # Classified by {RetryDecider.failure_kind}, the same function that decides how the error is retried.
805
+ #
806
+ # @param err [StandardError] Rescued transport error
807
+ # @return [Event::HttpResponse, Event::RequestFailed]
808
+ #
809
+ def rescue_request_error err
810
+ return rescue_faraday_error err if err.is_a? Faraday::Error
811
+
812
+ case RetryDecider.failure_kind err
813
+ when :status
814
+ Event::HttpResponse.new status: err.status_code, headers: err.headers || {}, body: err.message,
815
+ error: err
816
+ when :timeout
817
+ Event::RequestFailed.new kind: :timeout, message: err.message, source_error: err
818
+ when :connection_failed
819
+ Event::RequestFailed.new kind: :connection_failed, message: err.message, source_error: err
820
+ else
821
+ # Not a transport error at all, e.g. the authorization middleware failing to refresh a token. Nothing
822
+ # reached the server, so this is neither a connection failure nor a reason to recover.
823
+ Event::RequestFailed.new kind: :unknown, message: err.message, source_error: err
824
+ end
825
+ end
826
+
827
+ ##
828
+ # @private
829
+ # Converts Faraday client exceptions into canonical events.
830
+ #
831
+ # @param err [Faraday::Error] Rescued Faraday error
832
+ # @return [Event::HttpResponse, Event::RequestFailed]
833
+ #
834
+ def rescue_faraday_error err
835
+ case RetryDecider.failure_kind err
836
+ when :status
837
+ Event::HttpResponse.new(
838
+ status: err.response[:status],
839
+ headers: err.response[:headers] || {},
840
+ body: err.response[:body],
841
+ error: Gapic::Rest::Error.wrap_faraday_error(err)
842
+ )
843
+ when :timeout
844
+ Event::RequestFailed.new kind: :timeout, message: err.message, source_error: err
845
+ when :connection_failed
846
+ Event::RequestFailed.new kind: :connection_failed, message: err.message, source_error: err
847
+ else
848
+ Event::RequestFailed.new kind: :retries_exhausted, message: err.message, source_error: err
849
+ end
850
+ end
851
+ end
852
+ # rubocop:enable Metrics/ClassLength
853
+ end
854
+ end
855
+ end