chimera_http_client 1.7.1 → 1.9.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: 27502b5b2a10f00e43634863b14ce3d3d7ac3b957a9bcde7f239e0ee73124f55
4
- data.tar.gz: bd5697952a9e4a24b54e6fffb32ca6aadef39c28404bdfd60f00d0c1dd611f11
3
+ metadata.gz: ca5968573054108171952f955ef7974120a4d4725ed1ca34af29e3d40e831c61
4
+ data.tar.gz: 3f8adfc58275d048326c4cb25c93cc46d637195da9eb042e1c6388d705e3dc54
5
5
  SHA512:
6
- metadata.gz: 4db5ae181cefef0408b6e2f9586c96f0a311211b7d6b71a9c7ddf3960acb906e7873620b338b8231f42fd3ff999a9c3eeacd98a6f56d2e7345f1704f00bcca93
7
- data.tar.gz: 515e85c5020b526c6d9d064987355b9c28652ecd9b628d63de176a6dfffbe7fb273a2b7527ae0a9d4aee8128ea5d4cb8f89426f48c4b1d6e778c7d49481b4af5
6
+ metadata.gz: b00e1318e81f48ea6897e384062ab4e30b9ca83d1dc4549a22ecf8df9ce1940806c47fd04f8bf2f1109cd58922bee78605622fedc61bc74e5fb15d4be7dc0184
7
+ data.tar.gz: 494531a305d3bb571e98d9da1f6ad69330b64de19d29ed8d4cd64a3218fdc92bc3c2c499f7b88b829f714abc5f291654b09d364dff7577e83d6ff3080b68c6ae
@@ -1,25 +1,68 @@
1
- name: rubies-ci
1
+ name: Ruby CI
2
+
2
3
  on:
3
- pull_request:
4
4
  push:
