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,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright 2026 Google LLC
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
|
|
17
|
+
require "uri"
|
|
18
|
+
|
|
19
|
+
module Gapic
|
|
20
|
+
module Rest
|
|
21
|
+
module ResumableUpload
|
|
22
|
+
class Driver
|
|
23
|
+
##
|
|
24
|
+
# @private
|
|
25
|
+
# Pure functions for redacting and abridging sensitive data and large payloads in logs.
|
|
26
|
+
#
|
|
27
|
+
module Abridge
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# @private
|
|
32
|
+
# Formats binary payload into truncated hex representation.
|
|
33
|
+
#
|
|
34
|
+
# @param data [Object, nil] Binary or string payload
|
|
35
|
+
# @return [String, nil] Truncated hex representation or nil
|
|
36
|
+
#
|
|
37
|
+
def bytes data
|
|
38
|
+
return nil if data.nil?
|
|
39
|
+
|
|
40
|
+
str = data.to_s
|
|
41
|
+
if str.bytesize >= 64
|
|
42
|
+
"#{str.byteslice(0, 32).unpack1('H*')}... <#{str.bytesize} bytes>"
|
|
43
|
+
else
|
|
44
|
+
str.unpack1 "H*"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
##
|
|
49
|
+
# @private
|
|
50
|
+
# Truncates error body to a safe log length.
|
|
51
|
+
#
|
|
52
|
+
# @param data [Object, nil] Error body payload
|
|
53
|
+
# @return [String, nil] UTF-8 scrubbed and truncated string
|
|
54
|
+
#
|
|
55
|
+
def error_body data
|
|
56
|
+
return nil if data.nil?
|
|
57
|
+
|
|
58
|
+
data.to_s.dup.force_encoding(Encoding::UTF_8).scrub[0, 512]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
##
|
|
62
|
+
# @private
|
|
63
|
+
# Redacts query parameter values in URLs for safe logging.
|
|
64
|
+
#
|
|
65
|
+
# @param url [Object, nil] URL string or URI
|
|
66
|
+
# @return [String, nil] URL with query values elided
|
|
67
|
+
#
|
|
68
|
+
def url url
|
|
69
|
+
return nil if url.nil?
|
|
70
|
+
|
|
71
|
+
uri = URI.parse url.to_s
|
|
72
|
+
if uri.query && !uri.query.empty?
|
|
73
|
+
elided = uri.query.split("&").map do |pair|
|
|
74
|
+
key, _val = pair.split "=", 2
|
|
75
|
+
"#{key}=<...>"
|
|
76
|
+
end.join "&"
|
|
77
|
+
uri.query = nil
|
|
78
|
+
return "#{uri}?#{elided}"
|
|
79
|
+
end
|
|
80
|
+
uri.to_s
|
|
81
|
+
rescue URI::InvalidURIError
|
|
82
|
+
url.to_s
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
##
|
|
86
|
+
# @private
|
|
87
|
+
# Redacts non-protocol headers for safe logging.
|
|
88
|
+
#
|
|
89
|
+
# @param headers [Object] Headers hash
|
|
90
|
+
# @return [Hash<String, String>] Redacted headers
|
|
91
|
+
#
|
|
92
|
+
def headers headers
|
|
93
|
+
return {} unless headers.is_a? Hash
|
|
94
|
+
|
|
95
|
+
headers.each_with_object({}) do |(k, v), acc|
|
|
96
|
+
key_str = k.to_s
|
|
97
|
+
acc[key_str] = if key_str.downcase == "x-goog-upload-url"
|
|
98
|
+
url v
|
|
99
|
+
elsif key_str.downcase.start_with? "x-goog-upload-"
|
|
100
|
+
v
|
|
101
|
+
else
|
|
102
|
+
"<...>"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# @private
|
|
109
|
+
# Converts a list of instructions into log-safe representation hashes.
|
|
110
|
+
#
|
|
111
|
+
# @param instructions [Array<Object>] List of instructions
|
|
112
|
+
# @return [Array<Hash>] Log-safe instruction summaries
|
|
113
|
+
#
|
|
114
|
+
def instructions instructions
|
|
115
|
+
instructions.map { |i| instruction i }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# rubocop:disable Metrics/MethodLength
|
|
119
|
+
##
|
|
120
|
+
# @private
|
|
121
|
+
# Converts an instruction into a log-safe representation hash.
|
|
122
|
+
#
|
|
123
|
+
# @param instruction [Object] Instruction object
|
|
124
|
+
# @return [Hash] Log-safe instruction summary
|
|
125
|
+
#
|
|
126
|
+
def instruction instruction
|
|
127
|
+
case instruction
|
|
128
|
+
when Instruction::SendStart
|
|
129
|
+
{ "type" => "SendStart", "url" => url(instruction.url) }
|
|
130
|
+
when Instruction::SendChunk
|
|
131
|
+
{
|
|
132
|
+
"type" => "SendChunk",
|
|
133
|
+
"url" => url(instruction.url),
|
|
134
|
+
"offset" => instruction.offset,
|
|
135
|
+
"length" => instruction.length,
|
|
136
|
+
"finalize" => instruction.finalize
|
|
137
|
+
}
|
|
138
|
+
when Instruction::SendFinalize
|
|
139
|
+
{ "type" => "SendFinalize", "url" => url(instruction.url) }
|
|
140
|
+
when Instruction::SendQuery
|
|
141
|
+
{ "type" => "SendQuery", "url" => url(instruction.url), "backoff" => instruction.backoff }
|
|
142
|
+
when Instruction::SendCancel
|
|
143
|
+
{ "type" => "SendCancel", "url" => url(instruction.url) }
|
|
144
|
+
when Instruction::RealignBuffer
|
|
145
|
+
{ "type" => "RealignBuffer", "serverOffset" => instruction.server_offset }
|
|
146
|
+
when Instruction::FillBuffer
|
|
147
|
+
{ "type" => "FillBuffer", "targetBytesize" => instruction.target_bytesize }
|
|
148
|
+
when Instruction::NotifyProgress
|
|
149
|
+
{
|
|
150
|
+
"type" => "NotifyProgress",
|
|
151
|
+
"phase" => instruction.progress.phase.to_s,
|
|
152
|
+
"bytesUploaded" => instruction.progress.bytes_uploaded,
|
|
153
|
+
"totalBytes" => instruction.progress.total_bytes
|
|
154
|
+
}
|
|
155
|
+
when Instruction::TerminateSuccess
|
|
156
|
+
{ "type" => "TerminateSuccess" }
|
|
157
|
+
when Instruction::TerminateFailure
|
|
158
|
+
{ "type" => "TerminateFailure", "error" => instruction.error.to_s }
|
|
159
|
+
else
|
|
160
|
+
{ "type" => instruction.class.name }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
# rubocop:enable Metrics/MethodLength
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
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 "faraday"
|
|
18
|
+
require "gapic/rest/error"
|
|
19
|
+
require "gapic/rest/resumable_upload/rules"
|
|
20
|
+
|
|
21
|
+
module Gapic
|
|
22
|
+
module Rest
|
|
23
|
+
module ResumableUpload
|
|
24
|
+
class Driver
|
|
25
|
+
##
|
|
26
|
+
# @private
|
|
27
|
+
# Single owner of every retry decision for one resumable upload command.
|
|
28
|
+
#
|
|
29
|
+
# `ClientStub` is handed a never-retry policy, so each `ClientStub` call is exactly one attempt and
|
|
30
|
+
# every outcome comes back to the Driver. The Driver asks this object whether to send the same request
|
|
31
|
+
# again. It only ever chooses between re-sending and surfacing: an outcome it declines, and whatever
|
|
32
|
+
# is left when the budget runs out, is converted to an event unchanged and handed to {Rules}, which
|
|
33
|
+
# alone decides between recovery and a terminal outcome.
|
|
34
|
+
#
|
|
35
|
+
# The decision table (first match wins):
|
|
36
|
+
#
|
|
37
|
+
# | # | Outcome | start / control | data |
|
|
38
|
+
# |---|-----------------------------------------------------|------------------------|------------------------|
|
|
39
|
+
# | 1 | non-transport error (e.g. auth refresh) | ask policy | ask policy |
|
|
40
|
+
# | 2 | `Faraday::TimeoutError`, or no response and no kind | ask policy | surface |
|
|
41
|
+
# | 3 | `Faraday::ConnectionFailed`, `Faraday::SSLError` | retry within budget | surface |
|
|
42
|
+
# | 4 | `200` without `X-Goog-Upload-Status` | retry within budget | surface |
|
|
43
|
+
# | 5 | non-200 with `X-Goog-Upload-Status: final` | surface | surface |
|
|
44
|
+
# | 6 | any 4xx | (falls through) | surface |
|
|
45
|
+
# | 7 | any other non-200 | ask policy | ask policy |
|
|
46
|
+
# | - | any other `2xx`, or `200` with the header | surface | surface |
|
|
47
|
+
#
|
|
48
|
+
# * **Retry within budget** is the no-argument `RetryPolicy#call`: it checks the policy deadline and
|
|
49
|
+
# applies backoff, and hands nothing to a caller predicate.
|
|
50
|
+
# * **Ask policy** is `retry_with_deadline? && call(error)`: the caller's `retry_predicate`, then its
|
|
51
|
+
# `retry_codes`. `RetryPolicy#call(error)` does not check the deadline itself, hence the guard.
|
|
52
|
+
# * The data plane never re-sends after an outcome that leaves the server offset unknown (rows 2, 3, 4,
|
|
53
|
+
# 6); recovery owns those. A non-transport error (row 1) fails before anything reaches the wire, so
|
|
54
|
+
# re-sending cannot duplicate bytes.
|
|
55
|
+
# * Rows 5 and 6 are fixed ahead of the policy, so no caller setting can retry a rejection or make the
|
|
56
|
+
# data plane re-send on a 4xx.
|
|
57
|
+
#
|
|
58
|
+
# Row 2 is inert for timeouts until attempts get their own timeouts: each attempt currently receives the
|
|
59
|
+
# whole remaining command budget, so a timed-out attempt leaves none to retry with.
|
|
60
|
+
#
|
|
61
|
+
# See `design/resumable_upload/implementation-guide.md` section 6.1.1.
|
|
62
|
+
#
|
|
63
|
+
class RetryDecider
|
|
64
|
+
##
|
|
65
|
+
# @private
|
|
66
|
+
# Classifies a raised request error by what reached the wire.
|
|
67
|
+
#
|
|
68
|
+
# Shared with {Driver#rescue_request_error}, so the kind an error is retried as and the kind it is
|
|
69
|
+
# surfaced as cannot drift apart.
|
|
70
|
+
#
|
|
71
|
+
# @param error [StandardError] Error raised by, or through, `ClientStub#make_post_request`
|
|
72
|
+
# @return [Symbol] `:status` (an HTTP response arrived), `:timeout`, `:connection_failed`,
|
|
73
|
+
# `:no_response` (a transport error of no more specific kind), or `:non_transport`
|
|
74
|
+
def self.failure_kind error
|
|
75
|
+
case error
|
|
76
|
+
when Gapic::Rest::DeadlineExceededError then :timeout
|
|
77
|
+
when Gapic::Rest::Error then error.status_code ? :status : :connection_failed
|
|
78
|
+
when Faraday::Error then faraday_failure_kind error
|
|
79
|
+
else :non_transport
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
##
|
|
84
|
+
# @private
|
|
85
|
+
# @param error [Faraday::Error]
|
|
86
|
+
# @return [Symbol] See {.failure_kind}
|
|
87
|
+
def self.faraday_failure_kind error
|
|
88
|
+
if error.response.is_a?(Hash) && error.response[:status]
|
|
89
|
+
:status
|
|
90
|
+
elsif error.is_a? Faraday::TimeoutError
|
|
91
|
+
:timeout
|
|
92
|
+
elsif error.is_a?(Faraday::ConnectionFailed) || error.is_a?(Faraday::SSLError)
|
|
93
|
+
:connection_failed
|
|
94
|
+
else
|
|
95
|
+
:no_response
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
##
|
|
100
|
+
# @private
|
|
101
|
+
# @param policy [Gapic::Common::RetryPolicy] Started policy for this command
|
|
102
|
+
# @param data_plane [Boolean] Whether the command transmits upload bytes (`upload`, `finalize`)
|
|
103
|
+
def initialize policy, data_plane:
|
|
104
|
+
@policy = policy
|
|
105
|
+
@data_plane = data_plane
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
##
|
|
109
|
+
# @private
|
|
110
|
+
# Decides whether to send the same request again, performing the backoff delay if so.
|
|
111
|
+
#
|
|
112
|
+
# @param outcome [Object, StandardError] The response returned by `ClientStub#make_post_request`, or
|
|
113
|
+
# the error it raised
|
|
114
|
+
# @return [Boolean] `true` if the request should be sent again (the delay has been performed)
|
|
115
|
+
def retry? outcome
|
|
116
|
+
return retry_response? outcome unless outcome.is_a? Exception
|
|
117
|
+
|
|
118
|
+
error = unwrap outcome
|
|
119
|
+
case self.class.failure_kind error
|
|
120
|
+
when :status then retry_status? error
|
|
121
|
+
when :connection_failed then !@data_plane && @policy.call
|
|
122
|
+
when :timeout, :no_response then !@data_plane && ask_policy(error)
|
|
123
|
+
else ask_policy error
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
##
|
|
130
|
+
# @private
|
|
131
|
+
# Under `raise_faraday_errors: false`, `ClientStub` raises a {Gapic::Rest::Error} from inside its own
|
|
132
|
+
# `rescue`, so `cause` is the original Faraday error. `retry_codes` can read a status only from the
|
|
133
|
+
# latter.
|
|
134
|
+
#
|
|
135
|
+
# @param error [StandardError]
|
|
136
|
+
# @return [StandardError]
|
|
137
|
+
def unwrap error
|
|
138
|
+
return error.cause if error.is_a?(Gapic::Rest::Error) && error.cause.is_a?(Faraday::Error)
|
|
139
|
+
error
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
##
|
|
143
|
+
# @private
|
|
144
|
+
# Faraday raises on every `4xx`/`5xx`, so a returned response is a `1xx`-`3xx`. Only a headerless
|
|
145
|
+
# `200` is worth re-sending.
|
|
146
|
+
#
|
|
147
|
+
# @param response [Object] Response exposing `status` and `headers`
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def retry_response? response
|
|
150
|
+
return false unless response.status == 200
|
|
151
|
+
return false unless upload_status(response.headers).nil?
|
|
152
|
+
!@data_plane && @policy.call
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
##
|
|
156
|
+
# @private
|
|
157
|
+
# @param error [Faraday::Error, Gapic::Rest::Error] Error carrying an HTTP status
|
|
158
|
+
# @return [Boolean]
|
|
159
|
+
def retry_status? error
|
|
160
|
+
status, headers = if error.is_a? Gapic::Rest::Error
|
|
161
|
+
[error.status_code, error.headers]
|
|
162
|
+
else
|
|
163
|
+
[error.response[:status], error.response[:headers]]
|
|
164
|
+
end
|
|
165
|
+
return false if upload_status(headers) == "final"
|
|
166
|
+
return false if @data_plane && (400..499).cover?(status)
|
|
167
|
+
ask_policy error
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
##
|
|
171
|
+
# @private
|
|
172
|
+
# @param error [StandardError]
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def ask_policy error
|
|
175
|
+
@policy.retry_with_deadline? && @policy.call(error)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
##
|
|
179
|
+
# @private
|
|
180
|
+
# @param headers [Hash, nil]
|
|
181
|
+
# @return [String, nil] Downcased `X-Goog-Upload-Status`, or `nil` when missing or empty
|
|
182
|
+
def upload_status headers
|
|
183
|
+
value = Rules.header_value headers, "x-goog-upload-status"
|
|
184
|
+
value.nil? || value.empty? ? nil : value.downcase
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,343 @@
|
|
|
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 "google/logging/message"
|
|
18
|
+
require "gapic/rest/resumable_upload/driver/abridge"
|
|
19
|
+
|
|
20
|
+
module Gapic
|
|
21
|
+
module Rest
|
|
22
|
+
module ResumableUpload
|
|
23
|
+
class Driver
|
|
24
|
+
##
|
|
25
|
+
# @private
|
|
26
|
+
# Structured logging helper for a single Resumable Upload run.
|
|
27
|
+
#
|
|
28
|
+
class UploadLog
|
|
29
|
+
##
|
|
30
|
+
# @private
|
|
31
|
+
# Recipes omitted from INFO lifecycle logging.
|
|
32
|
+
# @return [Array<Symbol>]
|
|
33
|
+
SILENT_RECIPES = [
|
|
34
|
+
:ack_chunk, # per-chunk transition, doesn't belong at INFO
|
|
35
|
+
:fail_with_unmatched_transition # raises before Decision exists, logged by #unmatched_transition
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @private
|
|
40
|
+
# Severity and message mapping for lifecycle transitions.
|
|
41
|
+
# @return [Hash<Symbol, Array>]
|
|
42
|
+
LIFECYCLE = {
|
|
43
|
+
start_session: [:info, "Initiating resumable upload"],
|
|
44
|
+
resume_session: [:info, "Resuming upload session"],
|
|
45
|
+
begin_transmission: [:info, "Upload session established"],
|
|
46
|
+
send_chunk: [:debug, "Sending upload chunk"],
|
|
47
|
+
send_upload_finalize: [:info, "Sending final upload chunk"],
|
|
48
|
+
send_finalize: [:info, "Sending finalize command"],
|
|
49
|
+
enter_recovery: [:info, "Entering upload recovery"],
|
|
50
|
+
retry_recovery: [:info, "Retrying upload recovery query"],
|
|
51
|
+
realign_from_recovery: [:info, "Resuming upload from server offset"],
|
|
52
|
+
complete_upload_with_data: [:info, "Resumable upload completed"],
|
|
53
|
+
complete_upload_finalized: [:info, "Resumable upload completed"],
|
|
54
|
+
cancel_session: [:info, "Canceling resumable upload"],
|
|
55
|
+
complete_cancellation: [:info, "Resumable upload canceled"],
|
|
56
|
+
fail_with_cancelled: [:warn, "Resumable upload canceled on the server"],
|
|
57
|
+
fail_with_deadline_exceeded: [:warn, "Resumable upload failed"],
|
|
58
|
+
fail_with_rejected: [:warn, "Resumable upload failed"],
|
|
59
|
+
fail_with_bad_response: [:warn, "Resumable upload failed"],
|
|
60
|
+
fail_with_request_error: [:warn, "Resumable upload failed"]
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# @private
|
|
64
|
+
# @return [String]
|
|
65
|
+
attr_reader :upload_id
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# @private
|
|
69
|
+
# Initializes a new UploadLog logger wrapper.
|
|
70
|
+
#
|
|
71
|
+
# @param stub_logger [Logger, Object] Underlying structured logger
|
|
72
|
+
# @param upload_id [String] Unique session identifier
|
|
73
|
+
#
|
|
74
|
+
def initialize stub_logger, upload_id:
|
|
75
|
+
@stub_logger = stub_logger
|
|
76
|
+
@upload_id = upload_id
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
##
|
|
80
|
+
# @private
|
|
81
|
+
# Logs state machine transition decision at DEBUG level.
|
|
82
|
+
#
|
|
83
|
+
# @param decision [Decision] Decision snapshot
|
|
84
|
+
#
|
|
85
|
+
def decision decision
|
|
86
|
+
msg = "Rules: #{decision.from_status} + #{decision.shape} -> " \
|
|
87
|
+
"#{decision.recipe} -> #{decision.next_state.status}"
|
|
88
|
+
entry(
|
|
89
|
+
:debug,
|
|
90
|
+
msg,
|
|
91
|
+
fromStatus: decision.from_status,
|
|
92
|
+
shape: decision.shape,
|
|
93
|
+
recipe: decision.recipe,
|
|
94
|
+
toStatus: decision.next_state.status,
|
|
95
|
+
offset: decision.next_state.offset,
|
|
96
|
+
inFlightLength: decision.next_state.in_flight_length,
|
|
97
|
+
recoveryOffset: decision.next_state.recovery_offset,
|
|
98
|
+
instructions: Abridge.instructions(decision.instructions)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
##
|
|
103
|
+
# @private
|
|
104
|
+
# Logs high-level protocol lifecycle milestone if configured.
|
|
105
|
+
#
|
|
106
|
+
# @param decision [Decision] Decision snapshot
|
|
107
|
+
# @param config [StartUploadConfig, ResumeUploadConfig] Upload configuration
|
|
108
|
+
#
|
|
109
|
+
def lifecycle decision, config
|
|
110
|
+
return if SILENT_RECIPES.include? decision.recipe
|
|
111
|
+
|
|
112
|
+
severity, message = LIFECYCLE[decision.recipe]
|
|
113
|
+
return unless severity
|
|
114
|
+
|
|
115
|
+
extra_fields = lifecycle_fields decision, config
|
|
116
|
+
entry severity, message, recipe: decision.recipe, **extra_fields
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
##
|
|
120
|
+
# @private
|
|
121
|
+
# Logs an outgoing HTTP request at DEBUG level.
|
|
122
|
+
#
|
|
123
|
+
# @param method [String] HTTP method
|
|
124
|
+
# @param url [String] Request URL
|
|
125
|
+
# @param headers [Hash] Request headers
|
|
126
|
+
# @param start_attempt [Integer] Attempt index for start command
|
|
127
|
+
# @param body_size [Integer, nil] Byte size of request payload
|
|
128
|
+
# @param body [Object, nil] Request payload
|
|
129
|
+
# @param body_is_error [Boolean] Whether body contains an error payload
|
|
130
|
+
#
|
|
131
|
+
def wire_send method:, url:, headers:, start_attempt:, body_size: nil, body: nil, body_is_error: false
|
|
132
|
+
command = Rules.header_value headers, "x-goog-upload-command"
|
|
133
|
+
offset = Rules.header_value headers, "x-goog-upload-offset"
|
|
134
|
+
fields = {
|
|
135
|
+
method: method,
|
|
136
|
+
url: Abridge.url(url),
|
|
137
|
+
headers: Abridge.headers(headers),
|
|
138
|
+
startAttempt: start_attempt
|
|
139
|
+
}
|
|
140
|
+
fields[:command] = command if command
|
|
141
|
+
fields[:offset] = offset.to_i if offset
|
|
142
|
+
fields[:bodySize] = body_size if body_size
|
|
143
|
+
fields[:body] = body_is_error ? Abridge.error_body(body) : Abridge.bytes(body) if body
|
|
144
|
+
|
|
145
|
+
entry :debug, "Sending #{method} request", **fields
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
##
|
|
149
|
+
# @private
|
|
150
|
+
# Logs a received HTTP response at DEBUG level.
|
|
151
|
+
#
|
|
152
|
+
# @param event [Event::HttpResponse] Received HTTP event
|
|
153
|
+
#
|
|
154
|
+
def wire_receive event
|
|
155
|
+
upload_status = Rules.header_value event.headers, "x-goog-upload-status"
|
|
156
|
+
size_recv = Rules.parse_header_non_negative_integer(
|
|
157
|
+
Rules.header_value(event.headers, "x-goog-upload-size-received")
|
|
158
|
+
)
|
|
159
|
+
gran = Rules.parse_header_non_negative_integer(
|
|
160
|
+
Rules.header_value(event.headers, "x-goog-upload-chunk-granularity")
|
|
161
|
+
)
|
|
162
|
+
err = event.error if event.respond_to? :error
|
|
163
|
+
fields = {
|
|
164
|
+
status: event.status,
|
|
165
|
+
headers: Abridge.headers(event.headers),
|
|
166
|
+
body: wire_receive_body(event, err)
|
|
167
|
+
}
|
|
168
|
+
fields[:uploadStatus] = upload_status if upload_status
|
|
169
|
+
fields[:errorStatus] = err.status if err&.status
|
|
170
|
+
fields[:sizeReceived] = size_recv if size_recv
|
|
171
|
+
fields[:granularity] = gran if gran
|
|
172
|
+
|
|
173
|
+
entry :debug, "Received HTTP #{event.status}", **fields
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
##
|
|
177
|
+
# @private
|
|
178
|
+
# Logs a network or transport failure at DEBUG level.
|
|
179
|
+
#
|
|
180
|
+
# @param event [Event::RequestFailed] Failure event
|
|
181
|
+
#
|
|
182
|
+
def wire_failure event
|
|
183
|
+
entry(
|
|
184
|
+
:debug,
|
|
185
|
+
"Request failed: #{event.kind}",
|
|
186
|
+
kind: event.kind,
|
|
187
|
+
error: event.message
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
##
|
|
192
|
+
# @private
|
|
193
|
+
# Logs the backoff delay about to be performed before a query that continues a recovery episode.
|
|
194
|
+
#
|
|
195
|
+
# @param delay [Numeric] Base delay in seconds, before jitter and the `max_delay` cap
|
|
196
|
+
# @param attempt [Integer] 1-based index of the delay within the recovery episode
|
|
197
|
+
#
|
|
198
|
+
def recovery_backoff delay:, attempt:
|
|
199
|
+
entry :debug, "Performing backoff delay before recovery query", delay: delay, backoffAttempt: attempt
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
##
|
|
203
|
+
# @private
|
|
204
|
+
# Logs buffer realignment action.
|
|
205
|
+
#
|
|
206
|
+
# @param action [String] Realignment action description
|
|
207
|
+
# @param server_offset [Integer] Target server offset
|
|
208
|
+
# @param current_offset [Integer] Current buffer start offset
|
|
209
|
+
# @param unseekable [Boolean] Whether rewind was attempted on an unseekable stream
|
|
210
|
+
#
|
|
211
|
+
def buffer_realign action, server_offset:, current_offset:, unseekable: false
|
|
212
|
+
if unseekable
|
|
213
|
+
entry(
|
|
214
|
+
:warn,
|
|
215
|
+
"Server offset rewind on unseekable stream",
|
|
216
|
+
action: action,
|
|
217
|
+
serverOffset: server_offset,
|
|
218
|
+
currentOffset: current_offset
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
entry(
|
|
223
|
+
:debug,
|
|
224
|
+
"Buffer realignment: #{action}",
|
|
225
|
+
action: action,
|
|
226
|
+
serverOffset: server_offset,
|
|
227
|
+
currentOffset: current_offset
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
##
|
|
232
|
+
# @private
|
|
233
|
+
# Logs an invalid or unmatched state machine transition at WARN level.
|
|
234
|
+
#
|
|
235
|
+
# @param state [State] Current state
|
|
236
|
+
# @param event [Object] Triggering event
|
|
237
|
+
# @param error [StandardError] Resulting error
|
|
238
|
+
#
|
|
239
|
+
def unmatched_transition state, event, error
|
|
240
|
+
entry(
|
|
241
|
+
:warn,
|
|
242
|
+
"Unmatched transition",
|
|
243
|
+
status: state.status,
|
|
244
|
+
shape: Rules.shape_of(event, state.status),
|
|
245
|
+
error: error.message
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
##
|
|
252
|
+
# @private
|
|
253
|
+
# Formats response body for wire log entry.
|
|
254
|
+
#
|
|
255
|
+
# @param event [Event::HttpResponse] Response event
|
|
256
|
+
# @param err [Gapic::Rest::Error, nil] Error instance
|
|
257
|
+
# @return [String, nil] Formatted body
|
|
258
|
+
#
|
|
259
|
+
def wire_receive_body event, err
|
|
260
|
+
return Abridge.bytes event.body if event.status < 400
|
|
261
|
+
|
|
262
|
+
err&.message ? Abridge.error_body(err.message) : Abridge.error_body(event.body)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
##
|
|
266
|
+
# @private
|
|
267
|
+
# Extracts relevant state fields for lifecycle logging.
|
|
268
|
+
#
|
|
269
|
+
# @param decision [Decision] Decision snapshot
|
|
270
|
+
# @param config [StartUploadConfig, ResumeUploadConfig] Upload configuration
|
|
271
|
+
# @return [Hash] Metadata fields for log entry
|
|
272
|
+
#
|
|
273
|
+
def lifecycle_fields decision, config
|
|
274
|
+
state = decision.next_state
|
|
275
|
+
case decision.recipe
|
|
276
|
+
when :start_session
|
|
277
|
+
{ uploadSize: config.upload_size, requestedChunkSize: config.chunk_size }
|
|
278
|
+
when :resume_session
|
|
279
|
+
{
|
|
280
|
+
uploadUrl: Abridge.url(state.upload_url),
|
|
281
|
+
chunkSize: state.chunk_size
|
|
282
|
+
}
|
|
283
|
+
when :begin_transmission
|
|
284
|
+
{
|
|
285
|
+
effectiveChunkSize: state.chunk_size,
|
|
286
|
+
granularity: state.chunk_granularity,
|
|
287
|
+
uploadUrl: Abridge.url(state.upload_url)
|
|
288
|
+
}
|
|
289
|
+
when :send_chunk, :send_upload_finalize
|
|
290
|
+
{ offset: state.offset, inFlightLength: state.in_flight_length }
|
|
291
|
+
when :send_finalize, :enter_recovery, :retry_recovery,
|
|
292
|
+
:realign_from_recovery, :complete_upload_with_data, :complete_upload_finalized
|
|
293
|
+
{ offset: state.offset }
|
|
294
|
+
when :cancel_session
|
|
295
|
+
{ uploadUrl: Abridge.url(state.upload_url) }
|
|
296
|
+
when :fail_with_deadline_exceeded, :fail_with_rejected, :fail_with_bad_response,
|
|
297
|
+
:fail_with_request_error
|
|
298
|
+
failure_fields state
|
|
299
|
+
else
|
|
300
|
+
{}
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
##
|
|
305
|
+
# @private
|
|
306
|
+
# Extracts error and response details for failure lifecycle logs.
|
|
307
|
+
#
|
|
308
|
+
# @param state [State] Current protocol state
|
|
309
|
+
# @return [Hash] Failure metadata fields
|
|
310
|
+
#
|
|
311
|
+
def failure_fields state
|
|
312
|
+
err = state.last_error
|
|
313
|
+
fields = { error: err&.message || err.to_s }
|
|
314
|
+
if err.respond_to?(:response_body) && err.response_body
|
|
315
|
+
fields[:responseBody] = Abridge.error_body err.response_body
|
|
316
|
+
end
|
|
317
|
+
fields
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
##
|
|
321
|
+
# @private
|
|
322
|
+
# Dispatches structured log entry to stub logger.
|
|
323
|
+
#
|
|
324
|
+
# @param severity [Symbol] Log severity level
|
|
325
|
+
# @param log_msg [String] Primary log message
|
|
326
|
+
# @param fields [Hash] Structured key-value fields
|
|
327
|
+
#
|
|
328
|
+
def entry severity, log_msg, **fields
|
|
329
|
+
@stub_logger.public_send severity do |builder|
|
|
330
|
+
builder.set_system_name
|
|
331
|
+
builder.set_service
|
|
332
|
+
builder.set "uploadId", @upload_id
|
|
333
|
+
fields.each do |k, v|
|
|
334
|
+
builder.set k.to_s, v
|
|
335
|
+
end
|
|
336
|
+
builder.message = log_msg
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|