gapic-common 1.4.0 → 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,1210 @@
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 "gapic/common/error"
18
+ require "gapic/rest/resumable_upload/errors"
19
+ require "gapic/rest/resumable_upload/data_types"
20
+ require "gapic/rest/resumable_upload/events"
21
+ require "gapic/rest/resumable_upload/instructions"
22
+
23
+ module Gapic
24
+ module Rest
25
+ module ResumableUpload
26
+ ##
27
+ # @private
28
+ # Pure functional transition engine for the Resumable Upload Protocol.
29
+ # Contains zero side-effects and zero persistent state.
30
+ #
31
+ # ### Model
32
+ #
33
+ # {Rules.decide} is the protocol. It is a total function of `[state.status, shape_of(event, state.status)]`
34
+ # returning a {Decision} that carries the next {State} and the instructions for the Driver to execute. Three
35
+ # vocabularies define it, each published as a frozen constant:
36
+ #
37
+ # * {STATUSES} - protocol lifecycle statuses a {State} may hold.
38
+ # * {SHAPES} - canonical event shapes that {Rules.shape_of} reduces raw events to.
39
+ # * {RECIPES} - transition handlers that {Rules.decide} may select.
40
+ #
41
+ # Every router arm maps one (status, shape) pair to exactly one recipe, and every recipe returns
42
+ # `[next_state, instructions]`. Adding a protocol behaviour means adding a shape, a recipe and an arm.
43
+ # It never means adding branching to the Driver.
44
+ #
45
+ # ### Trampoline invariant
46
+ #
47
+ # {Driver#run} executes as a synchronous trampoline loop, so every recipe in {RECIPES} must return an
48
+ # instruction batch that yields either:
49
+ #
50
+ # 1. **Exactly one** event-producing instruction (`FillBuffer` or `Send*`) and zero terminal instructions, or
51
+ # 2. **Exactly one** terminal instruction (`TerminateSuccess` or `TerminateFailure`) and zero event-producing
52
+ # instructions.
53
+ #
54
+ # A recipe returning zero event-producing instructions without terminating stalls the loop, and a recipe
55
+ # returning multiple event-producing instructions discards continuation events. The Driver validates each
56
+ # batch against {Instruction::CONTINUATION}, {Instruction::TERMINAL} and {Instruction::SIDE_EFFECT} before
57
+ # executing any instruction, rejecting malformed batches up front. Side-effect instructions
58
+ # ({Instruction::NotifyProgress}, {Instruction::RealignBuffer}) explicitly return `nil` in the Driver by
59
+ # construction, so only {Instruction::FillBuffer} and `Send*` instructions produce continuation events.
60
+ #
61
+ # ### State transition graph
62
+ #
63
+ # ```mermaid
64
+ # stateDiagram-v2
65
+ # [*] --> initializing
66
+ # initializing --> starting : start_upload
67
+ # initializing --> recovery : resume_upload
68
+ # starting --> transmission_reading : response_active
69
+ # transmission_reading --> transmission_sending : chunk_read_full
70
+ # transmission_sending --> transmission_reading : response_active
71
+ # transmission_reading --> finalizing_sending_upload : chunk_read_eof_with_data
72
+ # transmission_reading --> finalizing_sending_finalize : chunk_read_eof_empty
73
+ # finalizing_sending_upload --> success : response_final
74
+ # finalizing_sending_finalize --> success : response_final
75
+ # transmission_sending --> recovery : response_cat2 / connection_failed / timeout
76
+ # finalizing_sending_upload --> recovery : response_cat2 / connection_failed / timeout
77
+ # finalizing_sending_finalize --> recovery : response_cat2 / connection_failed / timeout
78
+ # recovery --> recovery : response_cat2
79
+ # recovery --> transmission_reading : response_active
80
+ # recovery --> success : response_final
81
+ # starting --> error : response_cat2 / response_fatal_bad_response / request_*
82
+ # recovery --> error : request_*
83
+ # transmission_sending --> rejected : response_rejected
84
+ # recovery --> rejected : response_rejected
85
+ # cancelling --> cancelled : response_cancelled
86
+ # transmission_sending --> cancelled : response_cancelled
87
+ # success --> [*]
88
+ # rejected --> [*]
89
+ # cancelled --> [*]
90
+ # error --> [*]
91
+ # ```
92
+ #
93
+ # The graph shows the protocol's intended path and its recoverable detours. Failure edges are largely omitted
94
+ # to keep it readable: every non-terminal status can also reach `error` (on `:global_deadline_exceeded`, on an
95
+ # unretriable request failure, on a bad or out-of-phase response, or on any unmatched event) and `rejected` (on
96
+ # `:response_rejected`), and every status listed in the `:user_cancel` arm can reach `cancelling`. `cancelling`
97
+ # in particular has only its success edge drawn; it fails like any other in-flight state. The
98
+ # `transmission_sending --> cancelled` edge stands in for all four statuses that reach `cancelled` on a
99
+ # server-reported out-of-band cancellation. {Rules.route} is the authoritative enumeration.
100
+ #
101
+ # ### Routing table
102
+ #
103
+ # {Rules.route} is the table: a pure function from `[status, shape]` to a recipe symbol, naming the handler
104
+ # without running it. {Rules.decide} runs whatever it names. Keeping selection separate from execution is what
105
+ # lets the tests enumerate the entire {STATUSES} x {SHAPES} product and assert every cell.
106
+ #
107
+ # Arms are written so that no arm matches a pair another arm also matches, which makes their order
108
+ # presentational. There is one deliberate exception:
109
+ #
110
+ # * `enter_recovery` and `fail_with_request_error` both match
111
+ # `[:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize,`
112
+ # `:request_connection_failed | :request_timeout]`. Recovery wins purely because its arm precedes
113
+ # `fail_with_request_error`.
114
+ #
115
+ # Two properties of the table are easy to misread and are called out at their arms:
116
+ #
117
+ # * `[:starting, :response_cat2]` fails instead of recovering, unlike the same shape during transmission
118
+ # and finalizing. There is no upload to recover to until initiation yields an upload URL.
119
+ # * `recovery` re-queries on `:response_cat2` with no attempt cap. Termination is guaranteed only by the
120
+ # global deadline the Driver enforces, not by anything in this module. Re-queries are spaced by the
121
+ # recovery episode's backoff, tracked in {State#recovery_offset} and signalled through
122
+ # {Instruction::SendQuery#backoff}; see `design/resumable_upload/implementation-guide.md` section 6.2.1.
123
+ #
124
+ # See `design/resumable_upload/implementation-guide.md` section 4 for the transition specification and
125
+ # section 6.1 for the error category taxonomy this module implements.
126
+ #
127
+ # rubocop:disable Metrics/ModuleLength
128
+ module Rules
129
+ ##
130
+ # @private
131
+ # Default chunk size in bytes (8 MB).
132
+ # @return [Integer]
133
+ DEFAULT_CHUNK_SIZE = 8_388_608 # 8 MB
134
+
135
+ # Failures are classified into three categories, which the rest of this module is written in terms of:
136
+ #
137
+ # * **Category 1 (transient transport)** - connection resets, TLS failures, load shedding. Retried inside
138
+ # the Driver by {Driver::RetryDecider} for initiation, `query` and `cancel`, and never retried for
139
+ # `upload` and `finalize`, where a lost connection leaves the server offset unknown. Whatever the Driver
140
+ # does not retry, or stops retrying, reaches this module as a `:request_*` shape.
141
+ # * **Category 2 (recoverable protocol)** - the client offset may be misaligned with the server, or a
142
+ # proxy stripped the protocol headers. Resolved by querying the server for its acknowledged offset
143
+ # and realigning, never by blindly retransmitting. Shape: `:response_cat2`.
144
+ # * **Category 3 (terminal)** - structurally invalid, unauthorized, rejected, or out of budget.
145
+ # Resolved by transitioning to `:error` or `:rejected` and emitting `Instruction::TerminateFailure`.
146
+ #
147
+ # See `design/resumable_upload/implementation-guide.md` section 6.1 for the full classification, and
148
+ # section 6.1.1 for which failures the Driver retries.
149
+
150
+ ##
151
+ # @private
152
+ # 4xx status codes the initiation, `query` and `cancel` retry policies retry by default.
153
+ #
154
+ # The data plane never retries a 4xx; see {Driver::RetryDecider}.
155
+ #
156
+ # @return [Array<Integer>]
157
+ RETRIABLE_4XX_STATUS_CODES = [409, 429, 499].freeze
158
+
159
+ ##
160
+ # @private
161
+ # 5xx status codes every retry policy retries by default.
162
+ #
163
+ # @return [Array<Integer>]
164
+ RETRIABLE_5XX_STATUS_CODES = [500, 503, 504].freeze
165
+
166
+ ##
167
+ # @private
168
+ # HTTP status codes eligible for Category 2 (recovery) handling.
169
+ #
170
+ # {Rules.classify_http_response} classifies a non-200 response whose `X-Goog-Upload-Status`
171
+ # is missing, empty or `active` as `:response_cat2` **only** if its status is listed here, and as
172
+ # `:response_fatal_bad_response` otherwise. It is an allowlist on purpose: a status nobody anticipated
173
+ # fails the upload rather than looping it through recovery.
174
+ #
175
+ # Every status either retry policy retries by default is listed, so a response the Driver stopped
176
+ # retrying still recovers. 408 and 502 are listed but not retried by default: neither has a gRPC code
177
+ # in `Gapic::Common::ErrorCodes`, so neither can be named in `retry_codes`.
178
+ #
179
+ # @return [Array<Integer>]
180
+ CAT2_STATUS_CODES = (RETRIABLE_4XX_STATUS_CODES + RETRIABLE_5XX_STATUS_CODES +
181
+ [400, 408, 412, 416, 502]).sort.freeze
182
+
183
+ ##
184
+ # @private
185
+ # Human-readable state descriptions for error reporting.
186
+ # @return [Hash<Symbol, String>]
187
+ STATE_DESCRIPTIONS = {
188
+ initializing: "initializing upload",
189
+ starting: "initiating upload session",
190
+ transmission_reading: "reading chunk from stream",
191
+ transmission_sending: "sending a chunk of data",
192
+ finalizing_sending_upload: "sending final data chunk",
193
+ finalizing_sending_finalize: "sending finalize command",
194
+ recovery: "querying upload offset for recovery",
195
+ cancelling: "cancelling upload session",
196
+ success: "in completed upload state",
197
+ cancelled: "in cancelled upload state",
198
+ error: "in error state",
199
+ rejected: "in rejected upload state"
200
+ }.freeze
201
+
202
+ ##
203
+ # @private
204
+ # Canonical list of protocol lifecycle statuses a {State} may hold. Guaranteed to match the keys of
205
+ # {STATE_DESCRIPTIONS} by the classification test suite.
206
+ #
207
+ # * `:initializing` - nothing dispatched yet; awaits `:start_upload` or `:resume_upload`.
208
+ # * `:starting` - initiation request in flight; no upload URL yet.
209
+ # * `:transmission_reading` - filling the buffer from the stream.
210
+ # * `:transmission_sending` - a non-final chunk is in flight.
211
+ # * `:finalizing_sending_upload` - the last chunk is in flight, combined with the finalize command.
212
+ # * `:finalizing_sending_finalize` - a standalone finalize is in flight; all data bytes were already sent.
213
+ # * `:recovery` - offset query in flight, either after a recoverable failure or as the first step of a
214
+ # resume.
215
+ # * `:cancelling` - cancel command in flight. Not reachable from the public API.
216
+ # * `:success` - terminal; the upload finalized.
217
+ # * `:cancelled` - terminal; the server acknowledged cancellation.
218
+ # * `:rejected` - terminal; the server refused the upload.
219
+ # * `:error` - terminal for this run; `last_error` holds the exception.
220
+ #
221
+ # `:success`, `:cancelled` and `:rejected` are finalized and yield no {ResumeHandle}. `:error` ends the
222
+ # run but may still be resumable from a fresh upload; see {Rules.resume_handle_from}.
223
+ #
224
+ # @return [Array<Symbol>]
225
+ STATUSES = [
226
+ :initializing,
227
+ :starting,
228
+ :transmission_reading,
229
+ :transmission_sending,
230
+ :finalizing_sending_upload,
231
+ :finalizing_sending_finalize,
232
+ :recovery,
233
+ :cancelling,
234
+ :success,
235
+ :cancelled,
236
+ :rejected,
237
+ :error
238
+ ].freeze
239
+
240
+ ##
241
+ # @private
242
+ # Terminal protocol lifecycle statuses in {STATUSES}.
243
+ # @return [Array<Symbol>]
244
+ TERMINAL_STATUSES = [:success, :cancelled, :rejected, :error].freeze
245
+
246
+ ##
247
+ # @private
248
+ # Canonical list of event shapes produced by {Rules.shape_of} and matched by {Rules.decide},
249
+ # grouped by the event family each is reduced from.
250
+ #
251
+ # Lifecycle signals, one shape each from {Event::StartUpload}, {Event::ResumeUpload}, {Event::Cancel}
252
+ # and {Event::GlobalDeadlineExceeded}: `:start_upload`, `:resume_upload`, `:user_cancel`,
253
+ # `:global_deadline_exceeded`.
254
+ #
255
+ # Stream reads, from {Event::ChunkRead} split by EOF and buffer occupancy. The three-way split is what
256
+ # lets a zero-length tail finalize without sending an empty chunk: `:chunk_read_full`,
257
+ # `:chunk_read_eof_with_data`, `:chunk_read_eof_empty`.
258
+ #
259
+ # Request failures, from {Event::RequestFailed} split by `kind`: `:request_timeout`,
260
+ # `:request_retries_exhausted`, `:request_connection_failed`, `:request_failed_unknown`.
261
+ #
262
+ # HTTP responses, from {Event::HttpResponse} split by `X-Goog-Upload-Status` and HTTP status:
263
+ # `:response_active`, `:response_final`, `:response_cancelled`, `:response_rejected`, `:response_cat2`,
264
+ # `:response_fatal_bad_response`.
265
+ #
266
+ # `:unknown` is a live shape rather than an error sentinel. It is what {Rules.shape_of} returns for anything
267
+ # it does not recognise, and it routes to {Rules.fail_with_unmatched_transition}.
268
+ #
269
+ # @return [Array<Symbol>]
270
+ SHAPES = [
271
+ :start_upload,
272
+ :resume_upload,
273
+ :user_cancel,
274
+ :global_deadline_exceeded,
275
+ :chunk_read_full,
276
+ :chunk_read_eof_with_data,
277
+ :chunk_read_eof_empty,
278
+ :request_timeout,
279
+ :request_retries_exhausted,
280
+ :request_connection_failed,
281
+ :request_failed_unknown,
282
+ :response_active,
283
+ :response_final,
284
+ :response_cancelled,
285
+ :response_rejected,
286
+ :response_cat2,
287
+ :response_fatal_bad_response,
288
+ :unknown
289
+ ].freeze
290
+
291
+ ##
292
+ # @private
293
+ # Canonical list of recipe symbols emitted by {Rules.decide}.
294
+ # @return [Array<Symbol>]
295
+ RECIPES = [
296
+ :start_session,
297
+ :resume_session,
298
+ :begin_transmission,
299
+ :send_chunk,
300
+ :send_upload_finalize,
301
+ :send_finalize,
302
+ :ack_chunk,
303
+ :enter_recovery,
304
+ :retry_recovery,
305
+ :realign_from_recovery,
306
+ :complete_upload_with_data,
307
+ :complete_upload_finalized,
308
+ :cancel_session,
309
+ :complete_cancellation,
310
+ :fail_with_cancelled,
311
+ :fail_with_deadline_exceeded,
312
+ :fail_with_rejected,
313
+ :fail_with_bad_response,
314
+ :fail_with_request_error,
315
+ :fail_with_unmatched_transition
316
+ ].freeze
317
+
318
+ ##
319
+ # @private
320
+ # Mapping of notifying recipes to their emitted {Progress} phase.
321
+ # @return [Hash<Symbol, Symbol>]
322
+ RECIPE_PHASES = {
323
+ start_session: :initiating,
324
+ resume_session: :initiating,
325
+ begin_transmission: :uploading,
326
+ ack_chunk: :uploading,
327
+ realign_from_recovery: :uploading,
328
+ enter_recovery: :recovering,
329
+ send_upload_finalize: :finalizing,
330
+ send_finalize: :finalizing,
331
+ complete_upload_with_data: :completed,
332
+ complete_upload_finalized: :completed,
333
+ cancel_session: :cancelling
334
+ }.freeze
335
+
336
+ ##
337
+ # @private
338
+ # Recipes that do not emit {Instruction::NotifyProgress}.
339
+ # @return [Array<Symbol>]
340
+ NON_NOTIFYING_RECIPES = [
341
+ :send_chunk,
342
+ :retry_recovery,
343
+ :complete_cancellation,
344
+ :fail_with_cancelled,
345
+ :fail_with_deadline_exceeded,
346
+ :fail_with_rejected,
347
+ :fail_with_bad_response,
348
+ :fail_with_request_error,
349
+ :fail_with_unmatched_transition
350
+ ].freeze
351
+
352
+ ##
353
+ # @private
354
+ # Classifies incoming event into a canonical shape symbol.
355
+ #
356
+ # @param event [Object] Input event
357
+ # @param state_status [Symbol] Status of the state the event arrives in. Only HTTP responses use it; see
358
+ # {Rules.classify_http_response}.
359
+ # @return [Symbol] Canonical event shape
360
+ def self.shape_of event, state_status
361
+ case event
362
+ when Event::StartUpload, Event::StartUpload.singleton_class
363
+ :start_upload
364
+ when Event::ResumeUpload, Event::ResumeUpload.singleton_class
365
+ :resume_upload
366
+ when Event::ChunkRead
367
+ classify_chunk_read event
368
+ when Event::Cancel, Event::Cancel.singleton_class
369
+ :user_cancel
370
+ when Event::GlobalDeadlineExceeded, Event::GlobalDeadlineExceeded.singleton_class
371
+ :global_deadline_exceeded
372
+ when Event::RequestFailed
373
+ classify_request_failed event
374
+ when Event::HttpResponse
375
+ classify_http_response event, state_status
376
+ when Class
377
+ classify_event_class event
378
+ else
379
+ :unknown
380
+ end
381
+ end
382
+
383
+ ##
384
+ # @private
385
+ # Top-level transition decision engine. Routes `[state.status, shape_of(event, state.status)]` to a recipe
386
+ # and runs it.
387
+ #
388
+ # @param state [State] Current state
389
+ # @param event [Object] Input event
390
+ # @param config [StartUploadConfig, ResumeUploadConfig] Static configuration
391
+ # @return [Decision] Decision snapshot
392
+ # @raise [InternalError] If {Rules.shape_of} or {Rules.route} produces an unlisted value
393
+ def self.decide state, event, config
394
+ shape = shape_of event, state.status
395
+ unless SHAPES.include? shape
396
+ raise InternalError, "Resumable upload internal error: shape_of returned unknown shape #{shape.inspect}"
397
+ end
398
+
399
+ recipe = route state.status, shape
400
+ unless RECIPES.include? recipe
401
+ raise InternalError, "Resumable upload internal error: decide selected unknown recipe #{recipe.inspect}"
402
+ end
403
+
404
+ next_state, instructions = public_send recipe, state, event, config
405
+ Decision.new(
406
+ from_status: state.status,
407
+ shape: shape,
408
+ recipe: recipe,
409
+ next_state: next_state,
410
+ instructions: instructions
411
+ )
412
+ end
413
+
414
+ ##
415
+ # @private
416
+ # The routing table. Maps a `[status, shape]` pair to the recipe that handles it.
417
+ #
418
+ # Pure and side-effect free: it selects a recipe without running it, so the whole
419
+ # {STATUSES} x {SHAPES} product can be enumerated and asserted directly. {Rules.decide} is the only
420
+ # caller in production code.
421
+ #
422
+ # Arms are written so that no arm matches a pair another arm also matches, meaning
423
+ # the order does not matter, with a single deliberate exception, flagged in place,
424
+ # where `enter_recovery` must precede `fail_with_request_error`.
425
+ #
426
+ # @param status [Symbol] Current protocol status, one of {STATUSES}
427
+ # @param shape [Symbol] Canonical event shape, one of {SHAPES}
428
+ # @return [Symbol] Recipe symbol, one of {RECIPES}
429
+ #
430
+ # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
431
+ def self.route status, shape
432
+ case [status, shape]
433
+ in [:initializing, :start_upload]
434
+ :start_session
435
+ in [:initializing, :resume_upload]
436
+ :resume_session
437
+ in [:starting, :response_active]
438
+ :begin_transmission
439
+ in [:transmission_reading, :chunk_read_full]
440
+ :send_chunk
441
+ in [:transmission_reading, :chunk_read_eof_with_data]
442
+ :send_upload_finalize
443
+ in [:transmission_reading, :chunk_read_eof_empty]
444
+ :send_finalize
445
+ in [:transmission_sending, :response_active]
446
+ :ack_chunk
447
+ # Order matters: `enter_recovery` and `fail_with_request_error` below both match
448
+ # `[:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize,
449
+ # :request_connection_failed | :request_timeout]`. Recovery wins purely because this arm comes first.
450
+ # This is the only overlap in the table.
451
+ in [:transmission_sending | :finalizing_sending_upload | :finalizing_sending_finalize,
452
+ :response_cat2 | :request_connection_failed | :request_timeout]
453
+ :enter_recovery
454
+ in [:finalizing_sending_upload, :response_final]
455
+ :complete_upload_with_data
456
+ in [:finalizing_sending_finalize | :recovery, :response_final]
457
+ :complete_upload_finalized
458
+ in [:recovery, :response_active]
459
+ :realign_from_recovery
460
+ # Re-query with no attempt cap. Only the Driver's global deadline guarantees termination.
461
+ in [:recovery, :response_cat2]
462
+ :retry_recovery
463
+ in [:cancelling, :response_cancelled]
464
+ :complete_cancellation
465
+ # A `cancelled` status on a session this client did not ask to cancel: the session was terminated
466
+ # out of band and no byte of it will ever be accepted again. Terminal, and deliberately not a bad
467
+ # response — the server answered correctly, so the caller gets no resume handle to loop on.
468
+ in [:transmission_sending | :finalizing_sending_upload |
469
+ :finalizing_sending_finalize | :recovery, :response_cancelled]
470
+ :fail_with_cancelled
471
+ in [_, :global_deadline_exceeded]
472
+ :fail_with_deadline_exceeded
473
+ in [:transmission_reading | :transmission_sending | :finalizing_sending_upload |
474
+ :finalizing_sending_finalize | :recovery, :user_cancel]
475
+ :cancel_session
476
+ in [:starting | :transmission_sending | :finalizing_sending_upload |
477
+ :finalizing_sending_finalize | :recovery | :cancelling, :response_rejected]
478
+ :fail_with_rejected
479
+ # Every HTTP response shape in an HTTP-awaiting status that no arm above claims. Spelled out per
480
+ # status rather than as a `[six statuses, five shapes]` cross product: the product would also cover
481
+ # combinations which are handled above.
482
+ #
483
+ # Note `[:starting, :response_cat2]`: initiation fails on a Category 2 response rather than
484
+ # recovering, unlike the transmission and finalizing statuses, because there is no upload to
485
+ # recover to until initiation has returned an upload URL.
486
+ in [:starting, :response_final | :response_cancelled |
487
+ :response_cat2 | :response_fatal_bad_response] |
488
+ [:transmission_sending, :response_final | :response_fatal_bad_response] |
489
+ [:finalizing_sending_upload | :finalizing_sending_finalize,
490
+ :response_active | :response_fatal_bad_response] |
491
+ [:recovery, :response_fatal_bad_response] |
492
+ [:cancelling, :response_active | :response_final |
493
+ :response_cat2 | :response_fatal_bad_response]
494
+ :fail_with_bad_response
495
+ in [:starting | :transmission_sending | :finalizing_sending_upload |
496
+ :finalizing_sending_finalize | :recovery | :cancelling,
497
+ :request_retries_exhausted | :request_connection_failed | :request_timeout |
498
+ :request_failed_unknown]
499
+ :fail_with_request_error
500
+ else
501
+ :fail_with_unmatched_transition
502
+ end
503
+ end
504
+ # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength
505
+
506
+ ##
507
+ # @private
508
+ # Top-level transition router. Matches [state.status, shape].
509
+ #
510
+ # @param state [State] Current state
511
+ # @param event [Object] Input event
512
+ # @param config [StartUploadConfig, ResumeUploadConfig] Static configuration
513
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
514
+ def self.step state, event, config
515
+ decision = decide state, event, config
516
+ [decision.next_state, decision.instructions]
517
+ end
518
+
519
+ ##
520
+ # @private
521
+ # Initiates the upload session.
522
+ #
523
+ # @param state [State] Current state
524
+ # @param _event [Object] Dispatched event
525
+ # @param config [StartUploadConfig] Session configuration; only an initiating run reaches this recipe
526
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
527
+ def self.start_session state, _event, config
528
+ next_state = state.with status: :starting
529
+ progress = Progress.new phase: :initiating, bytes_uploaded: next_state.offset, total_bytes: config.upload_size
530
+ instructions = [
531
+ Instruction::NotifyProgress.new(progress: progress),
532
+ Instruction::SendStart.new(
533
+ url: config.initial_url,
534
+ headers: config.initial_headers,
535
+ body: config.initial_body
536
+ )
537
+ ]
538
+ [next_state, instructions]
539
+ end
540
+
541
+ ##
542
+ # @private
543
+ # Resumes an existing upload session by transitioning to recovery and querying backend offset.
544
+ #
545
+ # Opens a recovery episode at offset 0, so the first query is sent at once.
546
+ #
547
+ # @param state [State] Current state
548
+ # @param _event [Object] Dispatched event
549
+ # @param config [ResumeUploadConfig] Resume session configuration
550
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
551
+ def self.resume_session state, _event, config
552
+ next_state = state.with(
553
+ status: :recovery,
554
+ upload_url: config.upload_url,
555
+ chunk_size: config.chunk_size,
556
+ offset: 0,
557
+ recovery_offset: 0
558
+ )
559
+ progress = Progress.new(
560
+ phase: :initiating,
561
+ bytes_uploaded: 0,
562
+ total_bytes: config.upload_size
563
+ )
564
+ instructions = [
565
+ Instruction::NotifyProgress.new(progress: progress),
566
+ Instruction::SendQuery.new(url: config.upload_url)
567
+ ]
568
+ [next_state, instructions]
569
+ end
570
+
571
+ ##
572
+ # @private
573
+ # Processes initiation response and begins data reading.
574
+ #
575
+ # @param state [State] Current state
576
+ # @param event [Event::HttpResponse] Initiation response
577
+ # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration
578
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
579
+ def self.begin_transmission state, event, config
580
+ granularity_str = header_value event.headers, "x-goog-upload-chunk-granularity"
581
+ granularity = parse_header_non_negative_integer granularity_str
582
+ granularity = nil unless granularity&.positive?
583
+ chunk_size = resolve_chunk_size config.chunk_size, granularity
584
+ upload_url = header_value event.headers, "x-goog-upload-url"
585
+ next_state = state.with(
586
+ status: :transmission_reading,
587
+ upload_url: upload_url,
588
+ chunk_granularity: granularity,
589
+ chunk_size: chunk_size,
590
+ offset: 0,
591
+ in_flight_length: 0
592
+ )
593
+ progress = Progress.new phase: :uploading, bytes_uploaded: next_state.offset, total_bytes: config.upload_size
594
+ instructions = [
595
+ Instruction::NotifyProgress.new(progress: progress),
596
+ Instruction::FillBuffer.new(target_bytesize: chunk_size)
597
+ ]
598
+ [next_state, instructions]
599
+ end
600
+
601
+ ##
602
+ # @private
603
+ # Emits instruction to transmit a filled data chunk.
604
+ #
605
+ # @param state [State] Current state
606
+ # @param event [Event::ChunkRead] Chunk read event
607
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
608
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
609
+ def self.send_chunk state, event, _config
610
+ next_state = state.with(
611
+ status: :transmission_sending,
612
+ in_flight_length: event.bytes_buffered
613
+ )
614
+ instructions = [
615
+ Instruction::SendChunk.new(
616
+ url: state.upload_url,
617
+ offset: state.offset,
618
+ length: event.bytes_buffered,
619
+ finalize: false
620
+ )
621
+ ]
622
+ [next_state, instructions]
623
+ end
624
+
625
+ ##
626
+ # @private
627
+ # Emits instruction to transmit the final data chunk with finalize.
628
+ #
629
+ # @param state [State] Current state
630
+ # @param event [Event::ChunkRead] Chunk read event with EOF
631
+ # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration
632
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
633
+ def self.send_upload_finalize state, event, config
634
+ next_state = state.with(
635
+ status: :finalizing_sending_upload,
636
+ in_flight_length: event.bytes_buffered
637
+ )
638
+ progress = Progress.new phase: :finalizing, bytes_uploaded: next_state.offset, total_bytes: config.upload_size
639
+ instructions = [
640
+ Instruction::NotifyProgress.new(progress: progress),
641
+ Instruction::SendChunk.new(
642
+ url: state.upload_url,
643
+ offset: state.offset,
644
+ length: event.bytes_buffered,
645
+ finalize: true
646
+ )
647
+ ]
648
+ [next_state, instructions]
649
+ end
650
+
651
+ ##
652
+ # @private
653
+ # Emits instruction to send a zero-length finalize command.
654
+ #
655
+ # @param state [State] Current state
656
+ # @param _event [Object] Dispatched event
657
+ # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration
658
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
659
+ def self.send_finalize state, _event, config
660
+ next_state = state.with(
661
+ status: :finalizing_sending_finalize,
662
+ in_flight_length: 0
663
+ )
664
+ progress = Progress.new phase: :finalizing, bytes_uploaded: next_state.offset, total_bytes: config.upload_size
665
+ instructions = [
666
+ Instruction::NotifyProgress.new(progress: progress),
667
+ Instruction::SendFinalize.new(url: state.upload_url)
668
+ ]
669
+ [next_state, instructions]
670
+ end
671
+
672
+ ##
673
+ # @private
674
+ # Acknowledges transmitted chunk and advances offset.
675
+ #
676
+ # Closes any open recovery episode: the server acknowledged a data command.
677
+ #
678
+ # @param state [State] Current state
679
+ # @param _event [Object] Dispatched event
680
+ # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration
681
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
682
+ def self.ack_chunk state, _event, config
683
+ new_offset = state.offset + state.in_flight_length
684
+ next_state = state.with(
685
+ status: :transmission_reading,
686
+ offset: new_offset,
687
+ in_flight_length: 0,
688
+ recovery_offset: nil
689
+ )
690
+ progress = Progress.new phase: :uploading, bytes_uploaded: new_offset, total_bytes: config.upload_size
691
+ instructions = [
692
+ Instruction::NotifyProgress.new(progress: progress),
693
+ Instruction::RealignBuffer.new(server_offset: new_offset),
694
+ Instruction::FillBuffer.new(target_bytesize: state.chunk_size)
695
+ ]
696
+ [next_state, instructions]
697
+ end
698
+
699
+ ##
700
+ # @private
701
+ # Transitions to recovery state to query backend byte offset.
702
+ #
703
+ # Opens a recovery episode at the current offset and queries at once, unless an episode is already
704
+ # open. An open episode means the server has confirmed no new bytes since the previous recovery (an
705
+ # upload keeps failing while the query keeps answering `active`), so the query waits for the episode's
706
+ # next backoff delay instead.
707
+ #
708
+ # @param state [State] Current state
709
+ # @param _event [Object] Dispatched event
710
+ # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration
711
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
712
+ def self.enter_recovery state, _event, config
713
+ episode_open = !state.recovery_offset.nil?
714
+ next_state = state.with(
715
+ status: :recovery,
716
+ in_flight_length: 0,
717
+ recovery_offset: episode_open ? state.recovery_offset : state.offset
718
+ )
719
+ progress = Progress.new phase: :recovering, bytes_uploaded: next_state.offset, total_bytes: config.upload_size
720
+ instructions = [
721
+ Instruction::NotifyProgress.new(progress: progress),
722
+ Instruction::SendQuery.new(url: state.upload_url, backoff: episode_open)
723
+ ]
724
+ [next_state, instructions]
725
+ end
726
+
727
+ ##
728
+ # @private
729
+ # Retries offset query during recovery, after the open recovery episode's next backoff delay.
730
+ #
731
+ # @param state [State] Current state
732
+ # @param _event [Object] Dispatched event
733
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
734
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
735
+ def self.retry_recovery state, _event, _config
736
+ next_state = state.with(
737
+ status: :recovery,
738
+ in_flight_length: 0
739
+ )
740
+ [next_state, [Instruction::SendQuery.new(url: state.upload_url, backoff: true)]]
741
+ end
742
+
743
+ ##
744
+ # @private
745
+ # Completes upload when final chunk transmission succeeds.
746
+ #
747
+ # @param state [State] Current state
748
+ # @param event [Event::HttpResponse] Final HTTP response
749
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
750
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
751
+ def self.complete_upload_with_data state, event, _config
752
+ new_offset = state.offset + state.in_flight_length
753
+ next_state = state.with(
754
+ status: :success,
755
+ offset: new_offset,
756
+ in_flight_length: 0
757
+ )
758
+ # `total_bytes` is set from `new_offset` even when `config.upload_size` is nil (see `Progress#total_bytes`).
759
+ progress = Progress.new phase: :completed, bytes_uploaded: new_offset, total_bytes: new_offset
760
+ instructions = [
761
+ Instruction::NotifyProgress.new(progress: progress),
762
+ Instruction::TerminateSuccess.new(response: event)
763
+ ]
764
+ [next_state, instructions]
765
+ end
766
+
767
+ ##
768
+ # @private
769
+ # Completes upload when standalone finalize succeeds.
770
+ #
771
+ # @param state [State] Current state
772
+ # @param event [Event::HttpResponse] Final HTTP response
773
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
774
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
775
+ def self.complete_upload_finalized state, event, _config
776
+ next_state = state.with(
777
+ status: :success,
778
+ in_flight_length: 0
779
+ )
780
+ # `total_bytes` is set from `next_state.offset` even when `config.upload_size` is nil
781
+ # (see `Progress#total_bytes`).
782
+ progress = Progress.new phase: :completed, bytes_uploaded: next_state.offset, total_bytes: next_state.offset
783
+ instructions = [
784
+ Instruction::NotifyProgress.new(progress: progress),
785
+ Instruction::TerminateSuccess.new(response: event)
786
+ ]
787
+ [next_state, instructions]
788
+ end
789
+
790
+ ##
791
+ # @private
792
+ # Realigns buffer and resumes transmission from recovered offset.
793
+ #
794
+ # Closes the open recovery episode only if the server confirmed bytes beyond the offset the episode
795
+ # began at. Otherwise the episode stays open: the command that failed has not yet been re-sent
796
+ # successfully, and if it fails again the next query must keep backing off. {Rules.ack_chunk} closes
797
+ # the episode once the re-sent command succeeds.
798
+ #
799
+ # @param state [State] Current state
800
+ # @param event [Event::HttpResponse] Query response containing acknowledged offset
801
+ # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration
802
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
803
+ def self.realign_from_recovery state, event, config
804
+ server_offset_str = header_value event.headers, "x-goog-upload-size-received"
805
+ # Never nil here: {Rules.classify_http_response} classifies a query answer without a parseable offset
806
+ # as Category 2, so it is routed to {Rules.retry_recovery} instead.
807
+ server_offset = parse_header_non_negative_integer server_offset_str
808
+ # `>`, not `!=`: a lower offset is a regression, not progress. Closing on it would reset the backoff,
809
+ # and a server whose reported offset flips between two values would then query with no delay forever.
810
+ progressed = state.recovery_offset.nil? || server_offset > state.recovery_offset
811
+ next_state = state.with(
812
+ status: :transmission_reading,
813
+ offset: server_offset,
814
+ in_flight_length: 0,
815
+ recovery_offset: progressed ? nil : state.recovery_offset
816
+ )
817
+ progress = Progress.new phase: :uploading, bytes_uploaded: server_offset, total_bytes: config.upload_size
818
+ instructions = [
819
+ Instruction::NotifyProgress.new(progress: progress),
820
+ Instruction::RealignBuffer.new(server_offset: server_offset),
821
+ Instruction::FillBuffer.new(target_bytesize: state.chunk_size)
822
+ ]
823
+ [next_state, instructions]
824
+ end
825
+
826
+ ##
827
+ # @private
828
+ # Completes session cancellation and emits failure instruction.
829
+ #
830
+ # @param state [State] Current state
831
+ # @param event [Object] Cancellation response event
832
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
833
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
834
+ def self.complete_cancellation state, event, _config
835
+ err = UploadCancelledError.from event
836
+ next_state = state.with status: :cancelled, in_flight_length: 0, last_error: err
837
+ [next_state, [Instruction::TerminateFailure.new(error: err)]]
838
+ end
839
+
840
+ ##
841
+ # @private
842
+ # Terminates the run after the server reported a session this client did not cancel as cancelled.
843
+ #
844
+ # Distinct from {Rules.complete_cancellation}, which acknowledges a cancellation this client
845
+ # requested. Both land in `:cancelled` with an {UploadCancelledError}, so the caller sees one error
846
+ # type for "this session is gone" regardless of who ended it. No resume handle is attached:
847
+ # {UploadCancelledError} does not include {HasResumeHandle}, because a cancelled session will never
848
+ # accept another byte and retrying against it cannot succeed.
849
+ #
850
+ # @param state [State] Current state
851
+ # @param _event [Event::HttpResponse] Response carrying `X-Goog-Upload-Status: cancelled`
852
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
853
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
854
+ def self.fail_with_cancelled state, _event, _config
855
+ action = STATE_DESCRIPTIONS[state.status] || "processing #{state.status}"
856
+ err = UploadCancelledError.new "Resumable upload session was cancelled (detected while #{action})"
857
+ next_state = state.with status: :cancelled, in_flight_length: 0, last_error: err
858
+ [next_state, [Instruction::TerminateFailure.new(error: err)]]
859
+ end
860
+
861
+ ##
862
+ # @private
863
+ # Initiates session cancellation request.
864
+ #
865
+ # @param state [State] Current state
866
+ # @param _event [Object] Dispatched event
867
+ # @param config [StartUploadConfig, ResumeUploadConfig] Session configuration
868
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
869
+ def self.cancel_session state, _event, config
870
+ next_state = state.with status: :cancelling
871
+ progress = Progress.new phase: :cancelling, bytes_uploaded: next_state.offset, total_bytes: config.upload_size
872
+ instructions = [
873
+ Instruction::NotifyProgress.new(progress: progress),
874
+ Instruction::SendCancel.new(url: state.upload_url)
875
+ ]
876
+ [next_state, instructions]
877
+ end
878
+
879
+ ##
880
+ # @private
881
+ # Extracts a {ResumeHandle} from current protocol state.
882
+ # Completed uploads (`:success`), rejected uploads (`:rejected`), and cancelled uploads
883
+ # (`:cancelled`) are finalized and not resumable, returning `nil`.
884
+ #
885
+ # @param state [State] Protocol state
886
+ # @return [ResumeHandle, nil] Resume handle if upload URL is established and resumable, or nil
887
+ def self.resume_handle_from state
888
+ return nil if state.nil? || state.upload_url.nil? || [:rejected, :cancelled, :success].include?(state.status)
889
+
890
+ ResumeHandle.new upload_url: state.upload_url, chunk_size: state.chunk_size
891
+ end
892
+
893
+ ##
894
+ # @private
895
+ # Fails upload due to exceeded execution deadline.
896
+ #
897
+ # @param state [State] Current state
898
+ # @param _event [Object] Dispatched event
899
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
900
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
901
+ def self.fail_with_deadline_exceeded state, _event, _config
902
+ handle = resume_handle_from state
903
+ err = DeadlineExceededError.new resume_handle: handle
904
+ next_state = state.with(
905
+ status: :error,
906
+ in_flight_length: 0,
907
+ last_error: err
908
+ )
909
+ [next_state, [Instruction::TerminateFailure.new(error: err)]]
910
+ end
911
+
912
+ ##
913
+ # @private
914
+ # Fails upload when backend explicitly rejects session.
915
+ #
916
+ # @param state [State] Current state
917
+ # @param event [Event::HttpResponse] Rejected HTTP response
918
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
919
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
920
+ def self.fail_with_rejected state, event, _config
921
+ err = UploadRejectedError.from event
922
+ next_state = state.with(
923
+ status: :rejected,
924
+ in_flight_length: 0,
925
+ last_error: err
926
+ )
927
+ [next_state, [Instruction::TerminateFailure.new(error: err)]]
928
+ end
929
+
930
+ ##
931
+ # @private
932
+ # Fails upload when an unrecoverable HTTP response is encountered.
933
+ #
934
+ # Covers both malformed responses and well-formed ones that arrived in the wrong protocol phase, so
935
+ # the message names the phase: an `X-Goog-Upload-Status` of `final` is unremarkable on its own and
936
+ # only makes sense as a failure once the reader knows a non-final chunk was in flight.
937
+ #
938
+ # @param state [State] Current state
939
+ # @param event [Event::HttpResponse] Fatal or out-of-phase HTTP response
940
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
941
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
942
+ def self.fail_with_bad_response state, event, _config
943
+ handle = resume_handle_from state
944
+ action = STATE_DESCRIPTIONS[state.status] || "processing #{state.status}"
945
+ err = BadResponseError.from event, resume_handle: handle,
946
+ prefix: "Resumable upload failed while #{action}"
947
+ next_state = state.with(
948
+ status: :error,
949
+ in_flight_length: 0,
950
+ last_error: err
951
+ )
952
+ [next_state, [Instruction::TerminateFailure.new(error: err)]]
953
+ end
954
+
955
+ ##
956
+ # @private
957
+ # Fails upload when an unrecoverable network or request error occurs.
958
+ #
959
+ # @param state [State] Current state
960
+ # @param event [Event::RequestFailed] Request failure event
961
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
962
+ # @return [Array<State, Array<Object>>] Tuple of [next_state, instructions]
963
+ def self.fail_with_request_error state, event, _config
964
+ handle = resume_handle_from state
965
+ err = RequestFailedError.from event, resume_handle: handle
966
+ next_state = state.with(
967
+ status: :error,
968
+ in_flight_length: 0,
969
+ last_error: err
970
+ )
971
+ [next_state, [Instruction::TerminateFailure.new(error: err)]]
972
+ end
973
+
974
+ ##
975
+ # @private
976
+ # Raises {InvalidTransitionError} for an unmatched state and event pair.
977
+ #
978
+ # Only reachable on an internal sequencing bug. Every externally caused failure — including an HTTP
979
+ # response whose upload status does not match the phase of the request in flight — is claimed by an
980
+ # arm in {Rules.route} and terminates through a recipe instead of raising from here.
981
+ #
982
+ # No resume handle is attached, for the same reason: this reports a defect in the library, not a
983
+ # resumable upload condition. A caller that still wants the handle can read it from
984
+ # {Gapic::ResumableUpload#resume_handle}.
985
+ #
986
+ # @param state [State] Current state
987
+ # @param event [Object] Dispatched event
988
+ # @param _config [StartUploadConfig, ResumeUploadConfig] Session configuration
989
+ # @raise [InvalidTransitionError]
990
+ def self.fail_with_unmatched_transition state, event, _config
991
+ shape = shape_of event, state.status
992
+ action = STATE_DESCRIPTIONS[state.status] || "processing #{state.status}"
993
+ happened = describe_event event, shape
994
+ message = "Resumable upload failed while #{action}: #{happened}."
995
+ response = event.is_a?(Event::HttpResponse) ? event : nil
996
+ raise InvalidTransitionError.new(
997
+ message,
998
+ state: state.status,
999
+ event: event,
1000
+ response: response
1001
+ )
1002
+ end
1003
+
1004
+ ##
1005
+ # @private
1006
+ # Formats human-readable summary of an event.
1007
+ #
1008
+ # @param event [Object] Event instance
1009
+ # @param shape [Symbol] Event shape symbol
1010
+ # @return [String] Formatted description
1011
+ def self.describe_event event, shape
1012
+ case event
1013
+ when Event::HttpResponse
1014
+ upload_status = event.headers["x-goog-upload-status"] || event.headers["X-Goog-Upload-Status"]
1015
+ status_desc = upload_status ? "'#{upload_status}'" : "missing"
1016
+ "received an unexpected HTTP #{event.status} response (X-Goog-Upload-Status: #{status_desc})"
1017
+ when Event::ChunkRead
1018
+ "received unexpected stream chunk read (#{event.bytes_buffered} bytes, eof: #{event.eof})"
1019
+ when Event::RequestFailed
1020
+ "encountered unexpected request failure (#{event.kind}: #{event.message})"
1021
+ else
1022
+ "received unexpected event #{shape} (#{event.class.name})"
1023
+ end
1024
+ end
1025
+
1026
+ ##
1027
+ # @private
1028
+ # Parses a numeric protocol header value strictly.
1029
+ #
1030
+ # Accepts only a non-negative decimal integer, optionally surrounded by whitespace. Anything else —
1031
+ # `nil`, empty, signed, fractional, or trailing garbage such as `"12abc"` — returns `nil`, so callers
1032
+ # treat a malformed header the same as a missing one. `String#to_i` is deliberately not used: it turns
1033
+ # `"12abc"` into `12` and `"-5"` into `-5`.
1034
+ #
1035
+ # @param value [String, nil] Raw header value
1036
+ # @return [Integer, nil] Parsed value, or `nil` if the value is not a non-negative integer
1037
+ def self.parse_header_non_negative_integer value
1038
+ return nil unless value.is_a? String
1039
+
1040
+ stripped = value.strip
1041
+ return nil unless stripped.match?(/\A\d+\z/)
1042
+
1043
+ Integer stripped, 10
1044
+ end
1045
+
1046
+ ##
1047
+ # @private
1048
+ # Resolves effective chunk size given user specification and backend granularity.
1049
+ #
1050
+ # @param user_chunk_size [Integer, nil] Configured chunk size
1051
+ # @param chunk_granularity [Integer, nil] Backend alignment granularity
1052
+ # @return [Integer] Effective chunk size in bytes
1053
+ def self.resolve_chunk_size user_chunk_size, chunk_granularity
1054
+ base_size = user_chunk_size || DEFAULT_CHUNK_SIZE
1055
+ return base_size if chunk_granularity.nil? || chunk_granularity <= 0
1056
+ return chunk_granularity if base_size <= chunk_granularity
1057
+
1058
+ base_size - (base_size % chunk_granularity)
1059
+ end
1060
+
1061
+ ##
1062
+ # @private
1063
+ # Classifies an HTTP response into a canonical response shape.
1064
+ #
1065
+ # Shapes are shown without their `response_` prefix.
1066
+ #
1067
+ # | `X-Goog-Upload-Status` | `200` | in CAT2_STATUS_CODES | any other non-200 |
1068
+ # |------------------------|---------------|----------------|-------------------|
1069
+ # | missing / empty | `cat2` | `cat2` | `fatal_bad_response` |
1070
+ # | `active` | `active` (1) | `cat2` | `fatal_bad_response` |
1071
+ # | `final` | `final` | `rejected` | `rejected` |
1072
+ # | `cancelled` | `cancelled` | `fatal_bad_response` | `fatal_bad_response` |
1073
+ # | any other value | `fatal_bad_response` | `fatal_bad_response` | `fatal_bad_response` |
1074
+ #
1075
+ # A headerless `200` is Category 2 regardless of {CAT2_STATUS_CODES}.
1076
+ #
1077
+ # (1) `cat2` instead if the response lacks a header that the command it answers requires (see
1078
+ # {Rules.required_headers_present?}). This is the only cell that depends on `state_status`: every status
1079
+ # that awaits a response awaits exactly one command, so the status says which headers are required.
1080
+ #
1081
+ # @param response [Event::HttpResponse] Response event
1082
+ # @param state_status [Symbol] Status of the state the response arrives in
1083
+ # @return [Symbol] Canonical response shape
1084
+ def self.classify_http_response response, state_status
1085
+ status_header = header_value(response.headers, "x-goog-upload-status")&.downcase
1086
+
1087
+ case status_header
1088
+ when "active"
1089
+ return classify_unsuccessful_status response.status unless response.status == 200
1090
+ required_headers_present?(response, state_status) ? :response_active : :response_cat2
1091
+ when "final"
1092
+ response.status == 200 ? :response_final : :response_rejected
1093
+ when "cancelled"
1094
+ response.status == 200 ? :response_cancelled : :response_fatal_bad_response
1095
+ when nil, ""
1096
+ response.status == 200 ? :response_cat2 : classify_unsuccessful_status(response.status)
1097
+ else
1098
+ :response_fatal_bad_response
1099
+ end
1100
+ end
1101
+
1102
+ ##
1103
+ # @private
1104
+ # Whether a `200` `active` response carries the headers the command it answers needs to be acted on.
1105
+ #
1106
+ # * `start` (awaited in `:starting`): `X-Goog-Upload-URL` must be present and non-blank. Without it there
1107
+ # is no session to upload to.
1108
+ # * `query` (awaited in `:recovery`): `X-Goog-Upload-Size-Received` must parse as a non-negative integer.
1109
+ # Treating a missing or malformed offset as `0` would silently restart the upload.
1110
+ #
1111
+ # A response that fails this check is Category 2, like a response with no upload status header at all.
1112
+ # {Rules.route} then decides per status: `:starting` fails with {BadResponseError}, `:recovery`
1113
+ # re-queries.
1114
+ #
1115
+ # @param response [Event::HttpResponse] Response event
1116
+ # @param state_status [Symbol] Status of the state the response arrives in
1117
+ # @return [Boolean]
1118
+ def self.required_headers_present? response, state_status
1119
+ case state_status
1120
+ when :starting
1121
+ !header_value(response.headers, "x-goog-upload-url").to_s.strip.empty?
1122
+ when :recovery
1123
+ !parse_header_non_negative_integer(header_value(response.headers, "x-goog-upload-size-received")).nil?
1124
+ else
1125
+ true
1126
+ end
1127
+ end
1128
+
1129
+ ##
1130
+ # @private
1131
+ # Classifies a non-200 status whose upload status header is missing, empty or `active`.
1132
+ #
1133
+ # @param status [Integer] HTTP status code
1134
+ # @return [Symbol] `:response_cat2` if the status is in {CAT2_STATUS_CODES}, else
1135
+ # `:response_fatal_bad_response`
1136
+ def self.classify_unsuccessful_status status
1137
+ CAT2_STATUS_CODES.include?(status) ? :response_cat2 : :response_fatal_bad_response
1138
+ end
1139
+
1140
+ ##
1141
+ # @private
1142
+ # Case-insensitive header lookup helper.
1143
+ #
1144
+ # @param headers [Hash, Object] Headers collection
1145
+ # @param key [String] Target header key
1146
+ # @return [String, nil] Header value
1147
+ def self.header_value headers, key
1148
+ return nil unless headers.is_a? Hash
1149
+ return headers[key] if headers.key? key
1150
+
1151
+ target = key.downcase
1152
+ _, val = headers.find { |k, _| k.to_s.downcase == target }
1153
+ val
1154
+ end
1155
+
1156
+ ##
1157
+ # @private
1158
+ # Classifies chunk read event by buffer size and EOF flag.
1159
+ #
1160
+ # @param event [Event::ChunkRead] Chunk read event
1161
+ # @return [Symbol] Canonical chunk shape
1162
+ def self.classify_chunk_read event
1163
+ if !event.eof
1164
+ :chunk_read_full
1165
+ elsif event.bytes_buffered.positive?
1166
+ :chunk_read_eof_with_data
1167
+ else
1168
+ :chunk_read_eof_empty
1169
+ end
1170
+ end
1171
+
1172
+ ##
1173
+ # @private
1174
+ # Classifies request failure event by failure kind.
1175
+ #
1176
+ # @param event [Event::RequestFailed] Request failed event
1177
+ # @return [Symbol] Canonical failure shape
1178
+ def self.classify_request_failed event
1179
+ case event.kind
1180
+ when :timeout then :request_timeout
1181
+ when :retries_exhausted then :request_retries_exhausted
1182
+ when :connection_failed then :request_connection_failed
1183
+ else :request_failed_unknown
1184
+ end
1185
+ end
1186
+
1187
+ ##
1188
+ # @private
1189
+ # Classifies raw event class objects.
1190
+ #
1191
+ # @param event_class [Class] Event class
1192
+ # @return [Symbol] Canonical shape
1193
+ def self.classify_event_class event_class
1194
+ if event_class == Event::StartUpload
1195
+ :start_upload
1196
+ elsif event_class == Event::ResumeUpload
1197
+ :resume_upload
1198
+ elsif event_class == Event::Cancel
1199
+ :user_cancel
1200
+ elsif event_class == Event::GlobalDeadlineExceeded
1201
+ :global_deadline_exceeded
1202
+ else
1203
+ :unknown
1204
+ end
1205
+ end
1206
+ end
1207
+ # rubocop:enable Metrics/ModuleLength
1208
+ end
1209
+ end
1210
+ end