5
- branches: [ main ]
5
+ branches: [ master ]
6
+ pull_request:
7
+ branches: [ master ]
8
+ workflow_dispatch:
9
+
10
+ concurrency:
11
+ group: ${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ permissions:
15
+ contents: read
16
+
6
17
  jobs:
7
18
  test:
19
+ name: Test on ${{ matrix.os }} / ${{ matrix.ruby }}
8
20
  strategy:
9
21
  fail-fast: false
10
22
  matrix:
11
- os: [ubuntu-latest] # macos-latest
12
- ruby: ['3.3', '3.4', 'truffleruby', 'jruby']
13
- # add 'truffleruby', 'jruby', for releases - and remember to use quotes due to https://github.com/actions/runner/issues/849
23
+ os: [ubuntu-latest]
24
+ ruby: ['3.3', '3.4', '4.0']
25
+ experimental: [false]
26
+ # add certain optional combos whose failures won't block merges:
27
+ include:
28
+ - os: macos-latest
29
+ ruby: '4.0'
30
+ experimental: true
31
+ - os: ubuntu-latest
32
+ ruby: 'truffleruby'
33
+ experimental: true
34
+ - os: ubuntu-latest
35
+ ruby: 'jruby'
36
+ experimental: true
37
+ - os: windows-latest
38
+ ruby: '4.0'
39
+ experimental: true
14
40
 
15
- runs-on: ${{ matrix.os }}
41
+ runs-on: ${{ matrix.os }}
16
42
 
17
43
  steps:
18
- - uses: actions/checkout@v4
19
- - uses: ruby/setup-ruby@v1
20
- with:
21
- ruby-version: ${{ matrix.ruby }}
22
- bundler-cache: true # runs 'bundle install' and caches installed gems automatically
23
-
24
- - name: Run linters and test suite
25
- run: bundle exec rake ci
44
+ - uses: actions/checkout@v7
45
+ with:
46
+ fetch-depth: 0
47
+
48
+ - name: Set up Java for JRuby
49
+ if: ${{ matrix.ruby == 'jruby' }}
50
+ uses: actions/setup-java@v4
51
+ with:
52
+ distribution: temurin
53
+ java-version: '25'
54
+
55
+ - uses: ruby/setup-ruby@v1
56
+ with:
57
+ ruby-version: ${{ matrix.ruby }}
58
+ bundler-cache: true
59
+
60
+ - name: Print versions
61
+ run: |
62
+ ruby -v
63
+ gem -v
64
+ bundle -v
65
+
66
+ - name: Run linters and test suite
67
+ continue-on-error: ${{ matrix.experimental }}
68
+ run: bundle exec rake ci
data/.rubocop.yml CHANGED
@@ -54,6 +54,11 @@ RSpec/MultipleMemoizedHelpers:
54
54
  RSpec/NestedGroups:
55
55
  Max: 4
56
56
 
57
+ RSpec/Output:
58
+ Exclude:
59
+ - spec/server_spec_helper.rb
60
+ - spec/chimera_http_client/server/connection_options_spec.rb
61
+
57
62
  RSpec/PendingWithoutReason:
58
63
  Enabled: false
59
64
 
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.4.4
1
+ 4.0.6
data/README.markdown CHANGED
@@ -17,57 +17,65 @@ The only other runtime dependency is Ruby's latest code loader [**zeitwerk**](ht
17
17
  ^
18
18
  ### Ruby version
19
19
 
20
- | Chimera version | MRI Ruby version | JRuby | TruffleRuby |
21
- |:----------------|:----------------------------------------------------|:-----:|:-----------:|
22
- | >= 1.7 | >= 3.3 (older versions untested, likely still work) | yes | yes |
23
- | >= 1.6 | >= 2.7 (all 3.x versions supported) | yes | yes |
24
- | >= 1.4 | >= 2.5 (3.0 compatibility ensured) | yes | no |
25
- | >= 1.1 | >= 2.5 | ? | ? |
26
- | = 1.0 | >= 2.4, <= 3.0 | ? | ? |
27
- | <= 0.5 | >= 2.1, <= 3.0 | ? | ? |
20
+ | Chimera version | MRI Ruby version | JRuby | TruffleRuby |
21
+ |:----------------|:---------------------------------------------------------------------|:-----:|:-----------:|
22
+ | >= 1.8 | >= 3.3 (4.0 supported, older versions untested, likely still work) | yes | yes |
23
+ | >= 1.7 | >= 3.3 (older versions untested, likely still work) | yes | yes |
24
+ | >= 1.6 | >= 2.7 (all 3.x versions supported) | yes | yes |
25
+ | >= 1.4 | >= 2.5 (3.0 compatibility ensured) | yes | no |
26
+ | >= 1.1 | >= 2.5 | ? | ? |
27
+ | = 1.0 | >= 2.4, <= 3.0 | ? | ? |
28
+ | <= 0.5 | >= 2.1, <= 3.0 | ? | ? |
28
29
 
29
30
  The test suite of v1.4 passes on **MRI Ruby** (2.5, 2.6, 2.7, 3.0) and on **JRuby**, but not on **TruffleRuby**.
30
31
  The test suite of v1.6 passes on **MRI Ruby** (2.7, 3.0, 3.1, 3.2, 3.3) and on **JRuby** and **TruffleRuby**.
31
- The non-MRI Rubys are not part of the regular Matrix, as their CI jobs take 3x as long, but included for new releases.
32
+ v1.8 is no longer tested against MRI Rubies older than 3.3 (but they are likely still supported).
33
+ All information above is given for **Linux**.
34
+ **MacOS & Windows** were only tested successfully against **MRI Ruby 4.0** (older versions likely work as well).
32
35
 
33
36
  ### ENV variables
34
37
 
35
38
  Setting the environment variable `ENV['CHIMERA_HTTP_CLIENT_LOG_REQUESTS']` to `true` (or `'true'`) will provide more detailed error messages for logging and also add additional information to the Error JSON. It is recommended to use this only in development environments.
36
39
 
40
+ Setting `ENV['CHIMERA_HTTP_CLIENT_DEFAULT_HEADERS']` to a JSON object string will merge those headers into every `Connection`/`Queue` in the process, e.g. `export CHIMERA_HTTP_CLIENT_DEFAULT_HEADERS='{"X-Service-Name":"orders-api"}'`. This is meant for deployment/ops-level defaults (like identifying which service is calling), set once outside application code. See [Custom headers](#custom-headers) below.
41
+
37
42
  ## Table of Contents
38
43
 
39
44
  <!-- TOC depthFrom:1 depthTo:4 withLinks:1 updateOnSave:0 orderedList:0 -->
40
45
 
41
- * [ChimeraHttpClient](#chimerahttpclient)
42
- * [Dependencies](#dependencies)
43
- * [Ruby version](#ruby-version)
44
- * [ENV variables](#env-variables)
45
- * [Table of Contents](#table-of-contents)
46
- * [The Connection class](#the-connection-class)
47
- * [Initialization](#initialization)
48
- * [Mandatory initialization parameter `base_url`](#mandatory-initialization-parameter-base_url)
49
- * [Optional initialization parameters](#optional-initialization-parameters)
50
- * [Custom deserializers](#custom-deserializers)
51
- * [Monitoring, metrics, instrumentation](#monitoring-metrics-instrumentation)
52
- * [Request methods](#request-methods)
53
- * [Mandatory request parameter `endpoint`](#mandatory-request-parameter-endpoint)
54
- * [Optional request parameters](#optional-request-parameters)
55
- * [Basic auth](#basic-auth)
56
- * [Timeout duration](#timeout-duration)
57
- * [Custom logger](#custom-logger)
58
- * [Caching responses](#caching-responses)
59
- * [Example usage](#example-usage)
60
- * [The Request class](#the-request-class)
61
- * [The Response class](#the-response-class)
62
- * [Error classes](#error-classes)
63
- * [The Queue class](#the-queue-class)
64
- * [Queueing requests](#queueing-requests)
65
- * [Executing requests in parallel](#executing-requests-in-parallel)
66
- * [Empty the queue](#empty-the-queue)
67
- * [Installation](#installation)
68
- * [Maintainers and Contributors](#maintainers-and-contributors)
69
- * [Roadmap](#roadmap)
70
- * [Chimera](#chimera)
46
+ - [ChimeraHttpClient](#chimerahttpclient)
47
+ - [Dependencies](#dependencies)
48
+ - [Ruby version](#ruby-version)
49
+ - [ENV variables](#env-variables)
50
+ - [Table of Contents](#table-of-contents)
51
+ - [The Connection class](#the-connection-class)
52
+ - [Initialization](#initialization)
53
+ - [Mandatory initialization parameter `base_url`](#mandatory-initialization-parameter-base_url)
54
+ - [Optional initialization parameters](#optional-initialization-parameters)
55
+ - [Custom deserializers](#custom-deserializers)
56
+ - [Custom headers](#custom-headers)
57
+ - [Custom serializer](#custom-serializer)
58
+ - [Monitoring, metrics, instrumentation](#monitoring-metrics-instrumentation)
59
+ - [Request methods](#request-methods)
60
+ - [Mandatory request parameter `endpoint`](#mandatory-request-parameter-endpoint)
61
+ - [Optional request parameters](#optional-request-parameters)
62
+ - [Basic auth](#basic-auth)
63
+ - [Timeout duration](#timeout-duration)
64
+ - [Retrying requests](#retrying-requests)
65
+ - [Custom logger](#custom-logger)
66
+ - [Caching responses](#caching-responses)
67
+ - [Example usage](#example-usage)
68
+ - [The Request class](#the-request-class)
69
+ - [The Response class](#the-response-class)
70
+ - [Error classes](#error-classes)
71
+ - [The Queue class](#the-queue-class)
72
+ - [Queueing requests](#queueing-requests)
73
+ - [Executing requests in parallel](#executing-requests-in-parallel)
74
+ - [Empty the queue](#empty-the-queue)
75
+ - [Installation](#installation)
76
+ - [Maintainers and Contributors](#maintainers-and-contributors)
77
+ - [Roadmap](#roadmap)
78
+ - [Chimera](#chimera)
71
79
 
72
80
  <!-- /TOC -->
73
81
 
@@ -96,8 +104,12 @@ The optional parameters are:
96
104
 
97
105
  * `cache` - an instance of your cache solution, can be overwritten in any request
98
106
  * `deserializers` - custom methods to deserialize the response body, below more details
107
+ * `headers` - override/extend the default request headers (`{ "Content-Type" => "application/json" }`), can be overwritten or merged with per-request headers, below more details
99
108
  * `logger` - an instance of a logger class that implements `#info`, `#warn` and `#error` methods
100
109
  * `monitor` - to collect metrics about requests, the basis for your instrumentation needs
110
+ * `retries` - the number of times a failed idempotent request is retried, can be overwritten in any request, the default is `0` (no retries)
111
+ * `retry_delay` - the base delay in seconds between retries, can be overwritten in any request, the default is `1`
112
+ * `serializer` - override how a Hash/Array request body is turned into a request, can be overwritten in any request, below more details
101
113
  * `timeout` - the timeout for all requests, can be overwritten in any request, the default are 3 seconds
102
114
  * `user_agent` - if you would like your calls to identify with a specific user agent
103
115
  * `verbose` - the default is `false`, set it to true while debugging issues
@@ -112,7 +124,64 @@ A Deserializer has to be an object on which the method `call` with the parameter
112
124
 
113
125
  custom_deserializer.call(body)
114
126
 
115
- where `body` is the response body (in the default case a JSON object). The class `Deserializer` contains the default objects that are used. They might help you creating your own. Don't forget to make requests with another header than the default `"Content-Type" => "application/json"`, when the API you connect to does not support JSON.
127
+ where `body` is the response body (in the default case a JSON object). The class `Deserializer` contains the default objects that are used. They might help you creating your own. If the API you connect to does not support JSON, set `headers` (see [Custom headers](#custom-headers) below) once on the `Connection` instead of repeating it on every request - and see [Custom serializer](#custom-serializer) below for the equivalent on the request-body side.
128
+
129
+ ##### Custom headers
130
+
131
+ Every request sends `{ "Content-Type" => "application/json" }` plus a `User-Agent` by default. Four layers apply on top of each other (each one merges in, overriding only the keys it sets, so you never have to restate headers you're not changing):
132
+
133
+ 1. the built-in default above (also available as `ChimeraHttpClient::Base::DEFAULT_HEADERS`)
134
+ 2. `ENV['CHIMERA_HTTP_CLIENT_DEFAULT_HEADERS']`, a JSON object (see [ENV variables](#env-variables) above)
135
+ 3. `headers` passed to `Connection.new`/`Queue.new` - the connection's own default
136
+ 4. `headers` passed to an individual request - already documented above, unchanged
137
+
138
+ ```ruby
139
+ # connecting to a non-JSON API: override just Content-Type, everything else (User-Agent, ...) still applies
140
+ connection = ChimeraHttpClient::Connection.new(base_url: 'http://localhost:3000/v1', headers: { "Content-Type" => "application/xml" })
141
+ ```
142
+
143
+ A common use for the connection-level `headers` option is a request/correlation id that should be attached to every call made for one unit of work (a background job, one inbound request being served), without every call site needing to know about it:
144
+
145
+ ```ruby
146
+ connection = ChimeraHttpClient::Connection.new(base_url: 'http://localhost:3000/v1', headers: { "X-Request-Id" => job_id })
147
+ ```
148
+
149
+ For something that's the same for every request in the whole process instead - e.g. identifying which of your services is calling, in a service-to-service setting - set it once via `ENV['CHIMERA_HTTP_CLIENT_DEFAULT_HEADERS']` at the deployment level rather than in application code:
150
+
151
+ ```bash
152
+ export CHIMERA_HTTP_CLIENT_DEFAULT_HEADERS='{"X-Service-Name":"orders-api"}'
153
+ ```
154
+
155
+ ##### Custom serializer
156
+
157
+ A `Hash` or `Array` `body` is automatically serialized before the request is sent - by default with `body.to_json`, so this now just works:
158
+
159
+ ```ruby
160
+ connection.post!('users', body: { name: "Andy" }) # no more body.to_json needed
161
+ ```
162
+
163
+ A `String` body (e.g. one you already serialized yourself) is always passed through completely unchanged - so any existing code still doing `body: body.to_json` keeps working exactly as before.
164
+
165
+ To use a different format, pass `serializer` to `Connection.new`/`Queue.new`, or to an individual request. A Serializer has to be an object on which the method `call` with the parameter `body` can be called:
166
+
167
+ custom_serializer.call(body)
168
+
169
+ ```ruby
170
+ # talking to an XML API instead
171
+ connection = ChimeraHttpClient::Connection.new(base_url: 'http://localhost:3000/v1', serializer: ->(body) { body.to_xml })
172
+ ```
173
+
174
+ The serializer normally returns a `String`, but it can also return the `Hash` unconverted - Typhoeus (via Ethon/libcurl) then form-encodes it natively as `application/x-www-form-urlencoded`, or as real multipart if a value looks file-shaped:
175
+
176
+ ```ruby
177
+ # posting a plain form instead of JSON
178
+ connection.post(
179
+ 'login',
180
+ body: { username: "andy", password: "secret" },
181
+ serializer: ->(body) { body }, # hand the Hash to Typhoeus/Ethon unconverted
182
+ headers: { "Content-Type" => "application/x-www-form-urlencoded" }
183
+ )
184
+ ```
116
185
 
117
186
  ##### Monitoring, metrics, instrumentation
118
187
 
@@ -171,6 +240,9 @@ All request methods expect a mandatory `endpoint` and an optional hash as parame
171
240
  * `password` - used for a BasicAuth login
172
241
  * `timeout` - set a custom timeout per request (the default is 3 seconds)
173
242
  * `cache` - optionally overwrite the cache store set in `Connection` in any request
243
+ * `retries` - optionally overwrite the number of retries set in `Connection` for this request
244
+ * `retry_delay` - optionally overwrite the retry delay set in `Connection` for this request
245
+ * `serializer` - optionally overwrite the body serializer set in `Connection` for this request
174
246
  * `monitoring_context` - pass additional information you want to collect with your instrumentation `monitor`
175
247
 
176
248
  Example:
@@ -197,6 +269,24 @@ The default timeout duration is **3 seconds**.
197
269
 
198
270
  If you want to use a different timeout, you can pass the key `timeout` when initializing the `Connection`. You can also overwrite it on every call.
199
271
 
272
+ #### Retrying requests
273
+
274
+ Pass `retries` (and optionally `retry_delay`) to `Connection.new`/`Queue.new`, or to any individual request, to automatically retry on transient failures:
275
+
276
+ ```ruby
277
+ connection = ChimeraHttpClient::Connection.new(base_url: 'http://localhost:3000/v1', retries: 3, retry_delay: 1)
278
+ ```
279
+
280
+ * `retries` - the maximum number of retry attempts. The default is `0` (no retries, fully opt-in).
281
+ * `retry_delay` - the base delay in seconds before the first retry. The default is `1`. Each subsequent retry doubles the previous delay (a fixed 2x backoff, not configurable): with `retry_delay: 1` the delays are `1s, 2s, 4s, ...`.
282
+
283
+ Retries are automatic and safe by design - they only apply to:
284
+
285
+ * **idempotent methods**: `get`, `put`, `delete`, `head` - never `post`/`patch`, regardless of the configured `retries`, since retrying a non-idempotent write could duplicate side effects.
286
+ * **transient errors**: `ConnectionError`, `TimeoutError`, `ServerError` (5xx) - never 4xx `ClientError`s, since retrying those can't change the outcome.
287
+
288
+ > Note for `Queue`: retries are implemented by re-queueing the failed request onto the same `Typhoeus::Hydra` that's already running the batch, so a retry can start as soon as its own request fails rather than waiting for the whole batch. One consequence: the `retry_delay` sleep happens inside that request's completion callback, which briefly pauses progress on *every other* in-flight request in the same queue (libcurl's multi interface is a single-threaded, cooperative event loop). This doesn't affect `Connection`, whose retries run sequentially with nothing else in flight.
289
+
200
290
  #### Custom logger
201
291
 
202
292
  By default no logging is happening. If you need request logging, you can pass your custom Logger to the key `logger` when initializing the `Connection`. It will write to `logger.info` when starting and when completing a request.
@@ -287,7 +377,7 @@ class Users
287
377
  # CREATE a new user by sending attributes in a JSON body and instantiate the new User
288
378
  #
289
379
  def create(body:)
290
- response = connection.post!('users', body: body.to_json) # body.to_json (!!)
380
+ response = connection.post!('users', body: body) # a Hash body is serialized to JSON automatically
291
381
 
292
382
  user = response.parsed_body
293
383
  User.new(id: user['id'], name: user['name'], email: user['email'])
@@ -322,7 +412,7 @@ To create and fetch a user from a remote service with the `Users` wrapper listed
322
412
 
323
413
  Usually it does not have to be used directly. It is the class that executes the `Typhoeus::Requests`, raises `Errors` on failing and returns `Response` objects on successful calls.
324
414
 
325
- The `body` which it receives from the `Connection` class has to be in the in the (serialized) form in which the endpoint expects it. Usually this means you have to pass a JSON string to the `body` (it will **not** be serialized automatically).
415
+ By the time `Request` receives `body`, it's already in the (serialized) form the endpoint expects: `Connection`/`Queue` auto-serialize a `Hash`/`Array` body to JSON (or via a custom `serializer`, see [Custom serializer](#custom-serializer)) before it ever reaches `Request`. A `String` body is passed through unchanged.
326
416
 
327
417
  ## The Response class
328
418
 
@@ -434,8 +524,8 @@ After checking out the repo, run `bundle install` and then `bundle execute rake`
434
524
 
435
525
  > The test suite uses a Sinatra server to make real HTTP requests. It is mounted via Capybara_discoball and running in the same process. It is still running reasonably fast (on my MacBook Air):
436
526
 
437
- Finished in 2.01 seconds (files took 1.09 seconds to load)
438
- 824 examples, 0 failures, 7 pending
527
+ Finished in 1.02 seconds (files took 0.43805 seconds to load)
528
+ 882 examples, 0 failures, 7 pending
439
529
 
440
530
  You can also run `rake console` to open an irb session with the `ChimeraHttpClient` pre-loaded that will allow you to experiment.
441
531
 
data/TODO.markdown CHANGED
@@ -22,6 +22,12 @@ _none known_
22
22
  * [x] ~~use custom deserializer in #parsed_body instead of default JSON parsing~~
23
23
  * [x] ~~add example to README~~
24
24
 
25
+ ### ~~Custom serializer~~
26
+
27
+ * [x] ~~allow to pass custom serializer~~
28
+ * [x] ~~use custom serializer with body instead of default JSON serialization ~~
29
+ * [x] ~~add example to README~~
30
+
25
31
  ### Queueing / running in parallel
26
32
 
27
33
  * [x] ~~allow to queue multiple requests~~
@@ -79,12 +85,12 @@ _none known_
79
85
  * [ ] enable to pass on_headers, on_body, on_complete procs
80
86
  * [ ] add example to README
81
87
 
82
- ### HTTP Headers
88
+ ### ~~HTTP Headers~~
83
89
 
84
- * [ ] make Connection#default_headers configurable
85
- * [ ] allow to set default_headers via ENV vars
86
- * [ ] give example how to use default_headers (e.g. request_id)
87
- * [ ] explain in README how to benefit from this gem in a given setting
90
+ * [x] ~~make Connection#default_headers configurable~~
91
+ * [x] ~~allow to set default_headers via ENV vars~~
92
+ * [x] ~~give example how to use default_headers (e.g. request_id)~~
93
+ * [x] ~~explain in README how to benefit from this gem in a given setting~~
88
94
 
89
95
  ### ~~Caching~~
90
96
 
@@ -105,15 +111,13 @@ _none known_
105
111
  * [x] ~~hook up Travis-CI~~
106
112
  * [x] ~~ensure it runs with Ruby 2.4 and newer~~
107
113
 
108
- ### Retry Requests
114
+ ### ~~Retry Requests~~
109
115
 
110
- * [ ] either leverage Hydra to retry failed calls
111
- * [ ] configure number of retries
112
- * [ ] configure delay between retries
113
- * [ ] retry idempotent calls GET, PUT, DELETE, HEAD automatically
114
- * [ ] maybe implement retries with wait and Redis (external dependency!)
115
- * [ ] or document in README how to build a retry mechanism
116
- * [ ] https://gist.github.com/kunalmodi/2939288
116
+ * [x] ~~leverage Hydra to retry failed calls (Queue); Connection uses a sequential retry loop instead~~
117
+ * [x] ~~configure number of retries~~
118
+ * [x] ~~configure delay between retries~~
119
+ * [x] ~~retry idempotent calls GET, PUT, DELETE, HEAD automatically~~
120
+ * [x] ~~add example to README~~
117
121
 
118
122
  ### Queueing / run requests serialized
119
123
 
@@ -40,9 +40,9 @@ Gem::Specification.new do |spec|
40
40
  spec.add_development_dependency "irb", ">= 1.15"
41
41
  spec.add_development_dependency "rake", ">= 13.2"
42
42
  spec.add_development_dependency "rspec", "~> 3.13"
43
- spec.add_development_dependency "rubocop", "~> 1.75.7"
44
- spec.add_development_dependency "rubocop-rake", "~> 0.7.1"
45
- spec.add_development_dependency "rubocop-rspec", "~> 3.6.0"
43
+ spec.add_development_dependency "rubocop", "~> 1.75"
44
+ spec.add_development_dependency "rubocop-rake", "~> 0.7"
45
+ spec.add_development_dependency "rubocop-rspec", "~> 3.10"
46
46
 
47
47
  # only for server specs with real HTTP requests
48
48
  spec.add_development_dependency "capybara", "~> 3.40"
@@ -1,18 +1,24 @@
1
1
  module ChimeraHttpClient
2
2
  class Base
3
3
  USER_AGENT = "ChimeraHttpClient (by mediafinger)".freeze
4
+ DEFAULT_HEADERS = { "Content-Type" => "application/json" }.freeze
5
+ DEFAULT_HEADERS_ENV_VAR = "CHIMERA_HTTP_CLIENT_DEFAULT_HEADERS".freeze
4
6
 
5
7
  def initialize(options = {})
6
8
  fail(ChimeraHttpClient::ParameterMissingError, "base_url expected, but not given") if options[:base_url].nil?
7
9
 
8
10
  @base_url = options.fetch(:base_url)
9
11
  @deserializer = default_deserializer.merge(options.fetch(:deserializer, {}))
12
+ @serializer = options.fetch(:serializer, ::ChimeraHttpClient::Serializer.json)
10
13
  @logger = options[:logger]
11
14
  @monitor = options[:monitor]
12
15
  @timeout = options[:timeout]
13
16
  @cache = options[:cache]
17
+ @headers = DEFAULT_HEADERS.merge(headers_from_env).merge(options[:headers] || {})
14
18
  @user_agent = options.fetch(:user_agent, USER_AGENT)
15
19
  @verbose = options.fetch(:verbose, false)
20
+ @retries = options[:retries] || 0
21
+ @retry_delay = options[:retry_delay] || 1
16
22
  end
17
23
 
18
24
  private
@@ -22,6 +28,8 @@ module ChimeraHttpClient
22
28
  options[:timeout] ||= @timeout
23
29
  options[:cache] = @cache if options[:cache].nil?
24
30
  options[:verbose] = @verbose if options[:verbose].nil?
31
+ options[:retries] = @retries if options[:retries].nil?
32
+ options[:retry_delay] = @retry_delay if options[:retry_delay].nil?
25
33
 
26
34
  options
27
35
  end
@@ -29,10 +37,13 @@ module ChimeraHttpClient
29
37
  def extract_body(options)
30
38
  body = options.delete(:body)
31
39
  body_optional = options.delete(:body_optional)
40
+ serializer = options.delete(:serializer) || @serializer
32
41
 
33
42
  fail(ChimeraHttpClient::ParameterMissingError, "body expected, but not given") if body.nil? && !body_optional
34
43
 
35
- body
44
+ return body unless body.is_a?(Hash) || body.is_a?(Array)
45
+
46
+ serializer.call(body)
36
47
  end
37
48
 
38
49
  def extract_headers(options, headers)
@@ -41,7 +52,16 @@ module ChimeraHttpClient
41
52
  end
42
53
 
43
54
  def default_headers
44
- { "Content-Type" => "application/json", "User-Agent" => @user_agent }
55
+ @headers.merge("User-Agent" => @user_agent)
56
+ end
57
+
58
+ def headers_from_env
59
+ raw = ENV[DEFAULT_HEADERS_ENV_VAR].to_s
60
+ return {} if raw.strip.empty?
61
+
62
+ JSON.parse(raw)
63
+ rescue JSON::ParserError => e
64
+ raise(ChimeraHttpClient::ParameterMissingError, "ENV['#{DEFAULT_HEADERS_ENV_VAR}'] is not valid JSON: #{e.message}")
45
65
  end
46
66
 
47
67
  def default_deserializer
@@ -42,6 +42,7 @@ module ChimeraHttpClient
42
42
  deserializer: @deserializer,
43
43
  logger: @logger,
44
44
  monitor: @monitor,
45
+ hydra: hydra,
45
46
  }
46
47
 
47
48
  Request.new(instance_options).create(
@@ -2,10 +2,20 @@ module ChimeraHttpClient
2
2
  class Request
3
3
  TIMEOUT_SECONDS = 3
4
4
 
5
+ # Only idempotent methods are retried automatically, regardless of the configured retries count
6
+ RETRYABLE_METHODS = %i(get put delete head).freeze
7
+
8
+ # Only network failures and 5xx are retried - 4xx client errors can't be fixed by retrying
9
+ RETRYABLE_ERRORS = [ConnectionError, TimeoutError, ServerError].freeze
10
+
11
+ RETRY_BACKOFF_MULTIPLIER = 2
12
+
5
13
  attr_reader :request, :result
6
14
 
7
15
  def initialize(options = {})
8
16
  @options = options
17
+ @hydra = options[:hydra]
18
+ @attempt = 0
9
19
  end
10
20
 
11
21
  def run(url:, method:, body: nil, options: {}, headers: {})
@@ -13,6 +23,15 @@ module ChimeraHttpClient
13
23
 
14
24
  @request.run
15
25
 
26
+ attempt = 0
27
+ while retryable?(method, @result, options, attempt)
28
+ attempt += 1
29
+ sleep(delay_for(attempt, options))
30
+
31
+ create(url: url, method: method, body: body, options: options, headers: headers)
32
+ @request.run
33
+ end
34
+
16
35
  @result
17
36
  end
18
37
 
@@ -57,7 +76,21 @@ module ChimeraHttpClient
57
76
  }
58
77
  )
59
78
 
60
- @result = on_complete_handler(response)
79
+ result = on_complete_handler(response)
80
+
81
+ # Used for queued requests (Queue): retries are re-queued onto the same Hydra from
82
+ # inside this callback, per Typhoeus::Hydra::Queueable#queue ("can even be done while
83
+ # the hydra is running"). Connection-built requests never have a @hydra, so this branch
84
+ # never triggers for them - their retries are handled by the loop in #run instead.
85
+ if @hydra && retryable?(method, result, options, @attempt)
86
+ @attempt += 1
87
+ sleep(delay_for(@attempt, options))
88
+
89
+ create(url: url, method: method, body: body, options: options, headers: headers)
90
+ @hydra.queue(@request)
91
+ else
92
+ @result = result
93
+ end
61
94
  end
62
95
 
63
96
  @options[:logger]&.info(
@@ -76,6 +109,18 @@ module ChimeraHttpClient
76
109
 
77
110
  private
78
111
 
112
+ def retryable?(method, result, options, attempt)
113
+ result.error? &&
114
+ RETRYABLE_METHODS.include?(method) &&
115
+ RETRYABLE_ERRORS.any? { |klass| result.is_a?(klass) } &&
116
+ attempt < options[:retries].to_i
117
+ end
118
+
119
+ # Delay before retry attempt n (1-indexed): retry_delay * RETRY_BACKOFF_MULTIPLIER**(n - 1)
120
+ def delay_for(attempt, options)
121
+ options[:retry_delay].to_f * (RETRY_BACKOFF_MULTIPLIER**(attempt - 1))
122
+ end
123
+
79
124
  def on_complete_handler(response)
80
125
  return Response.new(response, @options) if response.success?
81
126
 
@@ -0,0 +1,23 @@
1
+ # The default JSON serializer for request bodies.
2
+ #
3
+ # To use a custom serializer, pass it as a param to Connection.new or Queue.new:
4
+ # `serializer: your_serializer`
5
+ # or override it for a single request: `connection.post(endpoint, body: {...}, serializer: your_serializer)`
6
+ #
7
+ # A Serializer has to be an object on which the method `call` with the parameter `body` can be called:
8
+ # `custom_serializer.call(body)`
9
+ # It is only ever invoked for a Hash or Array body - anything else (e.g. an already-serialized
10
+ # String) is passed to the request unchanged. It normally returns a String, but may also return
11
+ # a Hash - Typhoeus/Ethon will then form-encode it natively (application/x-www-form-urlencoded,
12
+ # or multipart if a value looks file-shaped), so an identity serializer (`->(body) { body }`) is
13
+ # a valid way to opt into that instead of JSON.
14
+ #
15
+ module ChimeraHttpClient
16
+ class Serializer
17
+ class << self
18
+ def json
19
+ proc { |body| body.to_json }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,3 +1,3 @@
1
1
  module ChimeraHttpClient
2
- VERSION = "1.7.1".freeze
2
+ VERSION = "1.9.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chimera_http_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.1
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andreas Finger
@@ -127,42 +127,42 @@ dependencies:
127
127
  requirements:
128
128
  - - "~>"
129
129
  - !ruby/object:Gem::Version
130
- version: 1.75.7
130
+ version: '1.75'
131
131
  type: :development
132
132
  prerelease: false
133
133
  version_requirements: !ruby/object:Gem::Requirement
134
134
  requirements:
135
135
  - - "~>"
136
136
  - !ruby/object:Gem::Version
137
- version: 1.75.7
137
+ version: '1.75'
138
138
  - !ruby/object:Gem::Dependency
139
139
  name: rubocop-rake
140
140
  requirement: !ruby/object:Gem::Requirement
141
141
  requirements:
142
142
  - - "~>"
143
143
  - !ruby/object:Gem::Version
144
- version: 0.7.1
144
+ version: '0.7'
145
145
  type: :development
146
146
  prerelease: false
147
147
  version_requirements: !ruby/object:Gem::Requirement
148
148
  requirements:
149
149
  - - "~>"
150
150
  - !ruby/object:Gem::Version
151
- version: 0.7.1
151
+ version: '0.7'
152
152
  - !ruby/object:Gem::Dependency
153
153
  name: rubocop-rspec
154
154
  requirement: !ruby/object:Gem::Requirement
155
155
  requirements:
156
156
  - - "~>"
157
157
  - !ruby/object:Gem::Version
158
- version: 3.6.0
158
+ version: '3.10'
159
159
  type: :development
160
160
  prerelease: false
161
161
  version_requirements: !ruby/object:Gem::Requirement
162
162
  requirements:
163
163
  - - "~>"
164
164
  - !ruby/object:Gem::Version
165
- version: 3.6.0
165
+ version: '3.10'
166
166
  - !ruby/object:Gem::Dependency
167
167
  name: capybara
168
168
  requirement: !ruby/object:Gem::Requirement
@@ -292,6 +292,7 @@ files:
292
292
  - lib/chimera_http_client/queue.rb
293
293
  - lib/chimera_http_client/request.rb
294
294
  - lib/chimera_http_client/response.rb
295
+ - lib/chimera_http_client/serializer.rb
295
296
  - lib/chimera_http_client/version.rb
296
297
  homepage: https://github.com/mediafinger/chimera_http_client
297
298
  licenses:
@@ -312,7 +313,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
312
313
  - !ruby/object:Gem::Version
313
314
  version: '0'
314
315
  requirements: []
315
- rubygems_version: 3.6.9
316
+ rubygems_version: 4.0.16
316
317
  specification_version: 4
317
318
  summary: General http client functionality to quickly connect to JSON REST API endpoints
318
319
  and any others