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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +2 -5
- data/lib/gapic/common/retry_policy.rb +34 -0
- data/lib/gapic/common/version.rb +1 -1
- data/lib/gapic/logging_concerns.rb +4 -0
- data/lib/gapic/rest/client_stub.rb +13 -3
- data/lib/gapic/rest/error.rb +4 -1
- data/lib/gapic/rest/resumable_upload/core.rb +77 -0
- data/lib/gapic/rest/resumable_upload/data_types.rb +428 -0
- data/lib/gapic/rest/resumable_upload/driver/abridge.rb +168 -0
- data/lib/gapic/rest/resumable_upload/driver/retry_decider.rb +190 -0
- data/lib/gapic/rest/resumable_upload/driver/upload_log.rb +343 -0
- data/lib/gapic/rest/resumable_upload/driver.rb +855 -0
- data/lib/gapic/rest/resumable_upload/errors.rb +581 -0
- data/lib/gapic/rest/resumable_upload/events.rb +129 -0
- data/lib/gapic/rest/resumable_upload/instructions.rb +273 -0
- data/lib/gapic/rest/resumable_upload/retry_policies.rb +116 -0
- data/lib/gapic/rest/resumable_upload/rules.rb +1210 -0
- data/lib/gapic/rest/resumable_upload.rb +110 -0
- data/lib/gapic/rest.rb +2 -0
- data/lib/gapic/resumable_upload.rb +508 -0
- metadata +14 -1
|
@@ -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
|