patient_http 1.7.0 → 1.8.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/ARCHITECTURE.md +10 -1
- data/CHANGELOG.md +14 -0
- data/README.md +35 -3
- data/VERSION +1 -1
- data/lib/patient_http/client.rb +30 -9
- data/lib/patient_http/configuration.rb +39 -1
- data/lib/patient_http/request.rb +49 -2
- data/lib/patient_http/request_helper.rb +37 -1
- data/lib/patient_http/request_task.rb +2 -0
- data/lib/patient_http/request_template.rb +33 -1
- data/lib/patient_http/response_reader.rb +107 -60
- data/lib/patient_http/synchronous_executor.rb +22 -5
- data/lib/patient_http.rb +16 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c4e10ada7964f607e81e169e4f88086d3dd32967fd6bf6ed48025615a133d5fd
|
|
4
|
+
data.tar.gz: 704ca986fb7622f6e977d8d02d76e198c98abdd1c5ea808d85c8af25bdd3f72f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 442991bea3a29c334866d9136bca7f9ada37d68326b537b31571bae9331f93387e3a617ff970e1433f289fbb078346b33a9394a22afc9eea01e7347233476fe9
|
|
7
|
+
data.tar.gz: baee6575b4bdf5f7798548cc17419d33234f255088bd612d41daec3bceafd56a7a96acb7f86f991d2b6f1a9e31f316100dee6b243e14b8b56831935614b46055
|
data/ARCHITECTURE.md
CHANGED
|
@@ -206,6 +206,8 @@ erDiagram
|
|
|
206
206
|
hash headers
|
|
207
207
|
string body
|
|
208
208
|
float timeout
|
|
209
|
+
float read_timeout
|
|
210
|
+
int max_response_size
|
|
209
211
|
}
|
|
210
212
|
|
|
211
213
|
REQUEST-TASK {
|
|
@@ -283,6 +285,12 @@ The processor uses Ruby's Fiber scheduler (`async` gem) for non-blocking I/O:
|
|
|
283
285
|
4. **Completion worker threads** decode response bodies (join, inflate, charset), build responses, and execute TaskHandler callbacks and `request_end` observers off the reactor. Decoding is CPU-bound and has no fiber yield point, so running it inline on the reactor would stop every other in-flight request until it finished. On a worker thread the Ruby scheduler can preempt it, and Zlib releases the GVL for part of the inflate
|
|
284
286
|
5. **Delivery is concurrent, not serialized.** Callbacks and completion-time observers run on any of the `completion_threads` workers, so they must be thread-safe. Completions are also bounded: a task stays in the capacity count until a worker claims its result, so the completion backlog can never exceed `max_connections`
|
|
285
287
|
|
|
288
|
+
## Timeouts and Response Size
|
|
289
|
+
|
|
290
|
+
A request can have two timeouts. The request timeout limits the full exchange. The read timeout limits each wait for data: the wait for the response headers, and then the wait for each body chunk. The first timer starts before the connection opens, so it also covers opening the connection, sending the request, and the immediate retries of a refused connection. Each wait has its own timer, so a slow but steady stream continues until the request timeout stops it. The read timeout raises `ReadTimeoutError`, a subclass of `Async::TimeoutError`. The immediate retries never retry either timeout.
|
|
291
|
+
|
|
292
|
+
The response size limit comes from the request, or from the configuration if the request doesn't set one. The reactor applies the limit to the declared body length before the read starts and to the raw bytes as they arrive. The raw response data that goes to the completion worker includes the limit, so the worker applies the same limit to the inflated body.
|
|
293
|
+
|
|
286
294
|
## State Management
|
|
287
295
|
|
|
288
296
|
The processor maintains state through its lifecycle:
|
|
@@ -307,7 +315,8 @@ When the processor is stopped with in-flight requests:
|
|
|
307
315
|
All behavior is controlled through a central `Configuration` object:
|
|
308
316
|
|
|
309
317
|
- Maximum concurrent connections
|
|
310
|
-
- Request timeouts
|
|
318
|
+
- Request timeouts and read timeouts
|
|
319
|
+
- Response size limits
|
|
311
320
|
- Connection pool settings
|
|
312
321
|
- Retry policies
|
|
313
322
|
- Proxy configuration
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 1.8.0
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `read_timeout` request option and `Configuration#read_timeout` default (default `nil`, no limit): the maximum number of seconds to wait for data from the server. The first timer starts before the connection opens and covers opening the connection, sending the request, and waiting for the response headers. The timer starts again for each body chunk, so a streamed response that keeps sending data can run longer than the read timeout while a stalled stream fails fast. The request timeout still limits the full exchange. When the read timeout fires, the error is a `PatientHttp::ReadTimeoutError`, a subclass of `Async::TimeoutError`, so `RequestError#error_type` is `:timeout`. It is never retried. Don't use it for requests where the server does all of its work before it sends the headers.
|
|
12
|
+
- `max_response_size` request option: a per-request override of `Configuration#max_response_size`. It applies to the declared body length, to the bytes read, and to the inflated body.
|
|
13
|
+
- `RequestTemplate` and `RequestHelper.request_template` accept `read_timeout` and `max_response_size` defaults. The `RequestHelper` `async_*` methods and the `PatientHttp` request methods accept both options.
|
|
14
|
+
- Both options are serialized with the request only when they are set. Requests serialized by earlier versions load with both options unset.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- The response size check before the body is read now works on HTTP/1 connections. The HTTP/1 parser removes `Content-Length` from the response headers, so the check only ran when the header was present, as it is in test stubs. The declared length of the body is now used when the header is missing.
|
|
19
|
+
- The response body is closed when the size check before the read fails. Before, `SynchronousExecutor` left the connection checked out and waited forever when it closed its client pool.
|
|
20
|
+
|
|
7
21
|
## 1.7.0
|
|
8
22
|
|
|
9
23
|
### Added
|
data/README.md
CHANGED
|
@@ -109,6 +109,8 @@ The methods take these options:
|
|
|
109
109
|
| `json:` | An object to send as a JSON body. Can't be combined with `body:`. |
|
|
110
110
|
| `params:` | Query parameters to add to the URL. |
|
|
111
111
|
| `timeout:` | The request timeout in seconds. |
|
|
112
|
+
| `read_timeout:` | The maximum number of seconds to wait for data from the server. See [Streamed and long-running responses](#streamed-and-long-running-responses). |
|
|
113
|
+
| `max_response_size:` | The maximum response body size in bytes for this request. Overrides the configured `max_response_size`. |
|
|
112
114
|
| `raise_error_responses:` | Whether to treat non-2xx responses as errors. See [Handle HTTP error responses](#handle-http-error-responses). |
|
|
113
115
|
| `max_redirects:` | The maximum number of redirects to follow for this request. `0` turns off redirects. |
|
|
114
116
|
| `follow_method_changing_redirects:` | Whether to follow redirects that change the HTTP method. See [Redirects](#redirects). |
|
|
@@ -217,7 +219,7 @@ request = template.get("/users/123")
|
|
|
217
219
|
PatientHttp.execute(request: request, callback: FetchUserCallback)
|
|
218
220
|
```
|
|
219
221
|
|
|
220
|
-
A template has a method for each HTTP method. The template joins each path with the base URL, and merges the headers and query parameters of each request with its own. If the template doesn't set a `timeout`, the configured `request_timeout` applies.
|
|
222
|
+
A template has a method for each HTTP method. The template joins each path with the base URL, and merges the headers and query parameters of each request with its own. If the template doesn't set a `timeout`, the configured `request_timeout` applies. A template can also set defaults for `read_timeout` and `max_response_size`. A value given to a request overrides the template value.
|
|
221
223
|
|
|
222
224
|
### Use the RequestHelper module
|
|
223
225
|
|
|
@@ -245,6 +247,29 @@ end
|
|
|
245
247
|
|
|
246
248
|
The `async_*` methods take the same options as the `PatientHttp` module methods. Paths are relative to the template's `base_url`. A subclass uses the template of its superclass unless it declares its own.
|
|
247
249
|
|
|
250
|
+
### Streamed and long-running responses
|
|
251
|
+
|
|
252
|
+
The `timeout` option limits the full request, from the time it is sent until the last byte of the body arrives. A streamed response, such as server-sent events from an LLM API, can continue for many minutes. A large `timeout` lets the stream finish, but it cannot find a stream that stops.
|
|
253
|
+
|
|
254
|
+
To find a stream that stops, set `read_timeout`. This is the maximum number of seconds to wait for data from the server. The first timer starts before the connection opens. It covers the time to get a connection, send the request, and wait for the response headers. The timer starts again each time a body chunk arrives. A stream that sends data at regular intervals can continue for longer than `read_timeout`, until `timeout` stops it.
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
PatientHttp.post(
|
|
258
|
+
"https://api.example.com/v1/generate",
|
|
259
|
+
json: {prompt: "...", stream: true},
|
|
260
|
+
callback: GenerateCallback,
|
|
261
|
+
timeout: 30 * 60, # Limit for the full stream.
|
|
262
|
+
read_timeout: 60, # Limit for each wait for data.
|
|
263
|
+
max_response_size: 8 * 1024 * 1024
|
|
264
|
+
)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
When the read timeout stops a request, the `on_error` callback receives a `RequestError` with an `error_type` of `:timeout` and an `error_class` of `PatientHttp::ReadTimeoutError`. When `timeout` stops a request, the `error_class` is `Async::TimeoutError`. A request stopped by either timeout isn't retried, because the server may have processed it.
|
|
268
|
+
|
|
269
|
+
Do not set `read_timeout` for a request where the server does all of its work before it sends the response headers. For that request, the wait for the headers can be longer than the read timeout, and the request fails.
|
|
270
|
+
|
|
271
|
+
The callback receives the full body when the stream ends. A streamed body is usually larger than the equivalent JSON body, so set `max_response_size` on the request to increase the limit for these requests only.
|
|
272
|
+
|
|
248
273
|
## Configuration
|
|
249
274
|
|
|
250
275
|
All configuration is optional. To set options, call `PatientHttp.configure` in an initializer. If an integration gem is loaded, the method yields that gem's configuration, which adds the options for the job system to the options below.
|
|
@@ -262,11 +287,17 @@ PatientHttp.configure do |config|
|
|
|
262
287
|
# Default timeout for HTTP requests in seconds (default: 60).
|
|
263
288
|
config.request_timeout = 60
|
|
264
289
|
|
|
290
|
+
# Default maximum number of seconds to wait for data from the server
|
|
291
|
+
# (default: nil, no limit). The timer covers the wait for the response
|
|
292
|
+
# headers and starts again each time a body chunk arrives. See "Streamed and
|
|
293
|
+
# long-running responses".
|
|
294
|
+
config.read_timeout = nil
|
|
295
|
+
|
|
265
296
|
# Timeout for graceful shutdown in seconds (default: 30).
|
|
266
297
|
config.shutdown_timeout = 30
|
|
267
298
|
|
|
268
299
|
# Maximum response body size in bytes (default: 1MB). Larger responses raise
|
|
269
|
-
# ResponseTooLargeError.
|
|
300
|
+
# ResponseTooLargeError. A request can set its own max_response_size.
|
|
270
301
|
config.max_response_size = 1024 * 1024
|
|
271
302
|
|
|
272
303
|
# Default User-Agent header for all requests (default: "PatientHttp").
|
|
@@ -369,11 +400,12 @@ For how the processor name is kept through retries and crash recovery, see the d
|
|
|
369
400
|
- `max_connections`: Set this based on your system's resources. Each connection uses memory and a file descriptor. A tuned system with enough resources can handle thousands of concurrent connections.
|
|
370
401
|
- `max_connections_per_host`: Limits the sockets open to each host. The default is no limit. For high concurrency, set a value such as 32, so that one host can't use every file descriptor. Make sure that the process file descriptor limit covers `max_connections`, plus idle pooled connections, plus the application's own connections.
|
|
371
402
|
- `request_timeout`: Set this based on the response times of the APIs that you call. AI APIs can take minutes to respond while they generate content.
|
|
403
|
+
- `read_timeout`: Limits each wait for data from the server, including the wait for the response headers. Set it on requests that stream their response, so that a stream that stops fails before `request_timeout`. Do not set it as a default if the APIs that you call do their work before they send the response headers.
|
|
372
404
|
- `connection_timeout`: Limits only the TCP connect and the TLS handshake. Set it to fail fast when a host doesn't answer. It doesn't limit the wait for a response, because `request_timeout` controls the full exchange.
|
|
373
405
|
- `tcp_keepalive`: The kernel sends probes on an idle pooled connection. The probes keep NAT and firewall mappings open, and let the kernel find a dead peer before the pool sends a request on the connection. Set this option when connections stay idle in the pool between requests.
|
|
374
406
|
- `tcp_user_timeout`: The kernel closes a connection when the peer doesn't acknowledge sent data. This option is available only on Linux. A request to a peer that stopped without notice fails after this timeout, without waiting for `request_timeout`. Acknowledged data isn't affected, so a slow response continues.
|
|
375
407
|
- `connection_pool_size`: Sets the maximum number of hosts whose connections are kept open. Increase it if your application calls many different hosts.
|
|
376
|
-
- `max_response_size`: Limits the size of HTTP responses to prevent high memory use from unexpectedly large responses. For a compressed response, the limit applies to the decompressed body. For large responses, consider a [payload store](#payload-stores).
|
|
408
|
+
- `max_response_size`: Limits the size of HTTP responses to prevent high memory use from unexpectedly large responses. For a compressed response, the limit applies to the decompressed body. To increase the limit for some requests only, set `max_response_size` on those requests. For large responses, consider a [payload store](#payload-stores).
|
|
377
409
|
- `completion_threads`: Increase this when result delivery does heavy work, such as serialization or encryption, and finished requests wait for a thread. If the value is greater than 1, results are delivered concurrently, so `TaskHandler` callbacks and completion-time observers must be thread-safe. Set it to 1 to deliver results one at a time.
|
|
378
410
|
- `completion_retries`: The number of times to retry result delivery before the failure is reported to observers through `completion_failed`. A retry calls `on_complete` or `on_error` again. As a result, a handler that raises an error *after* it enqueues its message delivers that message twice. Make handlers idempotent, or set `completion_retries` to 0 to report the first failure without a retry.
|
|
379
411
|
- `shutdown_timeout`: Must be less than the process supervisor's stop timeout, so that in-flight requests and their results finish before a hard kill.
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.8.0
|
data/lib/patient_http/client.rb
CHANGED
|
@@ -23,9 +23,15 @@ module PatientHttp
|
|
|
23
23
|
# use {#decode_response} to produce the final body string. Splitting the
|
|
24
24
|
# decode out keeps CPU-bound work off the reactor thread.
|
|
25
25
|
#
|
|
26
|
+
# The request timeout limits the full exchange. The read timeout, if set,
|
|
27
|
+
# limits the wait for the response headers and the wait for each body
|
|
28
|
+
# chunk.
|
|
29
|
+
#
|
|
26
30
|
# @param request [Request] The request to make.
|
|
27
31
|
# @param request_id [String] Unique request identifier.
|
|
28
|
-
# @return [Hash] The response data with keys for :status, :headers,
|
|
32
|
+
# @return [Hash] The response data with keys for :status, :headers, :body,
|
|
33
|
+
# and :max_response_size. The size limit goes with the data so that
|
|
34
|
+
# {#decode_response} applies the same limit to the inflated body.
|
|
29
35
|
def make_request(request, request_id)
|
|
30
36
|
async_response = nil
|
|
31
37
|
client = nil
|
|
@@ -36,21 +42,31 @@ module PatientHttp
|
|
|
36
42
|
headers = outgoing.headers.to_h
|
|
37
43
|
body = Protocol::HTTP::Body::Buffered.wrap([request.body.to_s]) if request.body
|
|
38
44
|
timeout = request.timeout || config.request_timeout
|
|
45
|
+
read_timeout = request.read_timeout || config.read_timeout
|
|
46
|
+
max_response_size = request.max_response_size || config.max_response_size
|
|
39
47
|
|
|
40
48
|
Async::Task.current.with_timeout(timeout) do
|
|
41
49
|
endpoint = Async::HTTP::Endpoint.parse(url)
|
|
42
|
-
async_response =
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
async_response = ResponseReader.with_read_timeout(read_timeout) do
|
|
51
|
+
request_with_immediate_retries(
|
|
52
|
+
@client_pool, request, endpoint, headers, body
|
|
53
|
+
) { |pooled_client| client = pooled_client }
|
|
54
|
+
end
|
|
45
55
|
# Note: headers that appear multiple times (e.g. set-cookie) are
|
|
46
56
|
# flattened to a single joined string value.
|
|
47
57
|
headers_hash = async_response.headers.to_h.transform_values(&:to_s)
|
|
48
|
-
body = @response_reader.read_raw_body(
|
|
58
|
+
body = @response_reader.read_raw_body(
|
|
59
|
+
async_response,
|
|
60
|
+
headers_hash,
|
|
61
|
+
max_response_size: max_response_size,
|
|
62
|
+
read_timeout: read_timeout
|
|
63
|
+
)
|
|
49
64
|
|
|
50
65
|
{
|
|
51
66
|
status: async_response.status,
|
|
52
67
|
headers: headers_hash,
|
|
53
|
-
body: body
|
|
68
|
+
body: body,
|
|
69
|
+
max_response_size: max_response_size
|
|
54
70
|
}
|
|
55
71
|
end
|
|
56
72
|
rescue => e
|
|
@@ -75,14 +91,19 @@ module PatientHttp
|
|
|
75
91
|
# intended to run on a completion worker thread.
|
|
76
92
|
#
|
|
77
93
|
# @param response_data [Hash] Raw response data from {#make_request}.
|
|
78
|
-
# @return [Hash] Response data with the
|
|
94
|
+
# @return [Hash] Response data with the :status, :headers, and decoded
|
|
95
|
+
# :body keys.
|
|
79
96
|
# @raise [ResponseTooLargeError] If the inflated body exceeds max_response_size.
|
|
80
97
|
def decode_response(response_data)
|
|
81
98
|
headers = response_data[:headers]
|
|
82
|
-
body = @response_reader.decode_body(
|
|
99
|
+
body = @response_reader.decode_body(
|
|
100
|
+
response_data[:body],
|
|
101
|
+
headers,
|
|
102
|
+
max_response_size: response_data[:max_response_size]
|
|
103
|
+
)
|
|
83
104
|
headers = ResponseReader.rewrite_content_encoding(headers)
|
|
84
105
|
|
|
85
|
-
response_data.merge(headers: headers, body: body)
|
|
106
|
+
response_data.except(:max_response_size).merge(headers: headers, body: body)
|
|
86
107
|
end
|
|
87
108
|
|
|
88
109
|
# Closes all clients and releases their resources.
|
|
@@ -42,6 +42,10 @@ module PatientHttp
|
|
|
42
42
|
# @return [Numeric] The default request timeout in seconds.
|
|
43
43
|
attr_reader :request_timeout
|
|
44
44
|
|
|
45
|
+
# @return [Numeric, nil] The default maximum number of seconds to wait for
|
|
46
|
+
# data from the server, or `nil` for no limit. See {#read_timeout=}.
|
|
47
|
+
attr_reader :read_timeout
|
|
48
|
+
|
|
45
49
|
# @return [Numeric] The graceful shutdown timeout in seconds.
|
|
46
50
|
attr_reader :shutdown_timeout
|
|
47
51
|
|
|
@@ -113,6 +117,9 @@ module PatientHttp
|
|
|
113
117
|
#
|
|
114
118
|
# @param max_connections [Integer] The maximum number of concurrent requests.
|
|
115
119
|
# @param request_timeout [Numeric] The default request timeout in seconds.
|
|
120
|
+
# @param read_timeout [Numeric, nil] The default maximum number of seconds to
|
|
121
|
+
# wait for data from the server. If `nil`, there is no limit. See
|
|
122
|
+
# {#read_timeout=}.
|
|
116
123
|
# @param shutdown_timeout [Numeric] The graceful shutdown timeout in seconds.
|
|
117
124
|
# @param logger [Logger, nil] The logger. If `nil`, errors are logged to
|
|
118
125
|
# standard error.
|
|
@@ -157,6 +164,7 @@ module PatientHttp
|
|
|
157
164
|
def initialize(
|
|
158
165
|
max_connections: 256,
|
|
159
166
|
request_timeout: 60,
|
|
167
|
+
read_timeout: nil,
|
|
160
168
|
shutdown_timeout: 30,
|
|
161
169
|
logger: nil,
|
|
162
170
|
max_response_size: 1024 * 1024,
|
|
@@ -195,6 +203,7 @@ module PatientHttp
|
|
|
195
203
|
|
|
196
204
|
self.max_connections = max_connections
|
|
197
205
|
self.request_timeout = request_timeout
|
|
206
|
+
self.read_timeout = read_timeout
|
|
198
207
|
self.shutdown_timeout = shutdown_timeout
|
|
199
208
|
self.logger = logger || Logger.new($stderr, level: Logger::ERROR)
|
|
200
209
|
self.max_response_size = max_response_size
|
|
@@ -293,6 +302,33 @@ module PatientHttp
|
|
|
293
302
|
@request_timeout = value
|
|
294
303
|
end
|
|
295
304
|
|
|
305
|
+
# Sets the default read timeout. This is the maximum number of seconds to
|
|
306
|
+
# wait for data from the server. The timer starts again each time a body
|
|
307
|
+
# chunk arrives. A response that sends data at regular intervals can thus
|
|
308
|
+
# continue for longer than the read timeout. The request timeout continues
|
|
309
|
+
# to limit the full request.
|
|
310
|
+
#
|
|
311
|
+
# The first timer starts before the connection opens. It covers the time to
|
|
312
|
+
# get a connection, send the request, and wait for the response headers.
|
|
313
|
+
# Immediate retries of a refused connection use the same timer. This is
|
|
314
|
+
# different from clients such as Net::HTTP, which use a separate open
|
|
315
|
+
# timeout and write timeout. The connection timeout continues to limit the
|
|
316
|
+
# time to open a connection.
|
|
317
|
+
#
|
|
318
|
+
# Use it for streamed responses, such as server-sent events. Do not use it
|
|
319
|
+
# for requests where the server does all of its work before it sends the
|
|
320
|
+
# response headers, because the wait for the headers must then be shorter
|
|
321
|
+
# than the read timeout.
|
|
322
|
+
#
|
|
323
|
+
# @param value [Numeric, nil] A positive number of seconds, or `nil` for no
|
|
324
|
+
# limit.
|
|
325
|
+
# @return [void]
|
|
326
|
+
# @raise [ArgumentError] If the value isn't positive or `nil`.
|
|
327
|
+
def read_timeout=(value)
|
|
328
|
+
validate_positive(:read_timeout, value) unless value.nil?
|
|
329
|
+
@read_timeout = value
|
|
330
|
+
end
|
|
331
|
+
|
|
296
332
|
# Sets the graceful shutdown timeout. Keep it less than the stop timeout of
|
|
297
333
|
# the process supervisor, so that in-flight requests finish before a hard
|
|
298
334
|
# kill.
|
|
@@ -307,7 +343,8 @@ module PatientHttp
|
|
|
307
343
|
|
|
308
344
|
# Sets the maximum response body size. For a compressed response, the limit
|
|
309
345
|
# applies to the decompressed body. A larger response raises
|
|
310
|
-
# {ResponseTooLargeError}.
|
|
346
|
+
# {ResponseTooLargeError}. A request can set its own limit with
|
|
347
|
+
# {Request#max_response_size}.
|
|
311
348
|
#
|
|
312
349
|
# @param value [Integer] A positive number of bytes.
|
|
313
350
|
# @return [void]
|
|
@@ -695,6 +732,7 @@ module PatientHttp
|
|
|
695
732
|
{
|
|
696
733
|
"max_connections" => max_connections,
|
|
697
734
|
"request_timeout" => request_timeout,
|
|
735
|
+
"read_timeout" => read_timeout,
|
|
698
736
|
"shutdown_timeout" => shutdown_timeout,
|
|
699
737
|
"logger" => logger,
|
|
700
738
|
"max_response_size" => max_response_size,
|
data/lib/patient_http/request.rb
CHANGED
|
@@ -39,6 +39,16 @@ module PatientHttp
|
|
|
39
39
|
# @return [Numeric, nil] The timeout in seconds for the full request.
|
|
40
40
|
attr_reader :timeout
|
|
41
41
|
|
|
42
|
+
# @return [Numeric, nil] The maximum number of seconds to wait for data from
|
|
43
|
+
# the server. The timer covers the wait for the response headers and
|
|
44
|
+
# starts again each time a body chunk arrives. If `nil`, the configuration
|
|
45
|
+
# value applies.
|
|
46
|
+
attr_reader :read_timeout
|
|
47
|
+
|
|
48
|
+
# @return [Integer, nil] The maximum response body size in bytes for this
|
|
49
|
+
# request. If `nil`, the configuration value applies.
|
|
50
|
+
attr_reader :max_response_size
|
|
51
|
+
|
|
42
52
|
# @return [Integer, nil] The maximum number of redirects to follow. If `0`,
|
|
43
53
|
# redirects aren't followed. If `nil`, the configuration value applies.
|
|
44
54
|
attr_reader :max_redirects
|
|
@@ -79,6 +89,8 @@ module PatientHttp
|
|
|
79
89
|
body: Payload.load(hash["body"])&.value,
|
|
80
90
|
params: load_secret_params(hash["secret_params"]),
|
|
81
91
|
timeout: hash["timeout"],
|
|
92
|
+
read_timeout: hash["read_timeout"],
|
|
93
|
+
max_response_size: hash["max_response_size"],
|
|
82
94
|
max_redirects: hash["max_redirects"],
|
|
83
95
|
follow_method_changing_redirects: hash["follow_method_changing_redirects"],
|
|
84
96
|
redirect_strip_headers: hash["redirect_strip_headers"],
|
|
@@ -118,6 +130,14 @@ module PatientHttp
|
|
|
118
130
|
# with `body`.
|
|
119
131
|
# @param params [Hash, nil] The query parameters to add to the URL.
|
|
120
132
|
# @param timeout [Numeric, nil] The timeout in seconds for the full request.
|
|
133
|
+
# @param read_timeout [Numeric, nil] The maximum number of seconds to wait
|
|
134
|
+
# for data from the server. The timer covers the wait for the response
|
|
135
|
+
# headers and starts again each time a body chunk arrives. Use it for
|
|
136
|
+
# streamed responses. Do not use it when the server does all of its work
|
|
137
|
+
# before it sends the headers. If `nil`, the configuration value applies.
|
|
138
|
+
# @param max_response_size [Integer, nil] The maximum response body size in
|
|
139
|
+
# bytes. For a compressed response, the limit also applies to the
|
|
140
|
+
# decompressed body. If `nil`, the configuration value applies.
|
|
121
141
|
# @param max_redirects [Integer, nil] The maximum number of redirects to
|
|
122
142
|
# follow. If `0`, redirects aren't followed. If `nil`, the configuration
|
|
123
143
|
# value applies.
|
|
@@ -132,8 +152,8 @@ module PatientHttp
|
|
|
132
152
|
# @param processor [String, Symbol, nil] The name of the processor that runs
|
|
133
153
|
# the request. Integrations use this value to send the request to a named
|
|
134
154
|
# processor.
|
|
135
|
-
# @raise [ArgumentError] If the HTTP method, URL,
|
|
136
|
-
# both `body` and `json` are given.
|
|
155
|
+
# @raise [ArgumentError] If the HTTP method, URL, body, `read_timeout`, or
|
|
156
|
+
# `max_response_size` isn't valid, or if both `body` and `json` are given.
|
|
137
157
|
def initialize(
|
|
138
158
|
http_method,
|
|
139
159
|
url,
|
|
@@ -142,6 +162,8 @@ module PatientHttp
|
|
|
142
162
|
json: nil,
|
|
143
163
|
params: nil,
|
|
144
164
|
timeout: nil,
|
|
165
|
+
read_timeout: nil,
|
|
166
|
+
max_response_size: nil,
|
|
145
167
|
max_redirects: nil,
|
|
146
168
|
follow_method_changing_redirects: nil,
|
|
147
169
|
redirect_strip_headers: nil,
|
|
@@ -161,6 +183,8 @@ module PatientHttp
|
|
|
161
183
|
@headers = headers.is_a?(HttpHeaders) ? headers.dup : HttpHeaders.new(headers)
|
|
162
184
|
@body = (body == "") ? nil : body
|
|
163
185
|
@timeout = timeout
|
|
186
|
+
@read_timeout = normalized_read_timeout(read_timeout)
|
|
187
|
+
@max_response_size = normalized_max_response_size(max_response_size)
|
|
164
188
|
@max_redirects = max_redirects
|
|
165
189
|
@follow_method_changing_redirects = normalized_follow_method_changing_redirects(follow_method_changing_redirects)
|
|
166
190
|
@redirect_strip_headers = RedirectHelper.normalize_header_names(redirect_strip_headers)
|
|
@@ -210,6 +234,9 @@ module PatientHttp
|
|
|
210
234
|
"max_redirects" => @max_redirects
|
|
211
235
|
}
|
|
212
236
|
|
|
237
|
+
hash["read_timeout"] = @read_timeout if @read_timeout
|
|
238
|
+
hash["max_response_size"] = @max_response_size if @max_response_size
|
|
239
|
+
|
|
213
240
|
if @secret_params.any?
|
|
214
241
|
hash["secret_params"] = @secret_params.transform_values(&:as_json)
|
|
215
242
|
end
|
|
@@ -243,6 +270,26 @@ module PatientHttp
|
|
|
243
270
|
raise ArgumentError.new("follow_method_changing_redirects must be true, false, or nil, got: #{value.inspect}")
|
|
244
271
|
end
|
|
245
272
|
|
|
273
|
+
# Validate the read timeout is a positive number or nil.
|
|
274
|
+
def normalized_read_timeout(value)
|
|
275
|
+
return nil if value.nil?
|
|
276
|
+
return value if value.is_a?(Numeric) && value > 0
|
|
277
|
+
|
|
278
|
+
raise ArgumentError.new(
|
|
279
|
+
"read_timeout must be a positive number or nil, got: #{value.inspect}"
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Validate the maximum response size is a positive integer or nil.
|
|
284
|
+
def normalized_max_response_size(value)
|
|
285
|
+
return nil if value.nil?
|
|
286
|
+
return value if value.is_a?(Integer) && value > 0
|
|
287
|
+
|
|
288
|
+
raise ArgumentError.new(
|
|
289
|
+
"max_response_size must be a positive integer or nil, got: #{value.inspect}"
|
|
290
|
+
)
|
|
291
|
+
end
|
|
292
|
+
|
|
246
293
|
# Normalize the processor name to a frozen string or nil.
|
|
247
294
|
def normalized_processor(processor)
|
|
248
295
|
return nil if processor.nil?
|
|
@@ -137,16 +137,32 @@ module PatientHttp
|
|
|
137
137
|
# @param params [Hash, nil] The default query parameters.
|
|
138
138
|
# @param timeout [Numeric, nil] The default request timeout in seconds. If
|
|
139
139
|
# `nil`, the configured `request_timeout` applies.
|
|
140
|
+
# @param read_timeout [Numeric, nil] The default maximum number of seconds to
|
|
141
|
+
# wait for data from the server. If `nil`, the configured `read_timeout`
|
|
142
|
+
# applies.
|
|
143
|
+
# @param max_response_size [Integer, nil] The default maximum response body
|
|
144
|
+
# size in bytes. If `nil`, the configured `max_response_size` applies.
|
|
140
145
|
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] The names
|
|
141
146
|
# of the default preprocessors.
|
|
142
147
|
# @param processor [String, Symbol, nil] The name of the default processor.
|
|
143
148
|
# @return [void]
|
|
144
|
-
def request_template(
|
|
149
|
+
def request_template(
|
|
150
|
+
base_url: nil,
|
|
151
|
+
headers: {},
|
|
152
|
+
params: nil,
|
|
153
|
+
timeout: nil,
|
|
154
|
+
read_timeout: nil,
|
|
155
|
+
max_response_size: nil,
|
|
156
|
+
preprocessors: nil,
|
|
157
|
+
processor: nil
|
|
158
|
+
)
|
|
145
159
|
@patient_http_request_template = RequestTemplate.new(
|
|
146
160
|
base_url: base_url,
|
|
147
161
|
headers: headers,
|
|
148
162
|
params: params,
|
|
149
163
|
timeout: timeout,
|
|
164
|
+
read_timeout: read_timeout,
|
|
165
|
+
max_response_size: max_response_size,
|
|
150
166
|
preprocessors: preprocessors,
|
|
151
167
|
processor: processor
|
|
152
168
|
)
|
|
@@ -167,6 +183,12 @@ module PatientHttp
|
|
|
167
183
|
# combined with `body`.
|
|
168
184
|
# @param params [Hash, nil] The query parameters to add to the URL.
|
|
169
185
|
# @param timeout [Numeric, nil] The request timeout in seconds.
|
|
186
|
+
# @param read_timeout [Numeric, nil] The maximum number of seconds to wait
|
|
187
|
+
# for data from the server. The timer covers the wait for the response
|
|
188
|
+
# headers and starts again each time a body chunk arrives. If `nil`, the
|
|
189
|
+
# configuration value applies.
|
|
190
|
+
# @param max_response_size [Integer, nil] The maximum response body size in
|
|
191
|
+
# bytes. If `nil`, the configuration value applies.
|
|
170
192
|
# @param raise_error_responses [Boolean, nil] If `true`, non-2xx responses go
|
|
171
193
|
# to the `on_error` callback as an {HttpError}.
|
|
172
194
|
# @param callback_args [Hash, nil] The JSON-compatible arguments to pass to the
|
|
@@ -194,6 +216,8 @@ module PatientHttp
|
|
|
194
216
|
json: nil,
|
|
195
217
|
params: nil,
|
|
196
218
|
timeout: nil,
|
|
219
|
+
read_timeout: nil,
|
|
220
|
+
max_response_size: nil,
|
|
197
221
|
raise_error_responses: nil,
|
|
198
222
|
callback_args: nil,
|
|
199
223
|
max_redirects: nil,
|
|
@@ -209,6 +233,8 @@ module PatientHttp
|
|
|
209
233
|
headers: headers,
|
|
210
234
|
params: params,
|
|
211
235
|
timeout: timeout,
|
|
236
|
+
read_timeout: read_timeout,
|
|
237
|
+
max_response_size: max_response_size,
|
|
212
238
|
max_redirects: max_redirects,
|
|
213
239
|
follow_method_changing_redirects: follow_method_changing_redirects,
|
|
214
240
|
redirect_strip_headers: redirect_strip_headers,
|
|
@@ -257,6 +283,12 @@ module PatientHttp
|
|
|
257
283
|
# combined with `body`.
|
|
258
284
|
# @param params [Hash, nil] The query parameters to add to the URL.
|
|
259
285
|
# @param timeout [Numeric, nil] The request timeout in seconds.
|
|
286
|
+
# @param read_timeout [Numeric, nil] The maximum number of seconds to wait for
|
|
287
|
+
# data from the server. The timer covers the wait for the response headers
|
|
288
|
+
# and starts again each time a body chunk arrives. If `nil`, the
|
|
289
|
+
# configuration value applies.
|
|
290
|
+
# @param max_response_size [Integer, nil] The maximum response body size in
|
|
291
|
+
# bytes. If `nil`, the configuration value applies.
|
|
260
292
|
# @param raise_error_responses [Boolean, nil] If `true`, non-2xx responses go
|
|
261
293
|
# to the `on_error` callback as an {HttpError}.
|
|
262
294
|
# @param callback_args [Hash, nil] The JSON-compatible arguments to pass to the
|
|
@@ -284,6 +316,8 @@ module PatientHttp
|
|
|
284
316
|
json: nil,
|
|
285
317
|
params: nil,
|
|
286
318
|
timeout: nil,
|
|
319
|
+
read_timeout: nil,
|
|
320
|
+
max_response_size: nil,
|
|
287
321
|
raise_error_responses: nil,
|
|
288
322
|
callback_args: nil,
|
|
289
323
|
max_redirects: nil,
|
|
@@ -301,6 +335,8 @@ module PatientHttp
|
|
|
301
335
|
json: json,
|
|
302
336
|
params: params,
|
|
303
337
|
timeout: timeout,
|
|
338
|
+
read_timeout: read_timeout,
|
|
339
|
+
max_response_size: max_response_size,
|
|
304
340
|
raise_error_responses: raise_error_responses,
|
|
305
341
|
callback_args: callback_args,
|
|
306
342
|
max_redirects: max_redirects,
|
|
@@ -274,6 +274,8 @@ module PatientHttp
|
|
|
274
274
|
headers: redirect_headers,
|
|
275
275
|
body: redirect_body,
|
|
276
276
|
timeout: request.timeout,
|
|
277
|
+
read_timeout: request.read_timeout,
|
|
278
|
+
max_response_size: request.max_response_size,
|
|
277
279
|
max_redirects: request.max_redirects,
|
|
278
280
|
follow_method_changing_redirects: request.follow_method_changing_redirects,
|
|
279
281
|
redirect_strip_headers: request.redirect_strip_headers,
|
|
@@ -27,6 +27,14 @@ module PatientHttp
|
|
|
27
27
|
# the configured `request_timeout` applies.
|
|
28
28
|
attr_accessor :timeout
|
|
29
29
|
|
|
30
|
+
# @return [Numeric, nil] The default read timeout in seconds. If `nil`, the
|
|
31
|
+
# configured `read_timeout` applies.
|
|
32
|
+
attr_accessor :read_timeout
|
|
33
|
+
|
|
34
|
+
# @return [Integer, nil] The default maximum response body size in bytes. If
|
|
35
|
+
# `nil`, the configured `max_response_size` applies.
|
|
36
|
+
attr_accessor :max_response_size
|
|
37
|
+
|
|
30
38
|
# Creates a template.
|
|
31
39
|
#
|
|
32
40
|
# @param base_url [String, URI::HTTP, nil] The base URL that relative paths
|
|
@@ -35,15 +43,31 @@ module PatientHttp
|
|
|
35
43
|
# @param params [Hash, nil] The default query parameters for all requests.
|
|
36
44
|
# @param timeout [Numeric, nil] The default request timeout in seconds. If
|
|
37
45
|
# `nil`, the configured `request_timeout` applies.
|
|
46
|
+
# @param read_timeout [Numeric, nil] The default maximum number of seconds to
|
|
47
|
+
# wait for data from the server. If `nil`, the configured `read_timeout`
|
|
48
|
+
# applies.
|
|
49
|
+
# @param max_response_size [Integer, nil] The default maximum response body
|
|
50
|
+
# size in bytes. If `nil`, the configured `max_response_size` applies.
|
|
38
51
|
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] The names
|
|
39
52
|
# of the default preprocessors for all requests.
|
|
40
53
|
# @param processor [String, Symbol, nil] The name of the default processor for
|
|
41
54
|
# all requests.
|
|
42
|
-
def initialize(
|
|
55
|
+
def initialize(
|
|
56
|
+
base_url: nil,
|
|
57
|
+
headers: {},
|
|
58
|
+
params: nil,
|
|
59
|
+
timeout: nil,
|
|
60
|
+
read_timeout: nil,
|
|
61
|
+
max_response_size: nil,
|
|
62
|
+
preprocessors: nil,
|
|
63
|
+
processor: nil
|
|
64
|
+
)
|
|
43
65
|
@base_url = base_url
|
|
44
66
|
@headers = HttpHeaders.new(headers)
|
|
45
67
|
@params = params
|
|
46
68
|
@timeout = timeout
|
|
69
|
+
@read_timeout = read_timeout
|
|
70
|
+
@max_response_size = max_response_size
|
|
47
71
|
@preprocessors = preprocessors
|
|
48
72
|
@processor = processor
|
|
49
73
|
end
|
|
@@ -62,6 +86,10 @@ module PatientHttp
|
|
|
62
86
|
# parameters.
|
|
63
87
|
# @param timeout [Numeric, nil] The request timeout in seconds. Overrides the
|
|
64
88
|
# template timeout.
|
|
89
|
+
# @param read_timeout [Numeric, nil] The maximum number of seconds to wait for
|
|
90
|
+
# data from the server. Overrides the template read timeout.
|
|
91
|
+
# @param max_response_size [Integer, nil] The maximum response body size in
|
|
92
|
+
# bytes. Overrides the template value.
|
|
65
93
|
# @param max_redirects [Integer, nil] The maximum number of redirects to
|
|
66
94
|
# follow. If `0`, redirects aren't followed. If `nil`, the configuration
|
|
67
95
|
# value applies.
|
|
@@ -84,6 +112,8 @@ module PatientHttp
|
|
|
84
112
|
headers: nil,
|
|
85
113
|
params: nil,
|
|
86
114
|
timeout: nil,
|
|
115
|
+
read_timeout: nil,
|
|
116
|
+
max_response_size: nil,
|
|
87
117
|
max_redirects: nil,
|
|
88
118
|
follow_method_changing_redirects: nil,
|
|
89
119
|
redirect_strip_headers: nil,
|
|
@@ -104,6 +134,8 @@ module PatientHttp
|
|
|
104
134
|
json: json,
|
|
105
135
|
params: merged_params,
|
|
106
136
|
timeout: timeout || @timeout,
|
|
137
|
+
read_timeout: read_timeout || @read_timeout,
|
|
138
|
+
max_response_size: max_response_size || @max_response_size,
|
|
107
139
|
max_redirects: max_redirects,
|
|
108
140
|
follow_method_changing_redirects: follow_method_changing_redirects,
|
|
109
141
|
redirect_strip_headers: redirect_strip_headers,
|
|
@@ -89,6 +89,28 @@ module PatientHttp
|
|
|
89
89
|
headers_hash.merge("content-encoding" => remaining.join(", "))
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
|
+
|
|
93
|
+
# Runs a block that waits for data from the server, and stops it if no
|
|
94
|
+
# data arrives in time. Each call starts a new timer, so call it once for
|
|
95
|
+
# each wait: once for the response headers and once for each body chunk.
|
|
96
|
+
#
|
|
97
|
+
# The block must run in an Async task.
|
|
98
|
+
#
|
|
99
|
+
# @param read_timeout [Numeric, nil] The maximum number of seconds to wait.
|
|
100
|
+
# If `nil`, the block runs without a limit.
|
|
101
|
+
# @yield Waits for data from the server.
|
|
102
|
+
# @return [Object] The value of the block.
|
|
103
|
+
# @raise [ReadTimeoutError] If the block doesn't finish in time.
|
|
104
|
+
def with_read_timeout(read_timeout, &block)
|
|
105
|
+
return yield if read_timeout.nil?
|
|
106
|
+
|
|
107
|
+
Async::Task.current.with_timeout(
|
|
108
|
+
read_timeout,
|
|
109
|
+
ReadTimeoutError,
|
|
110
|
+
"No data received from the server for #{read_timeout} seconds",
|
|
111
|
+
&block
|
|
112
|
+
)
|
|
113
|
+
end
|
|
92
114
|
end
|
|
93
115
|
|
|
94
116
|
# Creates the reader.
|
|
@@ -115,21 +137,44 @@ module PatientHttp
|
|
|
115
137
|
# bytes and {#decode_body} applies the same limit to the inflated bytes.
|
|
116
138
|
#
|
|
117
139
|
# The Content-Length header is checked against the size limit before the
|
|
118
|
-
# read starts.
|
|
119
|
-
#
|
|
120
|
-
#
|
|
140
|
+
# read starts. The HTTP/1 parser moves that header into the body, so the
|
|
141
|
+
# length of the body is used when the header is not present. A response to
|
|
142
|
+
# a HEAD request has an empty body but reports the Content-Length of the
|
|
143
|
+
# resource, so the check is skipped when the body reports itself as empty.
|
|
144
|
+
#
|
|
145
|
+
# A read timeout limits the wait for each chunk. The timer starts again
|
|
146
|
+
# each time a chunk arrives, so a body that arrives slowly but steadily is
|
|
147
|
+
# read to the end.
|
|
121
148
|
#
|
|
122
149
|
# @param async_response [Async::HTTP::Protocol::Response] The async HTTP response.
|
|
123
150
|
# @param headers_hash [Hash] The response headers.
|
|
151
|
+
# @param max_response_size [Integer, nil] The size limit in bytes. If `nil`,
|
|
152
|
+
# the configuration value applies.
|
|
153
|
+
# @param read_timeout [Numeric, nil] The maximum number of seconds to wait for
|
|
154
|
+
# each chunk. If `nil`, there is no limit.
|
|
124
155
|
# @return [Array<String>, nil] The raw body chunks or nil if no body present.
|
|
125
156
|
# @raise [ResponseTooLargeError] If the body exceeds max_response_size.
|
|
157
|
+
# @raise [ReadTimeoutError] If no chunk arrives within the read timeout.
|
|
126
158
|
# @raise [ReadAbortedError] If the processor stopped past its shutdown deadline mid-read.
|
|
127
|
-
def read_raw_body(async_response, headers_hash)
|
|
159
|
+
def read_raw_body(async_response, headers_hash, max_response_size: nil, read_timeout: nil)
|
|
128
160
|
body = async_response.body
|
|
129
161
|
return nil unless body
|
|
130
162
|
|
|
131
|
-
|
|
132
|
-
|
|
163
|
+
limit = max_response_size || @config.max_response_size
|
|
164
|
+
error = nil
|
|
165
|
+
|
|
166
|
+
begin
|
|
167
|
+
validate_content_length(headers_hash, body, limit) unless body.empty?
|
|
168
|
+
read_body_chunks(body, limit, read_timeout)
|
|
169
|
+
rescue => e
|
|
170
|
+
error = e
|
|
171
|
+
raise
|
|
172
|
+
ensure
|
|
173
|
+
# Always close the body. A body that is closed with an error discards
|
|
174
|
+
# its connection, so a connection that is not fully read is not
|
|
175
|
+
# returned to the pool.
|
|
176
|
+
body.close(error)
|
|
177
|
+
end
|
|
133
178
|
end
|
|
134
179
|
|
|
135
180
|
# Decodes raw body chunks into the final body string.
|
|
@@ -147,15 +192,18 @@ module PatientHttp
|
|
|
147
192
|
#
|
|
148
193
|
# @param chunks [Array<String>, nil] The raw body chunks.
|
|
149
194
|
# @param headers_hash [Hash] The response headers.
|
|
195
|
+
# @param max_response_size [Integer, nil] The size limit in bytes for the
|
|
196
|
+
# inflated body. If `nil`, the configuration value applies.
|
|
150
197
|
# @return [String, nil] The decoded body or nil if there was no body.
|
|
151
198
|
# @raise [ResponseTooLargeError] If the inflated body exceeds max_response_size.
|
|
152
|
-
def decode_body(chunks, headers_hash)
|
|
199
|
+
def decode_body(chunks, headers_hash, max_response_size: nil)
|
|
153
200
|
return nil if chunks.nil?
|
|
154
201
|
|
|
155
202
|
remaining, decodable = self.class.split_encodings(headers_hash)
|
|
156
203
|
warn_undecodable(remaining) unless remaining.empty?
|
|
157
204
|
|
|
158
|
-
|
|
205
|
+
limit = max_response_size || @config.max_response_size
|
|
206
|
+
body = inflate_encodings(chunks, decodable, limit).join
|
|
159
207
|
body.force_encoding(Encoding::ASCII_8BIT)
|
|
160
208
|
# A body that is still encoded is not text yet, so the charset does not
|
|
161
209
|
# describe its bytes. Leave it binary for the caller to decode.
|
|
@@ -171,11 +219,12 @@ module PatientHttp
|
|
|
171
219
|
#
|
|
172
220
|
# @param chunks [Array<String>] The encoded body chunks.
|
|
173
221
|
# @param encodings [Array<String>] Decodable encoding names in applied order.
|
|
222
|
+
# @param limit [Integer] The size limit in bytes for the inflated body.
|
|
174
223
|
# @return [Array<String>] The decoded chunks.
|
|
175
|
-
# @raise [ResponseTooLargeError] If the inflated body exceeds
|
|
176
|
-
def inflate_encodings(chunks, encodings)
|
|
224
|
+
# @raise [ResponseTooLargeError] If the inflated body exceeds the limit.
|
|
225
|
+
def inflate_encodings(chunks, encodings, limit)
|
|
177
226
|
encodings.reverse_each do |name|
|
|
178
|
-
chunks = [inflate_encoding(chunks, name)] if INFLATE_WINDOW_BITS.key?(name)
|
|
227
|
+
chunks = [inflate_encoding(chunks, name, limit)] if INFLATE_WINDOW_BITS.key?(name)
|
|
179
228
|
end
|
|
180
229
|
|
|
181
230
|
chunks
|
|
@@ -187,15 +236,16 @@ module PatientHttp
|
|
|
187
236
|
#
|
|
188
237
|
# @param chunks [Array<String>] The encoded body chunks.
|
|
189
238
|
# @param name [String] A lowercased content encoding name.
|
|
239
|
+
# @param limit [Integer] The size limit in bytes for the inflated body.
|
|
190
240
|
# @return [String] The inflated body.
|
|
191
241
|
# @raise [Zlib::Error] If no format could inflate the body.
|
|
192
|
-
# @raise [ResponseTooLargeError] If the inflated body exceeds
|
|
193
|
-
def inflate_encoding(chunks, name)
|
|
242
|
+
# @raise [ResponseTooLargeError] If the inflated body exceeds the limit.
|
|
243
|
+
def inflate_encoding(chunks, name, limit)
|
|
194
244
|
formats = INFLATE_WINDOW_BITS.fetch(name)
|
|
195
245
|
last_index = formats.size - 1
|
|
196
246
|
|
|
197
247
|
formats.each_with_index do |window_bits, index|
|
|
198
|
-
return inflate_chunks(chunks, window_bits)
|
|
248
|
+
return inflate_chunks(chunks, window_bits, limit)
|
|
199
249
|
rescue Zlib::DataError, Zlib::BufError
|
|
200
250
|
raise if index == last_index
|
|
201
251
|
end
|
|
@@ -215,77 +265,74 @@ module PatientHttp
|
|
|
215
265
|
nil
|
|
216
266
|
end
|
|
217
267
|
|
|
218
|
-
def max_response_size
|
|
219
|
-
@config.max_response_size
|
|
220
|
-
end
|
|
221
|
-
|
|
222
268
|
def logger
|
|
223
269
|
@config.logger
|
|
224
270
|
end
|
|
225
271
|
|
|
226
|
-
# Validates
|
|
272
|
+
# Validates the declared body length doesn't exceed max size.
|
|
227
273
|
#
|
|
228
274
|
# @param headers_hash [Hash] The response headers.
|
|
229
|
-
# @
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
275
|
+
# @param body [Protocol::HTTP::Body::Readable] The response body.
|
|
276
|
+
# @param limit [Integer] The size limit in bytes.
|
|
277
|
+
# @raise [ResponseTooLargeError] If the declared length exceeds the limit.
|
|
278
|
+
def validate_content_length(headers_hash, body, limit)
|
|
279
|
+
content_length = headers_hash["content-length"]&.to_i || body.length
|
|
280
|
+
if content_length && content_length > limit
|
|
233
281
|
raise ResponseTooLargeError.new(
|
|
234
|
-
"Response body size (#{content_length} bytes) exceeds maximum allowed size (#{
|
|
282
|
+
"Response body size (#{content_length} bytes) exceeds maximum allowed size (#{limit} bytes)"
|
|
235
283
|
)
|
|
236
284
|
end
|
|
237
285
|
end
|
|
238
286
|
|
|
239
287
|
# Reads body chunks while checking size.
|
|
240
288
|
#
|
|
241
|
-
#
|
|
289
|
+
# Each chunk is read with its own read timeout, so the timer starts again
|
|
290
|
+
# after every chunk.
|
|
291
|
+
#
|
|
292
|
+
# @param body [Protocol::HTTP::Body::Readable] The response body.
|
|
293
|
+
# @param limit [Integer] The size limit in bytes.
|
|
294
|
+
# @param read_timeout [Numeric, nil] The maximum number of seconds to wait for
|
|
295
|
+
# each chunk.
|
|
242
296
|
# @return [Array<String>] The raw body chunks.
|
|
243
|
-
# @raise [ResponseTooLargeError] If body size exceeds
|
|
297
|
+
# @raise [ResponseTooLargeError] If body size exceeds the limit during read.
|
|
298
|
+
# @raise [ReadTimeoutError] If no chunk arrives within the read timeout.
|
|
244
299
|
# @raise [ReadAbortedError] If the processor stopped past its shutdown deadline mid-read.
|
|
245
|
-
def read_body_chunks(
|
|
300
|
+
def read_body_chunks(body, limit, read_timeout)
|
|
246
301
|
chunks = []
|
|
247
302
|
total_size = 0
|
|
248
|
-
finished = false
|
|
249
303
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
raise ReadAbortedError.new("Processor stopped while reading response body")
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
total_size += chunk.bytesize
|
|
261
|
-
|
|
262
|
-
if total_size > max_response_size
|
|
263
|
-
raise ResponseTooLargeError.new(
|
|
264
|
-
"Response body size exceeded maximum allowed size (#{max_response_size} bytes)"
|
|
265
|
-
)
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
chunks << chunk
|
|
304
|
+
while (chunk = self.class.with_read_timeout(read_timeout) { body.read })
|
|
305
|
+
# Abort the read once the processor has passed its shutdown deadline.
|
|
306
|
+
# Reads are allowed to finish while the processor is merely stopping
|
|
307
|
+
# (the graceful shutdown window) so in-flight responses can still be
|
|
308
|
+
# delivered.
|
|
309
|
+
if @processor&.stopped?
|
|
310
|
+
raise ReadAbortedError.new("Processor stopped while reading response body")
|
|
269
311
|
end
|
|
270
312
|
|
|
271
|
-
|
|
313
|
+
total_size += chunk.bytesize
|
|
272
314
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
315
|
+
if total_size > limit
|
|
316
|
+
raise ResponseTooLargeError.new(
|
|
317
|
+
"Response body size exceeded maximum allowed size (#{limit} bytes)"
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
chunks << chunk
|
|
278
322
|
end
|
|
323
|
+
|
|
324
|
+
chunks
|
|
279
325
|
end
|
|
280
326
|
|
|
281
327
|
# Inflates compressed body chunks with streaming size enforcement, so a
|
|
282
|
-
# small compressed body cannot expand past
|
|
328
|
+
# small compressed body cannot expand past the size limit.
|
|
283
329
|
#
|
|
284
330
|
# @param chunks [Array<String>] The raw compressed chunks.
|
|
285
331
|
# @param window_bits [Integer] Zlib window bits for the content encoding.
|
|
332
|
+
# @param limit [Integer] The size limit in bytes for the inflated body.
|
|
286
333
|
# @return [String] The inflated body.
|
|
287
|
-
# @raise [ResponseTooLargeError] If the inflated size exceeds
|
|
288
|
-
def inflate_chunks(chunks, window_bits)
|
|
334
|
+
# @raise [ResponseTooLargeError] If the inflated size exceeds the limit.
|
|
335
|
+
def inflate_chunks(chunks, window_bits, limit)
|
|
289
336
|
# A response can declare a content encoding and still carry no body.
|
|
290
337
|
# There is nothing to inflate, and finishing an empty stream would
|
|
291
338
|
# raise a buffer error.
|
|
@@ -301,7 +348,7 @@ module PatientHttp
|
|
|
301
348
|
# before any check runs.
|
|
302
349
|
appender = ->(output) do
|
|
303
350
|
body << output
|
|
304
|
-
validate_inflated_size(body)
|
|
351
|
+
validate_inflated_size(body, limit)
|
|
305
352
|
end
|
|
306
353
|
|
|
307
354
|
chunks.each { |chunk| inflater.inflate(chunk, &appender) }
|
|
@@ -313,10 +360,10 @@ module PatientHttp
|
|
|
313
360
|
body
|
|
314
361
|
end
|
|
315
362
|
|
|
316
|
-
def validate_inflated_size(body)
|
|
317
|
-
if body.bytesize >
|
|
363
|
+
def validate_inflated_size(body, limit)
|
|
364
|
+
if body.bytesize > limit
|
|
318
365
|
raise ResponseTooLargeError.new(
|
|
319
|
-
"Response body size exceeded maximum allowed size (#{
|
|
366
|
+
"Response body size exceeded maximum allowed size (#{limit} bytes)"
|
|
320
367
|
)
|
|
321
368
|
end
|
|
322
369
|
end
|
|
@@ -121,26 +121,43 @@ module PatientHttp
|
|
|
121
121
|
# wrapper isn't used, because it replaces an `accept-encoding` header that
|
|
122
122
|
# the caller set to turn off compression.
|
|
123
123
|
#
|
|
124
|
+
# The request timeout limits the full exchange. The read timeout, if set,
|
|
125
|
+
# limits the wait for the response headers and the wait for each body
|
|
126
|
+
# chunk.
|
|
127
|
+
#
|
|
124
128
|
# @return [Hash] The response data, with the `:status`, `:headers`, and
|
|
125
129
|
# `:body` keys.
|
|
126
130
|
def perform_request
|
|
127
131
|
outgoing = @request_preparer.prepare(@task.request, @task.id)
|
|
128
132
|
timeout = @task.request.timeout || @config.request_timeout
|
|
133
|
+
read_timeout = @task.request.read_timeout || @config.read_timeout
|
|
134
|
+
max_response_size = @task.request.max_response_size || @config.max_response_size
|
|
129
135
|
|
|
130
136
|
Async::Task.current.with_timeout(timeout) do
|
|
131
137
|
headers = outgoing.headers.to_h
|
|
132
138
|
body = Protocol::HTTP::Body::Buffered.wrap([@task.request.body.to_s]) if @task.request.body
|
|
133
139
|
|
|
134
140
|
endpoint = Async::HTTP::Endpoint.parse(outgoing.url)
|
|
135
|
-
async_response =
|
|
136
|
-
|
|
137
|
-
|
|
141
|
+
async_response = ResponseReader.with_read_timeout(read_timeout) do
|
|
142
|
+
request_with_immediate_retries(
|
|
143
|
+
@client_pool, @task.request, endpoint, headers, body
|
|
144
|
+
)
|
|
145
|
+
end
|
|
138
146
|
# Note: headers that appear multiple times (e.g. set-cookie) are
|
|
139
147
|
# flattened to a single joined string value.
|
|
140
148
|
headers_hash = async_response.headers.to_h.transform_values(&:to_s)
|
|
141
149
|
|
|
142
|
-
chunks = @response_reader.read_raw_body(
|
|
143
|
-
|
|
150
|
+
chunks = @response_reader.read_raw_body(
|
|
151
|
+
async_response,
|
|
152
|
+
headers_hash,
|
|
153
|
+
max_response_size: max_response_size,
|
|
154
|
+
read_timeout: read_timeout
|
|
155
|
+
)
|
|
156
|
+
body_content = @response_reader.decode_body(
|
|
157
|
+
chunks,
|
|
158
|
+
headers_hash,
|
|
159
|
+
max_response_size: max_response_size
|
|
160
|
+
)
|
|
144
161
|
|
|
145
162
|
{
|
|
146
163
|
status: async_response.status,
|
data/lib/patient_http.rb
CHANGED
|
@@ -37,6 +37,12 @@ module PatientHttp
|
|
|
37
37
|
# Raised when a response body is larger than `max_response_size`.
|
|
38
38
|
class ResponseTooLargeError < StandardError; end
|
|
39
39
|
|
|
40
|
+
# Raised when no data arrives from the server for `read_timeout` seconds. It
|
|
41
|
+
# is a subclass of `Async::TimeoutError`, so the error type of the
|
|
42
|
+
# {RequestError} is `:timeout`. Use the class name to tell it apart from the
|
|
43
|
+
# timeout for the full request.
|
|
44
|
+
class ReadTimeoutError < Async::TimeoutError; end
|
|
45
|
+
|
|
40
46
|
# Raised when a request names a processor that isn't configured. The job
|
|
41
47
|
# system then retries the job instead of dropping it.
|
|
42
48
|
class UnknownProcessorError < StandardError; end
|
|
@@ -369,6 +375,12 @@ module PatientHttp
|
|
|
369
375
|
# combined with `body`.
|
|
370
376
|
# @param params [Hash, nil] The query parameters to add to the URL.
|
|
371
377
|
# @param timeout [Numeric, nil] The request timeout in seconds.
|
|
378
|
+
# @param read_timeout [Numeric, nil] The maximum number of seconds to wait for
|
|
379
|
+
# data from the server. The timer covers the wait for the response headers
|
|
380
|
+
# and starts again each time a body chunk arrives. If `nil`, the
|
|
381
|
+
# configuration value applies.
|
|
382
|
+
# @param max_response_size [Integer, nil] The maximum response body size in
|
|
383
|
+
# bytes. If `nil`, the configuration value applies.
|
|
372
384
|
# @param raise_error_responses [Boolean, nil] If `true`, non-2xx responses go
|
|
373
385
|
# to the `on_error` callback as an {HttpError}. If `nil`, the configuration
|
|
374
386
|
# value applies.
|
|
@@ -397,6 +409,8 @@ module PatientHttp
|
|
|
397
409
|
json: nil,
|
|
398
410
|
params: nil,
|
|
399
411
|
timeout: nil,
|
|
412
|
+
read_timeout: nil,
|
|
413
|
+
max_response_size: nil,
|
|
400
414
|
raise_error_responses: nil,
|
|
401
415
|
callback_args: nil,
|
|
402
416
|
max_redirects: nil,
|
|
@@ -413,6 +427,8 @@ module PatientHttp
|
|
|
413
427
|
headers: headers,
|
|
414
428
|
params: params,
|
|
415
429
|
timeout: timeout,
|
|
430
|
+
read_timeout: read_timeout,
|
|
431
|
+
max_response_size: max_response_size,
|
|
416
432
|
max_redirects: max_redirects,
|
|
417
433
|
follow_method_changing_redirects: follow_method_changing_redirects,
|
|
418
434
|
redirect_strip_headers: redirect_strip_headers,
|