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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e7ebec429195d4e674adf7836c916e2e9fa40938c1fe79209d8c10ad4c0df5d
4
- data.tar.gz: 94fd543943abe6573cb5ce9bdc686bc5c7aa79858b312aab251a4851c4f9718f
3
+ metadata.gz: 211aea712ab77e8a14f5a565d0a235113b1b5a5630e38e13ddcfb2f92c61b141
4
+ data.tar.gz: d9a7e6996c9ca7cc6ba296c837db466cdc692abb489bb179c24aea3e888e274b
5
5
  SHA512:
6
- metadata.gz: ba7e2ba9e18a068391353b8164393deb5205faa86367fffb1101b782ca08e72d1db2c8eecf8343621062b61629268c09254c9a597d333da59657979903090882
7
- data.tar.gz: 2ab2ebe6ab143e5417e7939905c9b971bce1237cf434f0d4a360f912ab02d37468affa783368297ed11e1de44689847239a55f9fbd7e51864c9ba7ec6be595fa
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.11.0...HEAD
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
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-http_client.svg)](https://rubygems.org/gems/philiprehberger-http_client)
5
5
  [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-http-client)](https://github.com/philiprehberger/rb-http-client/commits/main)
6
6
 
7
+ ![philiprehberger-http_client](https://raw.githubusercontent.com/philiprehberger/rb-http-client/main/package-card.webp)
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
- | `retry_on_status` | Array | `nil` | HTTP status codes to retry on (e.g., `[429, 503]`) |
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
- 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?
@@ -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.0'
5
+ VERSION = '0.12.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.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-05-14 00:00:00.000000000 Z
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.