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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c173592c54e9ac9730cbfeca9b81d61a5af9df06839b8abdc2bb2dcb54b228f
4
- data.tar.gz: 171d9176271d166855129c6c13bb0e234345f3368dbae835a4221e8b28a9deb8
3
+ metadata.gz: c4e10ada7964f607e81e169e4f88086d3dd32967fd6bf6ed48025615a133d5fd
4
+ data.tar.gz: 704ca986fb7622f6e977d8d02d76e198c98abdd1c5ea808d85c8af25bdd3f72f
5
5
  SHA512:
6
- metadata.gz: '0811e744dadbceb89141f2603de7113fec36ab389967fd941fa9202bba3d6c4924e169b594d17b6f03dba0437bb04b5ca5f895515174d2717a316027428a4f85'
7
- data.tar.gz: 3719152b73fd5994766a26dba869f4f278c2b8362fb79a30b543d123abfcfd996db0a5a2e57daf136f2812674c0644e1ad1671c50c2553a615724d7239312048
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.7.0
1
+ 1.8.0
@@ -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, and :body.
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 = request_with_immediate_retries(
43
- @client_pool, request, endpoint, headers, body
44
- ) { |pooled_client| client = pooled_client }
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(async_response, headers_hash)
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 decoded body string.
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(response_data[:body], headers)
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,
@@ -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, or body isn't valid, or if
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(base_url: nil, headers: {}, params: nil, timeout: nil, preprocessors: nil, processor: nil)
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(base_url: nil, headers: {}, params: nil, timeout: nil, preprocessors: nil, processor: nil)
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. A response to a HEAD request has an empty body but reports
119
- # the Content-Length of the resource, so the header check is skipped when
120
- # the body reports itself as empty.
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
- validate_content_length(headers_hash) unless body.empty?
132
- read_body_chunks(async_response)
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
- body = inflate_encodings(chunks, decodable).join
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 max_response_size.
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 max_response_size.
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 content-length header doesn't exceed max size.
272
+ # Validates the declared body length doesn't exceed max size.
227
273
  #
228
274
  # @param headers_hash [Hash] The response headers.
229
- # @raise [ResponseTooLargeError] If content-length exceeds max_response_size.
230
- def validate_content_length(headers_hash)
231
- content_length = headers_hash["content-length"]&.to_i
232
- if content_length && content_length > max_response_size
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 (#{max_response_size} bytes)"
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
- # @param async_response [Async::HTTP::Protocol::Response] The async HTTP response.
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 max_response_size during read.
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(async_response)
300
+ def read_body_chunks(body, limit, read_timeout)
246
301
  chunks = []
247
302
  total_size = 0
248
- finished = false
249
303
 
250
- begin
251
- async_response.body.each do |chunk|
252
- # Abort the read once the processor has passed its shutdown deadline.
253
- # Reads are allowed to finish while the processor is merely stopping
254
- # (the graceful shutdown window) so in-flight responses can still be
255
- # delivered.
256
- if @processor&.stopped?
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
- finished = true
313
+ total_size += chunk.bytesize
272
314
 
273
- chunks
274
- ensure
275
- # Always close the body if we were interrupted or if an error occurred
276
- # This ensures the connection is properly released back to the pool
277
- async_response.body.close unless finished
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 max_response_size.
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 max_response_size.
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 > max_response_size
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 (#{max_response_size} bytes)"
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 = request_with_immediate_retries(
136
- @client_pool, @task.request, endpoint, headers, body
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(async_response, headers_hash)
143
- body_content = @response_reader.decode_body(chunks, headers_hash)
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,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_http
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand