philiprehberger-http_client 0.11.1 → 0.13.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: 5438d46ef6a929e6272a230dc19c64a50ff6c183b96cbec03b6379f1dec476df
4
- data.tar.gz: 256673e0413568cfaafc4a4fbbba15d0fbdffe60c0aca1d22090909c2db24a16
3
+ metadata.gz: a22fdf41ff535b768c6ac9783ad358b8367cf7aa9f666368cd098c30ef36231c
4
+ data.tar.gz: 62bb80048febfa5be44bfef001cd082bf447866a2a4d5afe76717e2e829e9423
5
5
  SHA512:
6
- metadata.gz: fb20cd1a0bd5020afc0e519d6b02ae078fa19266ae829f201baf607b8b4b8dce1e1bdd73d80110739dbd04b2d8bcdc9e235b64ad9ed528be0efcecb9a8e52e54
7
- data.tar.gz: 81e1cdcef88ee36d4a585bd8ae6f190e61641ae0f7575b416dca40abbe389aadc4bdd7cf4a632a3778bb580333422cdbf7338f1afb268a225ee8c26767609f8e
6
+ metadata.gz: 75d9a2a474f77b8358ea76fb131acdcb2f11c9fdc31b46d4dc65ec6364139de9e6fd66b085f65b294afde442b0e9b12a73c1775cbc5c7ad2f3275544bb5d58ae
7
+ data.tar.gz: f728d0e5d12a7642e098c75fca3af7ef75aff526329982678e224c4917cd921164170319db294ad33ba1ca3bd923f39b442082bbb17f8a1fd8140873125325ce
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-10-30
11
+
12
+ ### Added
13
+ - `Response#raise_for_status!` — returns `self` on 2xx/3xx responses and raises `HttpError` (with `.response`) on 4xx/5xx, enabling chained calls like `client.get(path).raise_for_status!.json`
14
+ - `Response#content_type` — media type from the `Content-Type` header without parameters (e.g. `"application/json"`), `nil` if absent
15
+ - `Response#content_length` — `Content-Length` header as an Integer, `nil` if absent or invalid
16
+
17
+ ### Changed
18
+ - Aligned `.gitignore` with the standard gem template
19
+ - Aligned CI workflow with the standard template (`ruby` matrix key, glob gemspec in build/push)
20
+
21
+ ## [0.12.0] - 2026-07-15
22
+
23
+ ### Added
24
+ - `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)
25
+ - `retry_jitter:` option (default `false`) — spreads retries with full jitter over the computed delay
26
+ - `retry_max_delay:` option (default `30.0`) — caps any single backoff delay so exponential growth cannot run away
27
+ - `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
28
+
29
+ ### Fixed
30
+ - 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
31
+
10
32
  ## [0.11.1] - 2026-06-14
11
33
 
12
34
  ### Changed
@@ -200,7 +222,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
200
222
  - Response wrapper with `ok?` and `json` convenience methods
201
223
  - Zero dependencies — built on Ruby stdlib `net/http`
202
224
 
203
- [Unreleased]: https://github.com/philiprehberger/rb-http-client/compare/v0.11.1...HEAD
225
+ [Unreleased]: https://github.com/philiprehberger/rb-http-client/compare/v0.13.0...HEAD
226
+ [0.13.0]: https://github.com/philiprehberger/rb-http-client/compare/v0.12.0...v0.13.0
227
+ [0.12.0]: https://github.com/philiprehberger/rb-http-client/compare/v0.11.1...v0.12.0
204
228
  [0.11.1]: https://github.com/philiprehberger/rb-http-client/compare/v0.11.0...v0.11.1
205
229
  [0.11.0]: https://github.com/philiprehberger/rb-http-client/compare/v0.10.0...v0.11.0
206
230
  [0.10.0]: https://github.com/philiprehberger/rb-http-client/compare/v0.9.1...v0.10.0
data/README.md CHANGED
@@ -202,6 +202,12 @@ client = Philiprehberger::HttpClient.new(
202
202
  )
203
203
  ```
204
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
+
205
211
  ### Exponential backoff
206
212
 
207
213
  ```ruby
