basecamp-sdk 0.9.0 → 0.11.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/README.md +106 -10
- data/lib/basecamp/client.rb +13 -8
- data/lib/basecamp/config.rb +4 -3
- data/lib/basecamp/generated/metadata.json +280 -12
- data/lib/basecamp/generated/services/account_service.rb +1 -1
- data/lib/basecamp/generated/services/automation_service.rb +2 -2
- data/lib/basecamp/generated/services/boosts_service.rb +3 -3
- data/lib/basecamp/generated/services/campfires_service.rb +7 -7
- data/lib/basecamp/generated/services/card_columns_service.rb +1 -1
- data/lib/basecamp/generated/services/card_steps_service.rb +1 -1
- data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
- data/lib/basecamp/generated/services/cards_service.rb +4 -4
- data/lib/basecamp/generated/services/checkins_service.rb +8 -8
- data/lib/basecamp/generated/services/client_approvals_service.rb +2 -2
- data/lib/basecamp/generated/services/client_correspondences_service.rb +2 -2
- data/lib/basecamp/generated/services/client_replies_service.rb +2 -2
- data/lib/basecamp/generated/services/comments_service.rb +2 -2
- data/lib/basecamp/generated/services/documents_service.rb +2 -2
- data/lib/basecamp/generated/services/events_service.rb +1 -1
- data/lib/basecamp/generated/services/everything_service.rb +169 -0
- data/lib/basecamp/generated/services/forwards_service.rb +5 -5
- data/lib/basecamp/generated/services/gauges_service.rb +3 -3
- data/lib/basecamp/generated/services/hill_charts_service.rb +1 -1
- data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
- data/lib/basecamp/generated/services/message_types_service.rb +20 -15
- data/lib/basecamp/generated/services/messages_service.rb +2 -2
- data/lib/basecamp/generated/services/my_assignments_service.rb +5 -5
- data/lib/basecamp/generated/services/my_notifications_service.rb +16 -2
- data/lib/basecamp/generated/services/people_service.rb +9 -9
- data/lib/basecamp/generated/services/projects_service.rb +2 -2
- data/lib/basecamp/generated/services/recordings_service.rb +3 -3
- data/lib/basecamp/generated/services/reports_service.rb +5 -5
- data/lib/basecamp/generated/services/schedules_service.rb +13 -5
- data/lib/basecamp/generated/services/search_service.rb +2 -2
- data/lib/basecamp/generated/services/subscriptions_service.rb +1 -1
- data/lib/basecamp/generated/services/templates_service.rb +3 -3
- data/lib/basecamp/generated/services/timeline_service.rb +1 -1
- data/lib/basecamp/generated/services/timesheets_service.rb +5 -5
- data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
- data/lib/basecamp/generated/services/todolists_service.rb +2 -2
- data/lib/basecamp/generated/services/todos_service.rb +2 -2
- data/lib/basecamp/generated/services/todosets_service.rb +1 -1
- data/lib/basecamp/generated/services/tools_service.rb +4 -3
- data/lib/basecamp/generated/services/uploads_service.rb +3 -3
- data/lib/basecamp/generated/services/vaults_service.rb +2 -2
- data/lib/basecamp/generated/services/webhooks_service.rb +2 -2
- data/lib/basecamp/generated/types.rb +374 -18
- data/lib/basecamp/http.rb +47 -18
- data/lib/basecamp/oauth/device_authorization.rb +37 -0
- data/lib/basecamp/oauth/device_flow.rb +873 -0
- data/lib/basecamp/oauth/device_flow_error.rb +46 -0
- data/lib/basecamp/oauth/discovery.rb +13 -11
- data/lib/basecamp/oauth/exchange.rb +44 -5
- data/lib/basecamp/oauth/fetcher.rb +626 -56
- data/lib/basecamp/oauth/oauth_error.rb +3 -1
- data/lib/basecamp/oauth/refresh_request.rb +8 -2
- data/lib/basecamp/oauth/resource.rb +13 -10
- data/lib/basecamp/oauth/token.rb +10 -3
- data/lib/basecamp/oauth.rb +47 -2
- data/lib/basecamp/services/cards_extensions.rb +67 -0
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +5 -0
- data/scripts/generate-services.rb +24 -6
- data/scripts/generate-types.rb +29 -2
- metadata +7 -2
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "json"
|
|
5
|
+
# Full cgi, not cgi/escape: on Ruby 3.2-3.4 (the gem floor is 3.2) the
|
|
6
|
+
# escape-only require leaves CGI.unescapeURIComponent broken with an
|
|
7
|
+
# uninitialized @@accept_charset NameError.
|
|
8
|
+
require "cgi"
|
|
9
|
+
require "net/http"
|
|
10
|
+
require "openssl"
|
|
11
|
+
require "timeout"
|
|
12
|
+
require "uri"
|
|
13
|
+
require "zlib"
|
|
5
14
|
|
|
6
15
|
module Basecamp
|
|
7
16
|
module Oauth
|
|
@@ -41,23 +50,112 @@ module Basecamp
|
|
|
41
50
|
#
|
|
42
51
|
# @param timeout [Object] caller-supplied timeout
|
|
43
52
|
# @return [Numeric] a finite, positive timeout in seconds
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return timeout if
|
|
53
|
+
# +default+ is operation-specific: discovery falls back to +DEFAULT_TIMEOUT+
|
|
54
|
+
# (10s), device flow passes its own 30s budget, so an invalid runtime value
|
|
55
|
+
# falls back to that operation's own timeout rather than a foreign one.
|
|
56
|
+
def self.normalize_timeout(timeout, default: DEFAULT_TIMEOUT)
|
|
57
|
+
return timeout if valid_timeout?(timeout)
|
|
58
|
+
# Validate the fallback too: a caller passing an invalid +default+ must not
|
|
59
|
+
# be able to disable both timeout bounds. Fall back to the finite constant.
|
|
60
|
+
return default if valid_timeout?(default)
|
|
49
61
|
|
|
50
62
|
DEFAULT_TIMEOUT
|
|
51
63
|
end
|
|
52
64
|
|
|
53
|
-
#
|
|
54
|
-
#
|
|
65
|
+
# Upper bound (seconds) for any single OAuth request timeout. A huge but
|
|
66
|
+
# FINITE caller value (1e100) would pass a bare finite/positive check and
|
|
67
|
+
# hold both Faraday's socket timeout and the monotonic deadline open
|
|
68
|
+
# effectively forever — the same cap discipline as Python
|
|
69
|
+
# (_MAX_DEVICE_REQUEST_TIMEOUT = 3600) and TS (resolveDeviceTimeoutMs
|
|
70
|
+
# rejects oversized values back to the default).
|
|
71
|
+
MAX_REQUEST_TIMEOUT = 3600
|
|
72
|
+
|
|
73
|
+
# +real?+ gates out Complex before +finite?+/+positive?+ (which Complex does
|
|
74
|
+
# not define — calling them would raise NoMethodError). Integer, Float, and
|
|
75
|
+
# Rational are all real and answer both.
|
|
76
|
+
# Monotonic clock read (seconds). A module seam — rather than inline
|
|
77
|
+
# Process.clock_gettime — so tests can stub Fetcher's own notion of
|
|
78
|
+
# "now" without touching the process-wide clock that Net::HTTP, the
|
|
79
|
+
# watchdog sleeps, and the test server all rely on.
|
|
80
|
+
# net-http >= 0.5 (Ruby 3.4+) added Net::HTTP.new's eighth p_use_ssl
|
|
81
|
+
# parameter; the gem floor is Ruby 3.2, whose bundled net-http lacks it.
|
|
82
|
+
def self.proxy_tls_capable?
|
|
83
|
+
Net::HTTP.method(:new).parameters.length >= 8
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.monotonic_now
|
|
87
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.valid_timeout?(value)
|
|
91
|
+
value.is_a?(Numeric) && value.real? && value.finite? && value.positive? \
|
|
92
|
+
&& value <= MAX_REQUEST_TIMEOUT
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Coerce the public body cap to a non-negative Integer. A nil, non-Integer
|
|
96
|
+
# (+Float::INFINITY+ included), or negative value would disable the streaming
|
|
97
|
+
# memory bound (+total > cap+ never trips), defeating the bounded-read
|
|
98
|
+
# guarantee. This is the one shared policy for Discovery, Resource, and the
|
|
99
|
+
# device flow; the +default+ is validated too, so an invalid fallback cannot
|
|
100
|
+
# disable the bound either.
|
|
101
|
+
def self.normalize_body_cap(cap, default: DEFAULT_MAX_BODY_BYTES)
|
|
102
|
+
return cap if valid_body_cap?(cap)
|
|
103
|
+
return default if valid_body_cap?(default)
|
|
104
|
+
|
|
105
|
+
DEFAULT_MAX_BODY_BYTES
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.valid_body_cap?(value)
|
|
109
|
+
value.is_a?(Integer) && value >= 0
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Classifies a wire fault from {stream_http}'s read into the Faraday error
|
|
113
|
+
# the caller rescues. Once the watchdog DEADLINE has fired, the forced
|
|
114
|
+
# close can surface in the blocked reader as IOError, a SystemCallError
|
|
115
|
+
# (ECONNRESET/EBADF and friends), or SocketError depending on platform
|
|
116
|
+
# and read phase — ALL of them are the timeout then, or the device poll
|
|
117
|
+
# would terminate as transport instead of applying its transient backoff.
|
|
118
|
+
# Before the deadline they are genuine wire faults: a peer closing
|
|
119
|
+
# mid-headers, a malformed status line/header (the Net parse errors are
|
|
120
|
+
# direct StandardError subclasses, not IOError, so they must be mapped
|
|
121
|
+
# explicitly or they leak raw), or malformed compressed bytes from
|
|
122
|
+
# Net::HTTP's automatic Content-Encoding decode (Zlib::Error).
|
|
123
|
+
def self.classify_stream_error(error, deadline_fired)
|
|
124
|
+
if deadline_fired
|
|
125
|
+
Faraday::TimeoutError.new("OAuth request exceeded the timeout deadline")
|
|
126
|
+
else
|
|
127
|
+
# Wrap the exception itself (the conventional Faraday pattern), not
|
|
128
|
+
# just its message: the original class and backtrace survive for
|
|
129
|
+
# debugging while callers' rescues see the same Faraday class.
|
|
130
|
+
Faraday::ConnectionFailed.new(error)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Raised to abort a streaming read once the cap is exceeded. Crosses the
|
|
135
|
+
# module boundary by design: {stream_http} lets it propagate raw, and each
|
|
136
|
+
# caller (fetch_json here, the device flow's post paths) maps it to its
|
|
137
|
+
# own typed cap fault.
|
|
55
138
|
class BodyTooLarge < StandardError; end
|
|
56
139
|
|
|
57
|
-
# Raised
|
|
58
|
-
#
|
|
140
|
+
# Raised when a streaming read exceeds its wall-clock deadline. Crosses
|
|
141
|
+
# the module boundary like {BodyTooLarge}: callers map it to a retryable
|
|
142
|
+
# +network+ fault (fetch_json) or a Faraday timeout (the device flow).
|
|
59
143
|
class ReadDeadlineExceeded < StandardError; end
|
|
60
144
|
|
|
145
|
+
# Raised from +on_data+ to STOP reading a response whose body the caller does
|
|
146
|
+
# not use (a non-2xx device-auth, a 3xx token redirect). Draining such a slow
|
|
147
|
+
# body would otherwise time out and be misclassified as a transport failure.
|
|
148
|
+
# Carries the response status so the caller can classify by it; like the
|
|
149
|
+
# markers above it crosses the module boundary raw and is mapped there.
|
|
150
|
+
class SkipBody < StandardError
|
|
151
|
+
attr_reader :status
|
|
152
|
+
|
|
153
|
+
def initialize(status)
|
|
154
|
+
@status = status
|
|
155
|
+
super("device flow response body skipped for status #{status}")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
61
159
|
# Builds a +[chunks, on_data]+ pair for a genuine bounded/streaming read.
|
|
62
160
|
# Assign +on_data+ to a request's +req.options.on_data+; after the request
|
|
63
161
|
# returns, +chunks.join+ is the accumulated body. The proc raises
|
|
@@ -77,14 +175,30 @@ module Basecamp
|
|
|
77
175
|
# @param max_body_bytes [Integer] bounded read cap in bytes
|
|
78
176
|
# @param deadline [Float, nil] monotonic clock deadline (CLOCK_MONOTONIC seconds)
|
|
79
177
|
# @return [Array(Array<String>, Proc)] the chunk buffer and the +on_data+ proc
|
|
80
|
-
def self.bounded_reader(max_body_bytes, deadline: nil)
|
|
178
|
+
def self.bounded_reader(max_body_bytes, deadline: nil, skip_status: nil)
|
|
81
179
|
chunks = []
|
|
82
180
|
total = 0
|
|
83
|
-
reader = proc do |chunk, _received|
|
|
84
|
-
|
|
181
|
+
reader = proc do |chunk, _received, env|
|
|
182
|
+
# Fast-path status-first skip: Faraday >= 2.5 passes +env+ (with the
|
|
183
|
+
# response status) to +on_data+ once headers are in, so a body the caller
|
|
184
|
+
# will not use (a non-2xx device-auth / a 3xx token redirect) is abandoned
|
|
185
|
+
# at the FIRST body chunk rather than drained. +env+ is nil on older
|
|
186
|
+
# Faraday (2.0–2.4) or a 2-arg call; every response that COMPLETES is
|
|
187
|
+
# still classified by status via the caller's post-request re-check (see
|
|
188
|
+
# +DeviceFlow#post_form+). +on_data+ is a body callback, not a headers
|
|
189
|
+
# callback, so the one unreachable case — headers arrive, then the body
|
|
190
|
+
# stalls past the read timeout — surfaces as a bounded transport timeout
|
|
191
|
+
# instead (never followed, never unbounded; see +post_form+).
|
|
192
|
+
# Deadline FIRST: a first chunk that becomes runnable past the total
|
|
193
|
+
# bound means the status was not known in time — the timeout wins,
|
|
194
|
+
# matching the default transport's header-time gate. (The resetting
|
|
195
|
+
# per-read timeout alone would admit it.)
|
|
196
|
+
if deadline && monotonic_now > deadline
|
|
85
197
|
raise ReadDeadlineExceeded
|
|
86
198
|
end
|
|
87
199
|
|
|
200
|
+
raise SkipBody.new(env.status) if skip_status && env && skip_status.call(env.status)
|
|
201
|
+
|
|
88
202
|
total += chunk.bytesize
|
|
89
203
|
raise BodyTooLarge if total > max_body_bytes
|
|
90
204
|
|
|
@@ -93,29 +207,16 @@ module Basecamp
|
|
|
93
207
|
[ chunks, reader ]
|
|
94
208
|
end
|
|
95
209
|
|
|
96
|
-
# Builds the default SSRF-hardened Faraday connection. No redirect
|
|
97
|
-
# middleware is registered, so redirects are not followed.
|
|
98
|
-
#
|
|
99
|
-
# @param timeout [Integer] request + connect timeout in seconds
|
|
100
|
-
# @return [Faraday::Connection]
|
|
101
|
-
def self.build_client(timeout)
|
|
102
|
-
Faraday.new do |conn|
|
|
103
|
-
conn.options.timeout = timeout
|
|
104
|
-
conn.options.open_timeout = timeout
|
|
105
|
-
conn.adapter Faraday.default_adapter
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
|
|
109
210
|
# Rejects an INJECTED connection whose middleware stack we cannot verify to
|
|
110
211
|
# be redirect-free. Redirect suppression is a load-bearing SSRF control (RFC
|
|
111
212
|
# 9728 §7.7): a caller-supplied client that follows redirects would silently
|
|
112
213
|
# chase an attacker-controlled +Location+. A class-NAME heuristic (matching
|
|
113
214
|
# +/redirect/+) is bypassable by a follower whose class name does not contain
|
|
114
215
|
# "redirect", so we enforce a POLICY instead of guessing by name: an injected
|
|
115
|
-
# connection may carry ONLY adapter handlers
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
216
|
+
# connection may carry ONLY adapter handlers (an adapter-only connection or
|
|
217
|
+
# a test's mock adapter qualifies); ANY request/response middleware — which
|
|
218
|
+
# could follow redirects under any name, or otherwise rewrite the request —
|
|
219
|
+
# is refused rather than trusted.
|
|
119
220
|
#
|
|
120
221
|
# @param client [Faraday::Connection]
|
|
121
222
|
# @raise [OauthError] +validation+ when non-adapter middleware is present
|
|
@@ -144,45 +245,423 @@ module Basecamp
|
|
|
144
245
|
)
|
|
145
246
|
end
|
|
146
247
|
|
|
248
|
+
# Headers-first bounded HTTP over Net::HTTP — the default transport for
|
|
249
|
+
# every SDK-built OAuth fetch (both discovery hops and the device flow).
|
|
250
|
+
# Injected Faraday connections keep the Faraday path; this primitive exists
|
|
251
|
+
# because Faraday cannot provide these two guarantees:
|
|
252
|
+
#
|
|
253
|
+
# 1. **Status at header time.** Faraday's +on_data+ is a body callback, so a
|
|
254
|
+
# response whose body never arrives can only be classified after a
|
|
255
|
+
# timeout. Net::HTTP's block form yields the response once HEADERS are
|
|
256
|
+
# in: +skip_status+ classifies by status BEFORE any body read, and
|
|
257
|
+
# raising out of the block makes +Net::HTTP.start+'s ensure close the
|
|
258
|
+
# socket with the body undrained — exact status-first classification
|
|
259
|
+
# (SPEC.md §16) for every response shape, including a stalled body.
|
|
260
|
+
# 2. **A total wall-clock bound.** A per-read timeout resets on every byte,
|
|
261
|
+
# so a peer dripping header or body bytes defeats it. A WATCHDOG thread
|
|
262
|
+
# closes the connection at a monotonic deadline, which interrupts even a
|
|
263
|
+
# blocked or dripped HEADER read (closing the socket from another thread
|
|
264
|
+
# raises IOError in the blocked reader — verified on a live socket).
|
|
265
|
+
# +max_retries = 0+ is load-bearing: Net::HTTP's idempotent-retry would
|
|
266
|
+
# otherwise silently REOPEN the connection the watchdog just closed.
|
|
267
|
+
# Scope: the watchdog governs from session-start on. The CONNECTION
|
|
268
|
+
# phases (TCP connect, proxy CONNECT, TLS handshake) are not
|
|
269
|
+
# watchdog-interruptible — Net::HTTP marks the session started only
|
|
270
|
+
# after they complete — so connection setup runs under its own
|
|
271
|
+
# whole-phase Timeout.timeout bound (a proxy's CONNECT response is
|
|
272
|
+
# parsed under the per-read timeout, which a byte-dripping proxy
|
|
273
|
+
# resets forever), and a post-connect deadline re-check refuses the
|
|
274
|
+
# request when setup consumed the budget. Total wall clock is
|
|
275
|
+
# ~timeout, never unbounded.
|
|
276
|
+
#
|
|
277
|
+
# The body streams under the same cap + deadline as the Faraday path, and
|
|
278
|
+
# redirects are structurally never followed (+Net::HTTP#request+ has no
|
|
279
|
+
# follow logic). Transport failures surface as Faraday errors
|
|
280
|
+
# (+TimeoutError+ for timeouts, +ConnectionFailed+ for connection and
|
|
281
|
+
# protocol-parse failures) so both transport paths classify through the
|
|
282
|
+
# same caller rescues. Bounded-read violations keep raising the shared
|
|
283
|
+
# {BodyTooLarge} / {ReadDeadlineExceeded} markers — deliberately NOT
|
|
284
|
+
# Faraday errors, so each caller maps them to its own operation-specific
|
|
285
|
+
# error message, exactly as on the Faraday path.
|
|
286
|
+
#
|
|
287
|
+
# @param method [Symbol] +:get+ or +:post+
|
|
288
|
+
# @param url [String] fully-qualified URL (already origin-validated)
|
|
289
|
+
# @param headers [Hash] request headers
|
|
290
|
+
# @param form [Hash, nil] form params; www-form-encoded into the POST body
|
|
291
|
+
# @param timeout [Numeric] total request bound in seconds (already
|
|
292
|
+
# normalized by the caller). The deadline is anchored BEFORE connect and
|
|
293
|
+
# open_timeout carries the same value, so the total wall time is
|
|
294
|
+
# ~timeout regardless of which phase stalls (the watchdog closes the
|
|
295
|
+
# session the moment it exists if the deadline fired mid-connect)
|
|
296
|
+
# @param max_body_bytes [Integer] bounded read cap in bytes
|
|
297
|
+
# @param skip_status [Proc, nil] statuses whose body is never read
|
|
298
|
+
# @param on_headers [Proc, nil] called with the +Net::HTTPResponse+ once
|
|
299
|
+
# headers arrive, before any body read or skip decision — the device
|
|
300
|
+
# poll loop reads +Retry-After+ here without widening the return shape
|
|
301
|
+
# @return [Array(Integer, String)] status and (possibly empty) body
|
|
302
|
+
def self.stream_http(method, url, headers: {}, form: nil, timeout:, max_body_bytes: DEFAULT_MAX_BODY_BYTES, skip_status: nil, on_headers: nil)
|
|
303
|
+
# Response state first, before ANY call the def-level rescues could
|
|
304
|
+
# catch, so every rescue path sees it assigned (the completed_at guard
|
|
305
|
+
# means it is only READ once genuinely populated).
|
|
306
|
+
status = nil
|
|
307
|
+
chunks = []
|
|
308
|
+
total = 0
|
|
309
|
+
uri = begin
|
|
310
|
+
URI.parse(url)
|
|
311
|
+
rescue URI::InvalidURIError
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
# Fail closed on an unparsable or hostless URL ("https:foo" parses with a
|
|
315
|
+
# nil hostname): require_https checks only the scheme, and a nil host
|
|
316
|
+
# would otherwise surface as a raw ArgumentError from inside Net::HTTP —
|
|
317
|
+
# outside the transport's Faraday-error contract.
|
|
318
|
+
if uri.nil? || uri.hostname.nil? || uri.hostname.empty?
|
|
319
|
+
raise OauthError.new("validation", "OAuth endpoint URL has no host: #{url.inspect}")
|
|
320
|
+
end
|
|
321
|
+
# Fail closed on a non-HTTP(S) scheme: callers TLS-guard upstream, but
|
|
322
|
+
# the primitive must not run an HTTP conversation against ftp:// etc.
|
|
323
|
+
unless %w[http https].include?(uri.scheme)
|
|
324
|
+
raise OauthError.new("validation", "OAuth endpoint URL must be http(s): #{url.inspect}")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Fail closed on an un-normalized timeout (the operation entry points
|
|
328
|
+
# normalize; this guards direct callers): a non-finite, non-positive,
|
|
329
|
+
# or beyond-ceiling value would leave the socket timeouts and the
|
|
330
|
+
# watchdog's sleep unbounded, defeating the total-request bound this
|
|
331
|
+
# primitive exists to guarantee — mirroring the Python transport's
|
|
332
|
+
# fail-fast guard.
|
|
333
|
+
unless valid_timeout?(timeout)
|
|
334
|
+
raise OauthError.new("validation", \
|
|
335
|
+
"stream_http timeout must be a positive number of seconds no greater than #{MAX_REQUEST_TIMEOUT}")
|
|
336
|
+
end
|
|
337
|
+
# The body cap gets the same fail-closed discipline: nil, a Float
|
|
338
|
+
# (Infinity included), or a negative value would disable or crash the
|
|
339
|
+
# streaming bound. Same predicate as {normalize_body_cap} so the
|
|
340
|
+
# transport can never reject a cap the public entry points accept —
|
|
341
|
+
# zero is a legitimate strict cap (any non-empty body trips it).
|
|
342
|
+
unless valid_body_cap?(max_body_bytes)
|
|
343
|
+
raise OauthError.new("validation", "stream_http max_body_bytes must be a non-negative Integer")
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# URI#hostname strips IPv6 brackets ("[::1]" -> "::1"), which is the form
|
|
347
|
+
# Net::HTTP.new expects.
|
|
348
|
+
#
|
|
349
|
+
# Net::HTTP's built-in :ENV proxy detection hardcodes an http:// URI
|
|
350
|
+
# before calling find_proxy, so an HTTPS_PROXY-only environment would
|
|
351
|
+
# silently bypass its proxy for HTTPS endpoints (and http_proxy would
|
|
352
|
+
# wrongly govern TLS requests). Resolve the proxy against the REAL
|
|
353
|
+
# scheme — matching faraday-net_http's per-scheme resolution — and
|
|
354
|
+
# pass it explicitly; a nil p_addr disables the broken built-in.
|
|
355
|
+
# The total deadline starts BEFORE proxy resolution: find_proxy
|
|
356
|
+
# resolves the target hostname (IPSocket.getaddress) to evaluate its
|
|
357
|
+
# loopback rule — a blocking DNS call that must sit inside the
|
|
358
|
+
# advertised bound like every other network step. Net::OpenTimeout
|
|
359
|
+
# maps through the Timeout::Error rescue to the transport timeout.
|
|
360
|
+
deadline = monotonic_now + timeout
|
|
361
|
+
deadline_fired = false
|
|
362
|
+
completed_at = nil
|
|
363
|
+
proxy_uri = Timeout.timeout(timeout, Net::OpenTimeout) { uri.find_proxy }
|
|
364
|
+
http = if proxy_uri
|
|
365
|
+
proxy_tls = proxy_uri.scheme == "https"
|
|
366
|
+
# An https:// proxy needs TLS on its own connection via the
|
|
367
|
+
# p_use_ssl argument, which exists only in net-http >= 0.5
|
|
368
|
+
# (Ruby 3.4+). On the older bundled net-http (Ruby 3.2/3.3)
|
|
369
|
+
# the 8-arg call would raise ArgumentError — refuse with an
|
|
370
|
+
# actionable error instead of plaintext-to-a-TLS-proxy.
|
|
371
|
+
if proxy_tls && !proxy_tls_capable?
|
|
372
|
+
raise OauthError.new(
|
|
373
|
+
"validation",
|
|
374
|
+
"https:// proxies need net-http >= 0.5 (Ruby 3.4+, or add the net-http gem)"
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Percent-decode the credentials: URI#user/#password return the
|
|
379
|
+
# encoded forms, and the explicit-proxy Net::HTTP.new does NOT
|
|
380
|
+
# unescape them the way its :ENV mode does — p%40ss would be
|
|
381
|
+
# sent verbatim in Proxy-Authorization and fail authentication.
|
|
382
|
+
# unescapeURIComponent, NOT form decoding: a literal + in a
|
|
383
|
+
# userinfo component is a plus sign, never a space.
|
|
384
|
+
# p_no_proxy nil (find_proxy already honored no_proxy).
|
|
385
|
+
args = [
|
|
386
|
+
uri.hostname, uri.port,
|
|
387
|
+
proxy_uri.hostname, proxy_uri.port,
|
|
388
|
+
proxy_uri.user && CGI.unescapeURIComponent(proxy_uri.user),
|
|
389
|
+
proxy_uri.password && CGI.unescapeURIComponent(proxy_uri.password)
|
|
390
|
+
]
|
|
391
|
+
args += [ nil, true ] if proxy_tls
|
|
392
|
+
Net::HTTP.new(*args)
|
|
393
|
+
else
|
|
394
|
+
Net::HTTP.new(uri.hostname, uri.port, nil)
|
|
395
|
+
end
|
|
396
|
+
http.use_ssl = uri.scheme == "https"
|
|
397
|
+
http.open_timeout = timeout
|
|
398
|
+
http.read_timeout = timeout
|
|
399
|
+
http.write_timeout = timeout
|
|
400
|
+
http.max_retries = 0
|
|
401
|
+
|
|
402
|
+
# A form body is only meaningful on POST — a GET-with-body masks a
|
|
403
|
+
# call-site mistake and contradicts this method's own contract.
|
|
404
|
+
raise ArgumentError, "stream_http: form is only valid with :post" if form && method != :post
|
|
405
|
+
|
|
406
|
+
# Identity encoding IN THE INITHEADER: Net::HTTP inflates gzip/deflate
|
|
407
|
+
# BEFORE read_body yields, so the per-chunk cap would measure DECODED
|
|
408
|
+
# bytes — a small compressed body could balloon far past max_body_bytes
|
|
409
|
+
# in memory (compression bomb). A caller-supplied Accept-Encoding at
|
|
410
|
+
# construction time is the supported way to switch decode_content off
|
|
411
|
+
# (assigning the header later does not); a server compressing anyway
|
|
412
|
+
# hands us raw bytes bounded by the cap, which then fail
|
|
413
|
+
# classification upstream instead of exhausting memory.
|
|
414
|
+
identity = { "Accept-Encoding" => "identity" }
|
|
415
|
+
request =
|
|
416
|
+
case method
|
|
417
|
+
when :post then Net::HTTP::Post.new(uri, identity)
|
|
418
|
+
when :get then Net::HTTP::Get.new(uri, identity)
|
|
419
|
+
else raise ArgumentError, "stream_http supports :get and :post, got #{method.inspect}"
|
|
420
|
+
end
|
|
421
|
+
headers.each do |name, value|
|
|
422
|
+
# The identity Accept-Encoding above is load-bearing (compression
|
|
423
|
+
# bomb bound): a caller-supplied override would reintroduce
|
|
424
|
+
# transparent inflation, so it is dropped — matching the Python
|
|
425
|
+
# transport, which forcibly overwrites the header.
|
|
426
|
+
next if name.to_s.casecmp("accept-encoding").zero?
|
|
427
|
+
|
|
428
|
+
request[name] = value
|
|
429
|
+
end
|
|
430
|
+
if form
|
|
431
|
+
request.body = URI.encode_www_form(form)
|
|
432
|
+
# A form body implies the form content type; explicit headers win.
|
|
433
|
+
request["Content-Type"] ||= "application/x-www-form-urlencoded"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Net::HTTP#request implicitly re-starts a finished session, and that
|
|
437
|
+
# restart runs OUTSIDE the connect Timeout.timeout below — an
|
|
438
|
+
# ENV-proxied CONNECT could drip unbounded there (started? stays
|
|
439
|
+
# false, so the watchdog cannot close it). The only path to a
|
|
440
|
+
# finished session is the watchdog, which fires only after the
|
|
441
|
+
# deadline: guarding EVERY start on deadline_fired closes every
|
|
442
|
+
# implicit-reconnect path deterministically.
|
|
443
|
+
http.define_singleton_method(:start) do |&blk|
|
|
444
|
+
raise Net::OpenTimeout, "total deadline exceeded before (re)connect" if deadline_fired
|
|
445
|
+
|
|
446
|
+
super(&blk)
|
|
447
|
+
end
|
|
448
|
+
watchdog = Thread.new do
|
|
449
|
+
remaining = deadline - monotonic_now
|
|
450
|
+
sleep(remaining) if remaining.positive?
|
|
451
|
+
deadline_fired = true
|
|
452
|
+
# Close the session and KEEP closing until the ensure below kills this
|
|
453
|
+
# thread. A one-shot close is wrong twice over: finish raises IOError
|
|
454
|
+
# while the session is still CONNECTING (a single failed attempt would
|
|
455
|
+
# leave the subsequent header read unbounded), and Net::HTTP#request
|
|
456
|
+
# implicitly RE-STARTS a finished session — a close that landed
|
|
457
|
+
# between the post-connect deadline re-check and the request write
|
|
458
|
+
# would hand the reopened connection a fresh, unwatched header wait.
|
|
459
|
+
# Looping bounds any such reopen to one tick. The ensure kills this
|
|
460
|
+
# thread the moment the request completes, so the loop cannot outlive
|
|
461
|
+
# the call.
|
|
462
|
+
loop do
|
|
463
|
+
# The ensure's kill must never land INSIDE finish: interrupted
|
|
464
|
+
# after do_finish clears started? but before the socket close,
|
|
465
|
+
# BOTH closers skip — the request-side ensure sees started? false
|
|
466
|
+
# and this thread is dead — leaking the socket until GC. Defer the
|
|
467
|
+
# kill across each close attempt; it lands at the sleep below.
|
|
468
|
+
Thread.handle_interrupt(Object => :never) do
|
|
469
|
+
http.finish
|
|
470
|
+
rescue IOError
|
|
471
|
+
# Not started: still connecting, or between implicit reopens.
|
|
472
|
+
end
|
|
473
|
+
sleep(0.05)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# The connection phases (TCP connect, proxy CONNECT, TLS handshake) run
|
|
478
|
+
# before Net::HTTP marks the session started, so the watchdog cannot
|
|
479
|
+
# interrupt them (finish raises IOError until then) — and only TCP
|
|
480
|
+
# connect and the TLS handshake carry native open_timeout bounds. A
|
|
481
|
+
# proxy's CONNECT response is parsed under the PER-READ timeout, which
|
|
482
|
+
# resets on every dripped byte: without a whole-phase bound, an
|
|
483
|
+
# ENV-proxied HTTPS request could sit in connection setup indefinitely.
|
|
484
|
+
# Timeout.timeout's async raise is safe here precisely because the
|
|
485
|
+
# guarded region is ONLY connection setup: no response state exists
|
|
486
|
+
# yet, and Net::HTTP's connect rescue closes both sockets on any raise.
|
|
487
|
+
connect_remaining = deadline - monotonic_now
|
|
488
|
+
raise ReadDeadlineExceeded unless connect_remaining.positive?
|
|
489
|
+
|
|
490
|
+
begin
|
|
491
|
+
# start INSIDE the cleanup region: Timeout.timeout's asynchronous
|
|
492
|
+
# Net::OpenTimeout can land after do_start marked the session
|
|
493
|
+
# started but before the next statement — outside the ensure, that
|
|
494
|
+
# window leaked a live connection until GC (the outer ensure can
|
|
495
|
+
# kill the watchdog before it ever closes).
|
|
496
|
+
Timeout.timeout(connect_remaining, Net::OpenTimeout) { http.start }
|
|
497
|
+
# Re-check the deadline the moment the session is up: if setup
|
|
498
|
+
# consumed the whole budget, fail now rather than granting the
|
|
499
|
+
# request a fresh header wait.
|
|
500
|
+
raise ReadDeadlineExceeded if monotonic_now > deadline
|
|
501
|
+
|
|
502
|
+
http.request(request) do |response|
|
|
503
|
+
status = response.code.to_i
|
|
504
|
+
# Headers are available before any body read; the device poll loop
|
|
505
|
+
# uses this to read Retry-After without widening the return shape.
|
|
506
|
+
on_headers&.call(response)
|
|
507
|
+
if skip_status&.call(status)
|
|
508
|
+
# Deadline first for a status NOT known in time: headers that
|
|
509
|
+
# become runnable past the monotonic deadline (but before the
|
|
510
|
+
# watchdog flips deadline_fired) are a timeout, not a
|
|
511
|
+
# classification — matching the Python transport. A status
|
|
512
|
+
# known IN time is still raised before any body read: the
|
|
513
|
+
# SkipBody unwinds to the ensure below, which closes the
|
|
514
|
+
# socket undrained, so a skipped status's body is NEVER
|
|
515
|
+
# drained and a discovery 500 or token redirect known before
|
|
516
|
+
# the deadline never softens into a retryable timeout.
|
|
517
|
+
raise ReadDeadlineExceeded if monotonic_now > deadline
|
|
518
|
+
|
|
519
|
+
raise SkipBody.new(status)
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Net::HTTP#request implicitly re-starts a finished session: if the
|
|
523
|
+
# watchdog's close landed between the deadline re-check above and
|
|
524
|
+
# the request write, this round trip rode a fresh post-deadline
|
|
525
|
+
# connection. The watchdog loop closes such a connection within a
|
|
526
|
+
# tick; this classifies a non-skipped round trip that beat the
|
|
527
|
+
# next tick, BEFORE its body is read.
|
|
528
|
+
raise ReadDeadlineExceeded if deadline_fired
|
|
529
|
+
|
|
530
|
+
response.read_body do |chunk|
|
|
531
|
+
raise ReadDeadlineExceeded if monotonic_now > deadline
|
|
532
|
+
|
|
533
|
+
total += chunk.bytesize
|
|
534
|
+
raise BodyTooLarge if total > max_body_bytes
|
|
535
|
+
|
|
536
|
+
chunks << chunk
|
|
537
|
+
end
|
|
538
|
+
# Stamp completion INSIDE the request block: Net::HTTP's own
|
|
539
|
+
# end_transport runs before http.request returns, and the
|
|
540
|
+
# watchdog racing that cleanup must not erase a completed body.
|
|
541
|
+
completed_at = monotonic_now
|
|
542
|
+
end
|
|
543
|
+
# Final monotonic re-check AFTER the response completes: a peer can
|
|
544
|
+
# deliver the last body chunk just before the deadline and the
|
|
545
|
+
# terminating EOF just after it — read_body runs no further per-chunk
|
|
546
|
+
# check, and the request thread can process that completion before
|
|
547
|
+
# the watchdog sets deadline_fired. A completed response is never
|
|
548
|
+
# accepted past the advertised total-request bound. (Skipped statuses
|
|
549
|
+
# keep status-first classification — SkipBody unwinds before this.)
|
|
550
|
+
raise ReadDeadlineExceeded if monotonic_now > deadline
|
|
551
|
+
ensure
|
|
552
|
+
# Block-form start would close the session itself; with the explicit
|
|
553
|
+
# start (needed so ONLY connection setup sits under Timeout.timeout)
|
|
554
|
+
# close it here. When the connect timeout fires between Net::HTTP's
|
|
555
|
+
# connect assigning a live @socket and do_start marking the session
|
|
556
|
+
# started, started? is still false and Net::HTTP's own connect
|
|
557
|
+
# rescue is out of scope — close the orphaned socket directly or it
|
|
558
|
+
# leaks until GC.
|
|
559
|
+
begin
|
|
560
|
+
if http.started?
|
|
561
|
+
http.finish
|
|
562
|
+
else
|
|
563
|
+
orphan = http.instance_variable_get(:@socket)
|
|
564
|
+
orphan&.close
|
|
565
|
+
end
|
|
566
|
+
rescue IOError
|
|
567
|
+
# Already closed by the watchdog.
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
[ status, chunks.join.force_encoding(Encoding::UTF_8) ]
|
|
571
|
+
rescue SkipBody => e
|
|
572
|
+
[ e.status, "" ]
|
|
573
|
+
rescue Timeout::Error, Errno::ETIMEDOUT => e
|
|
574
|
+
# Timeout::Error covers Net::OpenTimeout/ReadTimeout/WriteTimeout alike
|
|
575
|
+
# (a peer that accepts the connection but stops READING trips the write
|
|
576
|
+
# timeout). Errno::ETIMEDOUT is a SystemCallError, but it is a TIMEOUT:
|
|
577
|
+
# both must map with the timeouts — the exact pair faraday-net_http
|
|
578
|
+
# rescues — or the device poll would terminate instead of applying its
|
|
579
|
+
# transient backoff.
|
|
580
|
+
raise Faraday::TimeoutError, "OAuth request timed out: #{e.message}"
|
|
581
|
+
rescue IOError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError,
|
|
582
|
+
SystemCallError, SocketError, Zlib::Error => e
|
|
583
|
+
# The clock outranks the watchdog FLAG: a wire fault observed past the
|
|
584
|
+
# monotonic deadline is the timeout it raced, even when the watchdog
|
|
585
|
+
# thread has not yet been scheduled to flip deadline_fired — otherwise
|
|
586
|
+
# a post-deadline peer reset classifies as ConnectionFailed and the
|
|
587
|
+
# device poll terminates instead of applying its transient backoff.
|
|
588
|
+
raise classify_stream_error(e, deadline_fired || monotonic_now > deadline)
|
|
589
|
+
rescue NoMethodError => e
|
|
590
|
+
# The watchdog's cross-thread finish can nil @socket between the body
|
|
591
|
+
# completing and Net::HTTP's own end_transport, whose @socket.closed?
|
|
592
|
+
# then raises NoMethodError on the request thread. An in-deadline
|
|
593
|
+
# COMPLETED response dominates that cleanup race (mirroring the
|
|
594
|
+
# Python transport's outcome preservation); otherwise, past the
|
|
595
|
+
# deadline, it is the timeout the cleanup raced. A genuine
|
|
596
|
+
# NoMethodError (a bug) re-raises untouched.
|
|
597
|
+
if completed_at && completed_at <= deadline
|
|
598
|
+
return [ status, chunks.join.force_encoding(Encoding::UTF_8) ]
|
|
599
|
+
end
|
|
600
|
+
raise classify_stream_error(e, true) if deadline_fired || monotonic_now > deadline
|
|
601
|
+
|
|
602
|
+
raise
|
|
603
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
604
|
+
# The watchdog's forced close surfaces mid-handshake/mid-read as an
|
|
605
|
+
# SSLError: past the deadline it is the timeout it raced, same clock
|
|
606
|
+
# rule as the wire-error branch above. A genuine pre-deadline TLS
|
|
607
|
+
# failure (an unverifiable peer certificate above all) still maps to
|
|
608
|
+
# Faraday::SSLError exactly as faraday-net_http maps it, so the
|
|
609
|
+
# default and injected paths classify certificate rejection alike.
|
|
610
|
+
raise Faraday::TimeoutError, "OAuth request timed out: TLS interrupted past the deadline" \
|
|
611
|
+
if deadline_fired || monotonic_now > deadline
|
|
612
|
+
|
|
613
|
+
raise Faraday::SSLError, e.message
|
|
614
|
+
ensure
|
|
615
|
+
watchdog&.kill
|
|
616
|
+
watchdog&.join
|
|
617
|
+
end
|
|
618
|
+
|
|
147
619
|
# Fetches +url+ and returns the parsed JSON object (a Hash).
|
|
148
620
|
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
# connection
|
|
152
|
-
#
|
|
621
|
+
# With a nil +http_client+ the fetch runs on the headers-first
|
|
622
|
+
# {stream_http} primitive (total wall-clock bound incl. the header phase).
|
|
623
|
+
# An INJECTED connection keeps the Faraday path: the request timeout is
|
|
624
|
+
# applied per-request (not only on the connection) so a bounded read is
|
|
625
|
+
# enforced even under the injected adapter's defaults, and the wall-clock
|
|
626
|
+
# deadline bounds the whole body read — but Faraday exposes no
|
|
627
|
+
# headers-time callback, so a body that stalls past the read timeout on
|
|
628
|
+
# the injected path surfaces as a bounded transport timeout.
|
|
153
629
|
#
|
|
154
|
-
# @param http_client [Faraday::Connection]
|
|
630
|
+
# @param http_client [Faraday::Connection, nil] injected connection, or
|
|
631
|
+
# nil for the default headers-first transport
|
|
155
632
|
# @param url [String] fully-qualified well-known URL to fetch
|
|
156
|
-
# @param timeout [
|
|
633
|
+
# @param timeout [Numeric] per-request timeout in seconds (fractional accepted)
|
|
157
634
|
# @param max_body_bytes [Integer] bounded read cap in bytes
|
|
158
635
|
# @return [Hash] the parsed JSON document
|
|
159
636
|
# @raise [OauthError] +api_error+ on non-2xx, oversized body, non-object
|
|
160
637
|
# JSON, or parse failure; +network+ on transport failure
|
|
161
638
|
def self.fetch_json(http_client, url, timeout:, max_body_bytes: DEFAULT_MAX_BODY_BYTES)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
body = chunks.join.force_encoding(Encoding::UTF_8)
|
|
639
|
+
status, body =
|
|
640
|
+
if http_client.nil?
|
|
641
|
+
stream_http(
|
|
642
|
+
:get, url,
|
|
643
|
+
headers: { "Accept" => "application/json" },
|
|
644
|
+
timeout: timeout, max_body_bytes: max_body_bytes,
|
|
645
|
+
# STATUS DOMINATES THE BODY (SPEC.md: non-2xx on either hop →
|
|
646
|
+
# api_error, never network): skip draining a non-2xx body so a
|
|
647
|
+
# stalled/dripped error body cannot convert the required api_error
|
|
648
|
+
# into a network timeout. The body text was only optional
|
|
649
|
+
# diagnostics — the other SDKs read it best-effort at most.
|
|
650
|
+
skip_status: ->(response_status) { !(200..299).cover?(response_status) }
|
|
651
|
+
)
|
|
652
|
+
else
|
|
653
|
+
faraday_fetch(http_client, url, timeout: timeout, max_body_bytes: max_body_bytes)
|
|
654
|
+
end
|
|
180
655
|
|
|
181
|
-
unless (200..299).cover?(
|
|
656
|
+
unless (200..299).cover?(status)
|
|
657
|
+
# Status-only, on BOTH paths: the error category, retryability, and
|
|
658
|
+
# http_status are the observable contract; embedding response body
|
|
659
|
+
# text would put attacker-influenced content in exception messages
|
|
660
|
+
# and diverge from the (body-less) default transport and Python.
|
|
182
661
|
raise OauthError.new(
|
|
183
662
|
"api_error",
|
|
184
|
-
"OAuth discovery failed with status #{
|
|
185
|
-
http_status:
|
|
663
|
+
"OAuth discovery failed with status #{status}",
|
|
664
|
+
http_status: status
|
|
186
665
|
)
|
|
187
666
|
end
|
|
188
667
|
|
|
@@ -199,6 +678,97 @@ module Basecamp
|
|
|
199
678
|
rescue JSON::ParserError => e
|
|
200
679
|
raise OauthError.new("api_error", "Failed to parse OAuth discovery response: #{e.message}")
|
|
201
680
|
end
|
|
681
|
+
|
|
682
|
+
# The Faraday transport for an INJECTED connection. The request timeout is
|
|
683
|
+
# applied per-request (not only on the connection) so a bounded read is
|
|
684
|
+
# enforced even under the injected adapter's defaults, and the wall-clock
|
|
685
|
+
# deadline bounds the whole body read.
|
|
686
|
+
#
|
|
687
|
+
# Known residual (injected connections only): unlike the default
|
|
688
|
+
# {stream_http} path — which skips unusable bodies by status at header
|
|
689
|
+
# time — Faraday exposes no headers-time seam, so a non-2xx body is
|
|
690
|
+
# drained here under the same cap + deadline before the status error
|
|
691
|
+
# surfaces. Bounded, never followed; callers who inject a connection
|
|
692
|
+
# keep their transport's semantics by design.
|
|
693
|
+
#
|
|
694
|
+
# @return [Array(Integer, String)] status and body
|
|
695
|
+
def self.faraday_fetch(http_client, url, timeout:, max_body_bytes:)
|
|
696
|
+
# Wall-clock deadline over the WHOLE read: req.options.timeout below bounds
|
|
697
|
+
# only each socket read and resets on every chunk, so a slow-drip peer could
|
|
698
|
+
# otherwise hang the fetch indefinitely while staying under max_body_bytes.
|
|
699
|
+
deadline = monotonic_now + timeout
|
|
700
|
+
# Streaming adapters (Faraday >= 2.5 pass +env+ to on_data) classify a
|
|
701
|
+
# non-2xx at HEADER time like the default path — fetch_json discards
|
|
702
|
+
# non-2xx bodies, so draining one is pure waste. Buffered adapters
|
|
703
|
+
# remain the documented drain-then-classify residual.
|
|
704
|
+
chunks, on_data = bounded_reader(
|
|
705
|
+
max_body_bytes, deadline: deadline,
|
|
706
|
+
skip_status: ->(s) { !(200..299).cover?(s) }
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# Timeout.timeout wraps the WHOLE call: the per-read timeout resets on
|
|
710
|
+
# every socket read, so a peer dripping HEADER bytes under it would
|
|
711
|
+
# otherwise hold the request open indefinitely — on_data (a body
|
|
712
|
+
# callback) never runs during the header phase, leaving nothing else
|
|
713
|
+
# to enforce the wall clock on an injected client.
|
|
714
|
+
# The window is the REMAINING budget, not a fresh +timeout+: time
|
|
715
|
+
# spent before dispatch (descheduling included) already counts
|
|
716
|
+
# against the deadline, so the request can never run past it.
|
|
717
|
+
remaining = deadline - monotonic_now
|
|
718
|
+
raise Faraday::TimeoutError, "request budget exhausted before dispatch" if remaining <= 0
|
|
719
|
+
|
|
720
|
+
response = Timeout.timeout(remaining, Faraday::TimeoutError) do
|
|
721
|
+
http_client.get(url) do |req|
|
|
722
|
+
req.headers["Accept"] = "application/json"
|
|
723
|
+
# Bounded streaming read: abort the moment the cap is exceeded so an
|
|
724
|
+
# oversized body is never fully buffered.
|
|
725
|
+
req.options.on_data = on_data
|
|
726
|
+
# Apply the request timeout on every request — even an injected client —
|
|
727
|
+
# so a stalled socket can't hang discovery under the adapter default.
|
|
728
|
+
req.options.timeout = timeout
|
|
729
|
+
req.options.open_timeout = timeout
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# Timeout.timeout's interrupt can be delivered late: a 2xx whose block
|
|
734
|
+
# returns just after the deadline would otherwise be accepted past the
|
|
735
|
+
# wall clock. A completed non-2xx stays status-classified (status
|
|
736
|
+
# outranks the deadline race, matching the default path); only a late
|
|
737
|
+
# 2xx is refused as the same transport-shaped timeout.
|
|
738
|
+
raise Faraday::TimeoutError, "response completed after the deadline" \
|
|
739
|
+
if (200..299).cover?(response.status) && monotonic_now > deadline
|
|
740
|
+
|
|
741
|
+
body =
|
|
742
|
+
if chunks.empty?
|
|
743
|
+
if (200..299).cover?(response.status)
|
|
744
|
+
# A buffered adapter (Faraday's test adapter above all) never
|
|
745
|
+
# invokes on_data: the streamed chunks are empty while the body
|
|
746
|
+
# sits on the response. Fall back to it under the same cap so an
|
|
747
|
+
# injected buffered client gets the document instead of a bogus
|
|
748
|
+
# empty-body parse failure — mirroring post_form's fallback.
|
|
749
|
+
# dup ONLY a frozen body (test adapters return literals, which
|
|
750
|
+
# cannot take force_encoding below): copying every large
|
|
751
|
+
# buffered 2xx unconditionally would double peak memory.
|
|
752
|
+
raw = response.body.to_s
|
|
753
|
+
raw = raw.dup if raw.frozen?
|
|
754
|
+
raise BodyTooLarge if raw.bytesize > max_body_bytes
|
|
755
|
+
|
|
756
|
+
raw
|
|
757
|
+
else
|
|
758
|
+
# fetch_json discards non-2xx bodies (status dominates, SPEC.md):
|
|
759
|
+
# return status-only rather than dup-and-size-checking a body
|
|
760
|
+
# nobody reads, so an oversized buffered error body surfaces as
|
|
761
|
+
# the status fault — not a size-cap one — and is never copied.
|
|
762
|
+
# +"" — the frozen literal cannot take force_encoding below.
|
|
763
|
+
+""
|
|
764
|
+
end
|
|
765
|
+
else
|
|
766
|
+
chunks.join
|
|
767
|
+
end
|
|
768
|
+
[ response.status, body.force_encoding(Encoding::UTF_8) ]
|
|
769
|
+
rescue SkipBody => e
|
|
770
|
+
[ e.status, "" ]
|
|
771
|
+
end
|
|
202
772
|
end
|
|
203
773
|
end
|
|
204
774
|
end
|