philiprehberger-http_client 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -1
- data/README.md +42 -8
- data/lib/philiprehberger/http_client/cache.rb +72 -5
- data/lib/philiprehberger/http_client/client.rb +22 -1
- data/lib/philiprehberger/http_client/connection.rb +19 -1
- data/lib/philiprehberger/http_client/retries.rb +63 -4
- data/lib/philiprehberger/http_client/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 211aea712ab77e8a14f5a565d0a235113b1b5a5630e38e13ddcfb2f92c61b141
|
|
4
|
+
data.tar.gz: d9a7e6996c9ca7cc6ba296c837db466cdc692abb489bb179c24aea3e888e274b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9cf88a13252d7b73c94b747a0ce54cc5027dbdb28aceaefeca3cf5c4d5c2818b09d8b7b9c92e7cbda000a8343fda30aff88f9110a0d96eccb0b9cada3e84d67e
|
|
7
|
+
data.tar.gz: 716b066adc90993966528dc3100dcb1b6d4e9130926673ee1279d2931e2f0089a2f0999e22fcfade122e9afd1a641e2e7a6c566594845efd8864ac9b0683bfdf
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.0] - 2026-07-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Retry-After` response header support on retryable status codes — honors both delta-seconds and HTTP-date forms as the retry delay, clamped to a 300s ceiling (falls back to the configured backoff when absent)
|
|
14
|
+
- `retry_jitter:` option (default `false`) — spreads retries with full jitter over the computed delay
|
|
15
|
+
- `retry_max_delay:` option (default `30.0`) — caps any single backoff delay so exponential growth cannot run away
|
|
16
|
+
- `cache_max_entries:` option (default `1000`) and `Cache#refresh` — the response cache is now bounded with least-recently-used eviction, preventing unbounded growth in long-lived clients
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- 304 Not Modified revalidation now returns the stored response (with body) and refreshes its freshness/ETag/Last-Modified, instead of returning the empty-body 304 and dropping the cached content
|
|
20
|
+
|
|
21
|
+
## [0.11.1] - 2026-06-14
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Added package card image to README
|
|
25
|
+
- Cleaned up Installation section formatting
|
|
26
|
+
|
|
10
27
|
## [0.11.0] - 2026-05-13
|
|
11
28
|
|
|
12
29
|
### Added
|
|
@@ -194,7 +211,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
194
211
|
- Response wrapper with `ok?` and `json` convenience methods
|
|
195
212
|
- Zero dependencies — built on Ruby stdlib `net/http`
|
|
196
213
|
|
|
197
|
-
[Unreleased]: https://github.com/philiprehberger/rb-http-client/compare/v0.
|
|
214
|
+
[Unreleased]: https://github.com/philiprehberger/rb-http-client/compare/v0.12.0...HEAD
|
|
215
|
+
[0.12.0]: https://github.com/philiprehberger/rb-http-client/compare/v0.11.1...v0.12.0
|
|
216
|
+
[0.11.1]: https://github.com/philiprehberger/rb-http-client/compare/v0.11.0...v0.11.1
|
|
198
217
|
[0.11.0]: https://github.com/philiprehberger/rb-http-client/compare/v0.10.0...v0.11.0
|
|
199
218
|
[0.10.0]: https://github.com/philiprehberger/rb-http-client/compare/v0.9.1...v0.10.0
|
|
200
219
|
[0.9.1]: https://github.com/philiprehberger/rb-http-client/compare/v0.9.0...v0.9.1
|
data/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
[](https://rubygems.org/gems/philiprehberger-http_client)
|
|
5
5
|
[](https://github.com/philiprehberger/rb-http-client/commits/main)
|
|
6
6
|
|
|
7
|
+

|
|
8
|
+
|
|
7
9
|
Lightweight HTTP client wrapper with retries and interceptors
|
|
8
10
|
|
|
9
11
|
## Requirements
|
|
@@ -18,12 +20,6 @@ Add to your Gemfile:
|
|
|
18
20
|
gem "philiprehberger-http_client"
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
Then run:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
bundle install
|
|
25
|
-
```
|
|
26
|
-
|
|
27
23
|
Or install directly:
|
|
28
24
|
|
|
29
25
|
```bash
|
|
@@ -206,6 +202,12 @@ client = Philiprehberger::HttpClient.new(
|
|
|
206
202
|
)
|
|
207
203
|
```
|
|
208
204
|
|
|
205
|
+
When retrying on a status code, a `Retry-After` response header is honored as the
|
|
206
|
+
delay before the next attempt. Both delta-seconds (`Retry-After: 120`) and HTTP-date
|
|
207
|
+
(`Retry-After: Wed, 21 Oct 2026 07:28:00 GMT`) forms are supported. The value is
|
|
208
|
+
clamped to a sane ceiling (300s) to guard against hostile headers, and the configured
|
|
209
|
+
backoff is used whenever the header is absent.
|
|
210
|
+
|
|
209
211
|
### Exponential backoff
|
|
210
212
|
|
|
211
213
|
```ruby
|
|
@@ -218,6 +220,22 @@ client = Philiprehberger::HttpClient.new(
|
|
|
218
220
|
# Delay sequence: 1s, 2s, 4s
|
|
219
221
|
```
|
|
220
222
|
|
|
223
|
+
Computed delays are capped at `retry_max_delay` (default 30s) so exponential growth
|
|
224
|
+
never runs away. Enable `retry_jitter` to spread retries with full jitter — each delay
|
|
225
|
+
becomes a random value between `0` and the capped delay, which helps avoid thundering-herd
|
|
226
|
+
retries against a recovering server:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
client = Philiprehberger::HttpClient.new(
|
|
230
|
+
base_url: "https://api.example.com",
|
|
231
|
+
retries: 5,
|
|
232
|
+
retry_delay: 1,
|
|
233
|
+
retry_backoff: :exponential,
|
|
234
|
+
retry_jitter: true, # randomize each delay (default: false)
|
|
235
|
+
retry_max_delay: 20 # cap any single delay at 20s (default: 30.0)
|
|
236
|
+
)
|
|
237
|
+
```
|
|
238
|
+
|
|
221
239
|
### Cookie jar
|
|
222
240
|
|
|
223
241
|
Enable automatic cookie handling across requests:
|
|
@@ -376,12 +394,25 @@ response = client.get("/data")
|
|
|
376
394
|
response = client.get("/data")
|
|
377
395
|
|
|
378
396
|
# Conditional requests: sends If-None-Match / If-Modified-Since
|
|
379
|
-
# when cache entry has ETag or Last-Modified
|
|
397
|
+
# when cache entry has ETag or Last-Modified. A 304 Not Modified
|
|
398
|
+
# refreshes the stored entry and returns its body (not the empty 304).
|
|
380
399
|
|
|
381
400
|
# Flush the cache
|
|
382
401
|
client.clear_cache!
|
|
383
402
|
```
|
|
384
403
|
|
|
404
|
+
The cache is bounded — once it holds `cache_max_entries` responses (default 1000),
|
|
405
|
+
the least recently used entry is evicted on the next insert, so a long-lived client
|
|
406
|
+
cannot grow the cache without limit:
|
|
407
|
+
|
|
408
|
+
```ruby
|
|
409
|
+
client = Philiprehberger::HttpClient.new(
|
|
410
|
+
base_url: "https://api.example.com",
|
|
411
|
+
cache: true,
|
|
412
|
+
cache_max_entries: 500 # LRU eviction beyond 500 entries (default: 1000)
|
|
413
|
+
)
|
|
414
|
+
```
|
|
415
|
+
|
|
385
416
|
### Request logging callback
|
|
386
417
|
|
|
387
418
|
Add an `on_request` callback for observability — it receives the HTTP method, URI, response status, and duration (in seconds) after each request completes:
|
|
@@ -451,7 +482,9 @@ response.header("X-Missing") # => nil
|
|
|
451
482
|
| `retries` | Integer | `0` | Retry attempts on network errors |
|
|
452
483
|
| `retry_delay` | Numeric | `1` | Seconds between retries |
|
|
453
484
|
| `retry_backoff` | Symbol | `:fixed` | Backoff strategy — `:fixed` or `:exponential` |
|
|
454
|
-
| `
|
|
485
|
+
| `retry_jitter` | Boolean | `false` | Randomize each backoff delay with full jitter |
|
|
486
|
+
| `retry_max_delay` | Numeric | `30.0` | Upper bound (seconds) on any computed backoff delay |
|
|
487
|
+
| `retry_on_status` | Array | `nil` | HTTP status codes to retry on (e.g., `[429, 503]`); honors `Retry-After` |
|
|
455
488
|
| `cookies` | Boolean | `false` | Enable cookie jar for automatic cookie handling |
|
|
456
489
|
| `proxy` | String | `nil` | Proxy URL (also reads `HTTP_PROXY`/`HTTPS_PROXY` env vars) |
|
|
457
490
|
| `follow_redirects` | Boolean | `true` | Follow 3xx redirects automatically |
|
|
@@ -459,6 +492,7 @@ response.header("X-Missing") # => nil
|
|
|
459
492
|
| `pool` | Boolean | `false` | Enable connection pooling |
|
|
460
493
|
| `pool_size` | Integer | `5` | Maximum connections per host:port |
|
|
461
494
|
| `cache` | Boolean | `false` | Enable in-memory GET response caching |
|
|
495
|
+
| `cache_max_entries` | Integer | `1000` | Maximum cached entries before least-recently-used eviction |
|
|
462
496
|
| `on_request` | Proc | `nil` | Callback invoked after each request with `(method, uri, status, duration)` |
|
|
463
497
|
|
|
464
498
|
### Methods
|
|
@@ -8,21 +8,41 @@ module Philiprehberger
|
|
|
8
8
|
# Respects Cache-Control headers (max-age, no-cache, no-store) and supports
|
|
9
9
|
# conditional requests via ETag and Last-Modified headers.
|
|
10
10
|
#
|
|
11
|
+
# The store is bounded: once it holds +max_entries+ entries, the least
|
|
12
|
+
# recently used entry is evicted on the next insert. Both {#lookup} hits
|
|
13
|
+
# and {#refresh} mark an entry as recently used.
|
|
14
|
+
#
|
|
11
15
|
# @example
|
|
12
|
-
# cache = Cache.new
|
|
16
|
+
# cache = Cache.new(max_entries: 500)
|
|
13
17
|
# cache.store(uri, response)
|
|
14
18
|
# cached = cache.lookup(uri)
|
|
15
19
|
class Cache
|
|
16
20
|
# A cached entry with metadata for expiration and conditional requests.
|
|
17
21
|
CacheEntry = Struct.new(:response, :stored_at, :max_age, :etag, :last_modified, keyword_init: true)
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
# Default upper bound on the number of cached entries.
|
|
24
|
+
DEFAULT_MAX_ENTRIES = 1000
|
|
25
|
+
|
|
26
|
+
# @param max_entries [Integer] maximum number of entries retained before
|
|
27
|
+
# least-recently-used eviction kicks in (default: 1000)
|
|
28
|
+
def initialize(max_entries: DEFAULT_MAX_ENTRIES)
|
|
29
|
+
unless max_entries.is_a?(Integer) && max_entries.positive?
|
|
30
|
+
raise ArgumentError, "max_entries must be a positive Integer, got #{max_entries.inspect}"
|
|
31
|
+
end
|
|
32
|
+
|
|
20
33
|
@monitor = Monitor.new
|
|
21
34
|
@store = {}
|
|
35
|
+
@max_entries = max_entries
|
|
22
36
|
end
|
|
23
37
|
|
|
38
|
+
# Maximum number of entries retained before LRU eviction.
|
|
39
|
+
#
|
|
40
|
+
# @return [Integer]
|
|
41
|
+
attr_reader :max_entries
|
|
42
|
+
|
|
24
43
|
# Look up a cached response for the given URI.
|
|
25
|
-
# Returns nil if not cached or expired.
|
|
44
|
+
# Returns nil if not cached or expired. A hit marks the entry as the most
|
|
45
|
+
# recently used.
|
|
26
46
|
#
|
|
27
47
|
# @param uri [URI] the request URI
|
|
28
48
|
# @return [Response, nil] the cached response or nil
|
|
@@ -37,6 +57,7 @@ module Philiprehberger
|
|
|
37
57
|
@store.delete(key) unless entry.etag || entry.last_modified
|
|
38
58
|
nil
|
|
39
59
|
else
|
|
60
|
+
touch(key, entry)
|
|
40
61
|
entry.response
|
|
41
62
|
end
|
|
42
63
|
end
|
|
@@ -53,7 +74,8 @@ module Philiprehberger
|
|
|
53
74
|
end
|
|
54
75
|
|
|
55
76
|
# Store a response in the cache.
|
|
56
|
-
# Respects Cache-Control: no-store (does not cache).
|
|
77
|
+
# Respects Cache-Control: no-store (does not cache). Evicts the least
|
|
78
|
+
# recently used entry when the store exceeds +max_entries+.
|
|
57
79
|
#
|
|
58
80
|
# @param uri [URI] the request URI
|
|
59
81
|
# @param response [Response] the response to cache
|
|
@@ -72,7 +94,39 @@ module Philiprehberger
|
|
|
72
94
|
last_modified: response.headers['last-modified']
|
|
73
95
|
)
|
|
74
96
|
|
|
75
|
-
@monitor.synchronize
|
|
97
|
+
@monitor.synchronize do
|
|
98
|
+
@store.delete(key)
|
|
99
|
+
@store[key] = entry
|
|
100
|
+
evict_lru
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Refresh a stored entry after a 304 Not Modified revalidation.
|
|
105
|
+
#
|
|
106
|
+
# Resets +stored_at+ and applies any fresh +max-age+, +ETag+, or
|
|
107
|
+
# +Last-Modified+ carried by the 304 response, then returns the stored
|
|
108
|
+
# response (with its body) so the caller can serve cached content.
|
|
109
|
+
# Marks the entry as most recently used. Returns nil when no entry exists
|
|
110
|
+
# for the URI.
|
|
111
|
+
#
|
|
112
|
+
# @param uri [URI] the request URI
|
|
113
|
+
# @param response [Response] the 304 Not Modified response
|
|
114
|
+
# @return [Response, nil] the stored response, or nil if not cached
|
|
115
|
+
def refresh(uri, response)
|
|
116
|
+
key = cache_key(uri)
|
|
117
|
+
cc = parse_cache_control(response)
|
|
118
|
+
|
|
119
|
+
@monitor.synchronize do
|
|
120
|
+
entry = @store[key]
|
|
121
|
+
return nil unless entry
|
|
122
|
+
|
|
123
|
+
entry.stored_at = now
|
|
124
|
+
entry.max_age = cc[:max_age] if cc[:max_age]
|
|
125
|
+
entry.etag = response.headers['etag'] if response.headers['etag']
|
|
126
|
+
entry.last_modified = response.headers['last-modified'] if response.headers['last-modified']
|
|
127
|
+
touch(key, entry)
|
|
128
|
+
entry.response
|
|
129
|
+
end
|
|
76
130
|
end
|
|
77
131
|
|
|
78
132
|
# Remove all entries from the cache.
|
|
@@ -95,6 +149,19 @@ module Philiprehberger
|
|
|
95
149
|
uri.to_s
|
|
96
150
|
end
|
|
97
151
|
|
|
152
|
+
# Move an existing key to the end of the insertion order, marking it as
|
|
153
|
+
# the most recently used. Must be called while holding the monitor.
|
|
154
|
+
def touch(key, entry)
|
|
155
|
+
@store.delete(key)
|
|
156
|
+
@store[key] = entry
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Evict least-recently-used entries (front of the insertion order) until
|
|
160
|
+
# the store is within +max_entries+. Must be called while holding the monitor.
|
|
161
|
+
def evict_lru
|
|
162
|
+
@store.shift while @store.size > @max_entries
|
|
163
|
+
end
|
|
164
|
+
|
|
98
165
|
def expired?(entry)
|
|
99
166
|
return false unless entry.max_age
|
|
100
167
|
|
|
@@ -20,6 +20,8 @@ module Philiprehberger
|
|
|
20
20
|
# @param retries [Integer] Number of retry attempts on network errors
|
|
21
21
|
# @param retry_delay [Numeric] Seconds to wait between retries
|
|
22
22
|
# @param retry_backoff [Symbol] Backoff strategy (:fixed or :exponential)
|
|
23
|
+
# @param retry_jitter [Boolean] Randomize backoff delay with full jitter (default: false)
|
|
24
|
+
# @param retry_max_delay [Numeric] Upper bound (seconds) on any computed backoff delay (default: 30.0)
|
|
23
25
|
# @param cookies [Boolean] Enable cookie jar for automatic cookie handling
|
|
24
26
|
# @param proxy [String, nil] Proxy URL (e.g., "http://proxy:8080"), also reads HTTP_PROXY/HTTPS_PROXY
|
|
25
27
|
# @param follow_redirects [Boolean] Follow 3xx redirects (default: true)
|
|
@@ -27,6 +29,7 @@ module Philiprehberger
|
|
|
27
29
|
# @param pool [Boolean, nil] Enable connection pooling (default: false)
|
|
28
30
|
# @param pool_size [Integer, nil] Maximum connections per host:port (default: 5)
|
|
29
31
|
# @param cache [Boolean, nil] Enable response caching for GET requests (default: false)
|
|
32
|
+
# @param cache_max_entries [Integer, nil] Maximum cached entries before LRU eviction (default: 1000)
|
|
30
33
|
# @param on_request [Proc, nil] Callback invoked after each request with (method, uri, status, duration)
|
|
31
34
|
def initialize(base_url:, headers: {}, timeout: 30, **opts)
|
|
32
35
|
@base_url = base_url.chomp('/')
|
|
@@ -252,6 +255,12 @@ module Philiprehberger
|
|
|
252
255
|
unless %i[fixed exponential].include?(@retry_backoff)
|
|
253
256
|
raise ConfigurationError, "retry_backoff must be :fixed or :exponential, got #{@retry_backoff.inspect}"
|
|
254
257
|
end
|
|
258
|
+
unless [true, false].include?(@retry_jitter)
|
|
259
|
+
raise ConfigurationError, "retry_jitter must be true or false, got #{@retry_jitter.inspect}"
|
|
260
|
+
end
|
|
261
|
+
unless @retry_max_delay.is_a?(Numeric) && !@retry_max_delay.negative?
|
|
262
|
+
raise ConfigurationError, "retry_max_delay must be a non-negative Numeric, got #{@retry_max_delay.inspect}"
|
|
263
|
+
end
|
|
255
264
|
return if @retry_on_status.nil?
|
|
256
265
|
return if @retry_on_status.respond_to?(:include?)
|
|
257
266
|
|
|
@@ -262,6 +271,8 @@ module Philiprehberger
|
|
|
262
271
|
@retries = opts.fetch(:retries, 0)
|
|
263
272
|
@retry_delay = opts.fetch(:retry_delay, 1)
|
|
264
273
|
@retry_backoff = opts.fetch(:retry_backoff, :fixed)
|
|
274
|
+
@retry_jitter = opts.fetch(:retry_jitter, false)
|
|
275
|
+
@retry_max_delay = opts.fetch(:retry_max_delay, 30.0)
|
|
265
276
|
@retry_on_status = opts[:retry_on_status]
|
|
266
277
|
end
|
|
267
278
|
|
|
@@ -285,7 +296,17 @@ module Philiprehberger
|
|
|
285
296
|
end
|
|
286
297
|
|
|
287
298
|
def assign_cache_opts(opts)
|
|
288
|
-
|
|
299
|
+
unless opts[:cache]
|
|
300
|
+
@cache = nil
|
|
301
|
+
return
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
max_entries = opts.fetch(:cache_max_entries, Cache::DEFAULT_MAX_ENTRIES)
|
|
305
|
+
unless max_entries.is_a?(Integer) && max_entries.positive?
|
|
306
|
+
raise ConfigurationError, "cache_max_entries must be a positive Integer, got #{max_entries.inspect}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
@cache = Cache.new(max_entries: max_entries)
|
|
289
310
|
end
|
|
290
311
|
|
|
291
312
|
def lookup_cache(uri, extra_headers)
|
|
@@ -75,8 +75,8 @@ module Philiprehberger
|
|
|
75
75
|
run_interceptors(context)
|
|
76
76
|
response = perform_with_retries(uri, request, **timeout_opts, &)
|
|
77
77
|
response = follow_redirect_chain(response, request, **timeout_opts) if should_follow_redirect?(response)
|
|
78
|
+
response = handle_cacheable_get(uri, request, response)
|
|
78
79
|
response.instance_variable_set(:@request_id, request_id)
|
|
79
|
-
cache_response(uri, response) if @cache && request.is_a?(Net::HTTP::Get)
|
|
80
80
|
context[:response] = response
|
|
81
81
|
run_interceptors(context)
|
|
82
82
|
notify_on_request(context[:request][:method], uri, response)
|
|
@@ -242,6 +242,24 @@ module Philiprehberger
|
|
|
242
242
|
@pool.checkin(uri, http)
|
|
243
243
|
end
|
|
244
244
|
|
|
245
|
+
# Cache-aware post-processing for GET responses. On a 304 Not Modified
|
|
246
|
+
# for a URI we hold a cache entry for, the stored (bodied) response is
|
|
247
|
+
# refreshed and returned in place of the bodyless 304; otherwise the
|
|
248
|
+
# response is stored (when cacheable) and returned unchanged.
|
|
249
|
+
def handle_cacheable_get(uri, request, response)
|
|
250
|
+
return response unless @cache && request.is_a?(Net::HTTP::Get)
|
|
251
|
+
|
|
252
|
+
if response.status == 304
|
|
253
|
+
refreshed = @cache.refresh(uri, response)
|
|
254
|
+
return refreshed if refreshed
|
|
255
|
+
|
|
256
|
+
return response
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
cache_response(uri, response)
|
|
260
|
+
response
|
|
261
|
+
end
|
|
262
|
+
|
|
245
263
|
def cache_response(uri, response)
|
|
246
264
|
return unless response.ok?
|
|
247
265
|
return if response.streaming?
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
3
5
|
module Philiprehberger
|
|
4
6
|
module HttpClient
|
|
5
7
|
# Retry logic extracted from Connection to keep module length manageable.
|
|
6
8
|
module Retries
|
|
9
|
+
# Absolute ceiling (seconds) applied to a server-supplied Retry-After
|
|
10
|
+
# value, guarding against pathological or hostile headers.
|
|
11
|
+
RETRY_AFTER_CEILING = 300.0
|
|
12
|
+
|
|
7
13
|
private
|
|
8
14
|
|
|
9
15
|
def perform_with_retries(uri, request, **timeout_opts, &block)
|
|
@@ -12,7 +18,7 @@ module Philiprehberger
|
|
|
12
18
|
response = perform_request(uri, request, **timeout_opts, &block)
|
|
13
19
|
return response unless retry_on_status?(response.status, attempts)
|
|
14
20
|
|
|
15
|
-
wait_and_retry(attempts += 1)
|
|
21
|
+
wait_and_retry(attempts += 1, response)
|
|
16
22
|
rescue *Connection::TIMEOUT_ERRORS => e
|
|
17
23
|
handle_retry_error(attempts += 1, TimeoutError, e.message)
|
|
18
24
|
rescue *Connection::NETWORK_ERRORS => e
|
|
@@ -30,13 +36,66 @@ module Philiprehberger
|
|
|
30
36
|
@retry_on_status&.include?(status) && attempts < @retries
|
|
31
37
|
end
|
|
32
38
|
|
|
33
|
-
def wait_and_retry(attempt)
|
|
34
|
-
sleep(retry_delay_for(attempt))
|
|
39
|
+
def wait_and_retry(attempt, response = nil)
|
|
40
|
+
sleep(retry_delay_for(attempt, response))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Compute the delay before the next retry. A server-supplied Retry-After
|
|
44
|
+
# header (delta-seconds or HTTP-date) takes precedence and is clamped to
|
|
45
|
+
# {RETRY_AFTER_CEILING}; otherwise the configured backoff is used, capped
|
|
46
|
+
# at +retry_max_delay+ and optionally randomized with full jitter.
|
|
47
|
+
def retry_delay_for(attempt, response = nil)
|
|
48
|
+
retry_after = response ? retry_after_delay(response) : nil
|
|
49
|
+
return [retry_after, RETRY_AFTER_CEILING].min if retry_after
|
|
50
|
+
|
|
51
|
+
apply_jitter(cap_delay(backoff_delay(attempt)))
|
|
35
52
|
end
|
|
36
53
|
|
|
37
|
-
def
|
|
54
|
+
def backoff_delay(attempt)
|
|
38
55
|
@retry_backoff == :exponential ? @retry_delay * (2**(attempt - 1)) : @retry_delay
|
|
39
56
|
end
|
|
57
|
+
|
|
58
|
+
def cap_delay(delay)
|
|
59
|
+
@retry_max_delay ? [delay, @retry_max_delay].min : delay
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def apply_jitter(delay)
|
|
63
|
+
return delay unless @retry_jitter
|
|
64
|
+
|
|
65
|
+
jitter_rand * delay
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Random float in the half-open range [0.0, 1.0) used for full-jitter
|
|
69
|
+
# backoff. Extracted so it can be stubbed in tests.
|
|
70
|
+
#
|
|
71
|
+
# @return [Float]
|
|
72
|
+
def jitter_rand
|
|
73
|
+
rand
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Parse a Retry-After response header into a delay in seconds.
|
|
77
|
+
# Supports both delta-seconds (e.g. "120") and HTTP-date
|
|
78
|
+
# (e.g. "Wed, 21 Oct 2026 07:28:00 GMT") forms. Returns nil when the
|
|
79
|
+
# header is absent or unparseable.
|
|
80
|
+
#
|
|
81
|
+
# @param response [Response]
|
|
82
|
+
# @return [Float, Integer, nil]
|
|
83
|
+
def retry_after_delay(response)
|
|
84
|
+
value = response.headers['retry-after']
|
|
85
|
+
return nil unless value
|
|
86
|
+
|
|
87
|
+
value = value.to_s.strip
|
|
88
|
+
return value.to_i if value.match?(/\A\d+\z/)
|
|
89
|
+
|
|
90
|
+
http_date_delay(value)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def http_date_delay(value)
|
|
94
|
+
delay = Time.httpdate(value) - Time.now
|
|
95
|
+
delay.negative? ? 0.0 : delay
|
|
96
|
+
rescue ArgumentError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
40
99
|
end
|
|
41
100
|
end
|
|
42
101
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: philiprehberger-http_client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Philip Rehberger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-16 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A zero-dependency HTTP client built on Ruby's net/http with automatic
|
|
14
14
|
retries, request/response interceptors, and a clean API for JSON services.
|