@@ -214,6 +220,22 @@ client = Philiprehberger::HttpClient.new(
214
220
  # Delay sequence: 1s, 2s, 4s
215
221
  ```
216
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
+
217
239
  ### Cookie jar
218
240
 
219
241
  Enable automatic cookie handling across requests:
@@ -372,12 +394,25 @@ response = client.get("/data")
372
394
  response = client.get("/data")
373
395
 
374
396
  # Conditional requests: sends If-None-Match / If-Modified-Since
375
- # 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).
376
399
 
377
400
  # Flush the cache
378
401
  client.clear_cache!
379
402
  ```
380
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
+
381
416
  ### Request logging callback
382
417
 
383
418
  Add an `on_request` callback for observability — it receives the HTTP method, URI, response status, and duration (in seconds) after each request completes:
@@ -432,6 +467,25 @@ response.header("etag") # works for any casing
432
467
  response.header("X-Missing") # => nil
433
468
  ```
434
469
 
470
+ ### Content metadata and status checks
471
+
472
+ `Response#content_type` returns the media type without parameters, `Response#content_length` parses the `Content-Length` header, and `Response#raise_for_status!` raises `HttpError` on 4xx/5xx while returning the response otherwise:
473
+
474
+ ```ruby
475
+ response = client.get("/report")
476
+
477
+ response.content_type # => "application/json" (from "application/json; charset=utf-8")
478
+ response.content_length # => 2048, or nil if the header is absent
479
+
480
+ data = client.get("/users").raise_for_status!.json
481
+
482
+ begin
483
+ client.get("/missing").raise_for_status!
484
+ rescue Philiprehberger::HttpClient::HttpError => e
485
+ e.response.status # => 404
486
+ end
487
+ ```
488
+
435
489
  ## API
436
490
 
437
491
  ### `Philiprehberger::HttpClient.new(**options)`
@@ -447,7 +501,9 @@ response.header("X-Missing") # => nil
447
501
  | `retries` | Integer | `0` | Retry attempts on network errors |
448
502
  | `retry_delay` | Numeric | `1` | Seconds between retries |
449
503
  | `retry_backoff` | Symbol | `:fixed` | Backoff strategy — `:fixed` or `:exponential` |
450
- | `retry_on_status` | Array | `nil` | HTTP status codes to retry on (e.g., `[429, 503]`) |
504
+ | `retry_jitter` | Boolean | `false` | Randomize each backoff delay with full jitter |
505
+ | `retry_max_delay` | Numeric | `30.0` | Upper bound (seconds) on any computed backoff delay |
506
+ | `retry_on_status` | Array | `nil` | HTTP status codes to retry on (e.g., `[429, 503]`); honors `Retry-After` |
451
507
  | `cookies` | Boolean | `false` | Enable cookie jar for automatic cookie handling |
452
508
  | `proxy` | String | `nil` | Proxy URL (also reads `HTTP_PROXY`/`HTTPS_PROXY` env vars) |
453
509
  | `follow_redirects` | Boolean | `true` | Follow 3xx redirects automatically |
@@ -455,6 +511,7 @@ response.header("X-Missing") # => nil
455
511
  | `pool` | Boolean | `false` | Enable connection pooling |
456
512
  | `pool_size` | Integer | `5` | Maximum connections per host:port |
457
513
  | `cache` | Boolean | `false` | Enable in-memory GET response caching |
514
+ | `cache_max_entries` | Integer | `1000` | Maximum cached entries before least-recently-used eviction |
458
515
  | `on_request` | Proc | `nil` | Callback invoked after each request with `(method, uri, status, duration)` |
459
516
 
460
517
  ### Methods
@@ -507,6 +564,9 @@ response.header("X-Missing") # => nil
507
564
  | `server_error?` | Boolean | Returns true for 5xx status codes |
508
565
  | `json` | Hash | Parsed JSON body |
509
566
  | `json?` | Boolean | `true` if `Content-Type` is `application/json` or `+json` suffix |
567
+ | `content_type` | String | Media type without parameters (e.g. `"application/json"`), `nil` if absent |
568
+ | `content_length` | Integer | `Content-Length` header as an Integer, `nil` if absent or invalid |
569
+ | `raise_for_status!` | Response | Returns `self` on 1xx-3xx, raises `HttpError` (with `.response`) on 4xx/5xx |
510
570
  | `streaming?` | Boolean | `true` if response was streamed |
511
571
  | `metrics` | Metrics | Request timing breakdown (total_time, first_byte_time, etc.) |
512
572
  | `redirects` | Array | Redirect chain URLs (empty if no redirects) |
@@ -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
- def initialize
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 { @store[key] = entry }
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
- @cache = opts[:cache] ? Cache.new : nil
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?
@@ -83,6 +83,45 @@ module Philiprehberger
83
83
  primary == 'application/json' || primary.end_with?('+json')
84
84
  end
85
85
 
86
+ # Returns the media type from the `Content-Type` header without any
87
+ # parameters (e.g. `"application/json"` for
88
+ # `"application/json; charset=utf-8"`). The value is downcased.
89
+ #
90
+ # @return [String, nil] the media type, or `nil` if the header is absent or blank
91
+ def content_type
92
+ value = header('content-type')
93
+ return nil if value.nil?
94
+
95
+ media_type = value.to_s.split(';').first.to_s.strip.downcase
96
+ media_type.empty? ? nil : media_type
97
+ end
98
+
99
+ # Returns the `Content-Length` header as an Integer.
100
+ #
101
+ # @return [Integer, nil] the declared body length, or `nil` if the header
102
+ # is absent or not a valid non-negative integer
103
+ def content_length
104
+ value = header('content-length')
105
+ return nil if value.nil?
106
+
107
+ length = Integer(value.to_s.strip, 10, exception: false)
108
+ length&.negative? ? nil : length
109
+ end
110
+
111
+ # Raises {HttpError} if the response has a 4xx or 5xx status code.
112
+ # Returns `self` otherwise so the call can be chained.
113
+ #
114
+ # @example
115
+ # data = client.get('/users').raise_for_status!.json
116
+ #
117
+ # @return [self]
118
+ # @raise [HttpError] if the status code is 400 or greater
119
+ def raise_for_status!
120
+ raise HttpError, self if status >= 400
121
+
122
+ self
123
+ end
124
+
86
125
  # Returns request timing metrics (nil if not available).
87
126
  #
88
127
  # @return [Metrics, nil]
@@ -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 retry_delay_for(attempt)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module HttpClient
5
- VERSION = '0.11.1'
5
+ VERSION = '0.13.0'
6
6
  end
7
7
  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.11.1
4
+ version: 0.13.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-06-15 00:00:00.000000000 Z
11
+ date: 2026-10-30 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.