philiprehberger-http_client 0.3.3 → 0.4.1
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 +19 -0
- data/README.md +128 -15
- data/lib/philiprehberger/http_client/body_encoder.rb +43 -0
- data/lib/philiprehberger/http_client/client.rb +54 -18
- data/lib/philiprehberger/http_client/connection.rb +57 -54
- data/lib/philiprehberger/http_client/errors.rb +25 -0
- data/lib/philiprehberger/http_client/multipart.rb +69 -0
- data/lib/philiprehberger/http_client/response.rb +11 -2
- data/lib/philiprehberger/http_client/retries.rb +42 -0
- data/lib/philiprehberger/http_client/version.rb +1 -1
- data/lib/philiprehberger/http_client.rb +4 -0
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97333340c609f0644e26f98a211046453d8a9a2a25d481ed7e32bdc692e59c34
|
|
4
|
+
data.tar.gz: 6de9a47bb6e9d51cce945257c8dfd2c7ab8dc9b4d6d06e98ca2b88fac0ea3a88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 31b3c5ff2878f16cfb643e94f76288e578aba515100c285a56b216704ca164f262fa7a870a6075b90cdea8deeb76b38f679cf30dd91273b5c5be1e46277d0345
|
|
7
|
+
data.tar.gz: e76a8fd782453e0e8f684a4781f5136e6da323aa7f435dc384d606c87c3b88cebd48e4ae86537ea05f125aa68f9da39e0365082d601207ef47983b23a423b455
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
- Fix RuboCop Style/StringLiterals violations in gemspec
|
|
6
|
+
|
|
7
|
+
## 0.4.0
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Custom error hierarchy: `Error`, `TimeoutError`, `NetworkError`, `HttpError` for structured error handling
|
|
12
|
+
- Streaming responses via block parameter — yield body chunks instead of buffering entire response
|
|
13
|
+
- Multipart form data support via `multipart:` parameter for file uploads
|
|
14
|
+
- Per-phase timeouts: `open_timeout:`, `read_timeout:`, `write_timeout:` on constructor and per-request
|
|
15
|
+
- Response validation via `expect:` option — auto-raises `HttpError` if status not in expected list
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Network errors (`Errno::ECONNREFUSED`, `Errno::ECONNRESET`, etc.) now raise `NetworkError` instead of raw system errors
|
|
20
|
+
- Timeout errors (`Net::OpenTimeout`, `Net::ReadTimeout`) now raise `TimeoutError` instead of raw Net errors
|
|
21
|
+
|
|
3
22
|
## 0.3.3
|
|
4
23
|
|
|
5
24
|
- Fix CI: version test and rubocop compliance
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://github.com/philiprehberger/rb-http-client/actions/workflows/ci.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
Lightweight HTTP client wrapper with retries and interceptors.
|
|
7
|
+
Lightweight HTTP client wrapper with retries and interceptors.
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
@@ -20,7 +20,7 @@ gem "philiprehberger-http_client"
|
|
|
20
20
|
|
|
21
21
|
Or install directly:
|
|
22
22
|
|
|
23
|
-
```
|
|
23
|
+
```bash
|
|
24
24
|
gem install philiprehberger-http_client
|
|
25
25
|
```
|
|
26
26
|
|
|
@@ -88,6 +88,41 @@ response = client.post("/login", form: { username: "alice", password: "secret" }
|
|
|
88
88
|
# Content-Type: application/x-www-form-urlencoded is set automatically
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
### File uploads (multipart)
|
|
92
|
+
|
|
93
|
+
Upload files using multipart/form-data:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
response = client.post("/upload", multipart: {
|
|
97
|
+
file: File.open("photo.jpg"),
|
|
98
|
+
name: "vacation"
|
|
99
|
+
})
|
|
100
|
+
# Content-Type: multipart/form-data is set automatically with boundary
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Both `File` objects and string values are supported. Files are sent with their filename and `application/octet-stream` content type.
|
|
104
|
+
|
|
105
|
+
### Streaming responses
|
|
106
|
+
|
|
107
|
+
Stream large responses without buffering the entire body in memory:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
File.open("download.bin", "wb") do |file|
|
|
111
|
+
client.get("/large-file") do |chunk|
|
|
112
|
+
file.write(chunk)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
# Returns a Response with body: nil and streaming?: true
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Streaming works with all HTTP methods that accept a block:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
client.post("/export", json: { format: "csv" }) do |chunk|
|
|
122
|
+
output.write(chunk)
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
91
126
|
### Authentication helpers
|
|
92
127
|
|
|
93
128
|
```ruby
|
|
@@ -100,6 +135,61 @@ client.basic_auth("username", "password")
|
|
|
100
135
|
client.get("/protected") # Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
|
|
101
136
|
```
|
|
102
137
|
|
|
138
|
+
### Error handling
|
|
139
|
+
|
|
140
|
+
All errors inherit from `Philiprehberger::HttpClient::Error`:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
begin
|
|
144
|
+
client.get("/api/data")
|
|
145
|
+
rescue Philiprehberger::HttpClient::TimeoutError => e
|
|
146
|
+
puts "Request timed out: #{e.message}"
|
|
147
|
+
rescue Philiprehberger::HttpClient::NetworkError => e
|
|
148
|
+
puts "Network error: #{e.message}"
|
|
149
|
+
rescue Philiprehberger::HttpClient::HttpError => e
|
|
150
|
+
puts "HTTP error: #{e.response.status}"
|
|
151
|
+
rescue Philiprehberger::HttpClient::Error => e
|
|
152
|
+
puts "Client error: #{e.message}"
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `TimeoutError` — raised on `Net::OpenTimeout` and `Net::ReadTimeout`
|
|
157
|
+
- `NetworkError` — raised on connection refused, reset, host unreachable, etc.
|
|
158
|
+
- `HttpError` — raised when response status doesn't match `expect:` list (includes `.response`)
|
|
159
|
+
|
|
160
|
+
### Response validation
|
|
161
|
+
|
|
162
|
+
Auto-raise `HttpError` if the response status doesn't match expected values:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
# Raises HttpError if status is not 200
|
|
166
|
+
response = client.get("/api/users", expect: [200])
|
|
167
|
+
|
|
168
|
+
# Accept multiple status codes
|
|
169
|
+
response = client.post("/api/users", json: data, expect: [200, 201])
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Timeouts
|
|
173
|
+
|
|
174
|
+
Set a general timeout or per-phase timeouts:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# General timeout (applies to open, read, and write)
|
|
178
|
+
client = Philiprehberger::HttpClient.new(base_url: "https://api.example.com", timeout: 30)
|
|
179
|
+
|
|
180
|
+
# Per-phase timeouts (override general timeout)
|
|
181
|
+
client = Philiprehberger::HttpClient.new(
|
|
182
|
+
base_url: "https://api.example.com",
|
|
183
|
+
open_timeout: 5,
|
|
184
|
+
read_timeout: 30,
|
|
185
|
+
write_timeout: 10
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Per-request timeout overrides
|
|
189
|
+
response = client.get("/slow-endpoint", read_timeout: 120)
|
|
190
|
+
response = client.post("/upload", body: data, write_timeout: 60)
|
|
191
|
+
```
|
|
192
|
+
|
|
103
193
|
### Retries
|
|
104
194
|
|
|
105
195
|
Automatically retry on network errors (connection refused, timeouts, etc.):
|
|
@@ -139,12 +229,6 @@ client = Philiprehberger::HttpClient.new(
|
|
|
139
229
|
# Delay sequence: 1s, 2s, 4s
|
|
140
230
|
```
|
|
141
231
|
|
|
142
|
-
### Per-request timeout
|
|
143
|
-
|
|
144
|
-
```ruby
|
|
145
|
-
response = client.get("/slow-endpoint", timeout: 60)
|
|
146
|
-
```
|
|
147
|
-
|
|
148
232
|
### All HTTP methods
|
|
149
233
|
|
|
150
234
|
```ruby
|
|
@@ -164,7 +248,10 @@ client.head("/resource")
|
|
|
164
248
|
|---------------|---------|---------|--------------------------------------|
|
|
165
249
|
| `base_url` | String | — | Base URL for all requests (required) |
|
|
166
250
|
| `headers` | Hash | `{}` | Default headers for every request |
|
|
167
|
-
| `timeout` | Integer | `30` |
|
|
251
|
+
| `timeout` | Integer | `30` | General timeout in seconds |
|
|
252
|
+
| `open_timeout` | Integer | `nil` | TCP connection timeout (overrides `timeout`) |
|
|
253
|
+
| `read_timeout` | Integer | `nil` | Response read timeout (overrides `timeout`) |
|
|
254
|
+
| `write_timeout` | Integer | `nil` | Request write timeout (overrides `timeout`) |
|
|
168
255
|
| `retries` | Integer | `0` | Retry attempts on network errors |
|
|
169
256
|
| `retry_delay` | Numeric | `1` | Seconds between retries |
|
|
170
257
|
| `retry_backoff` | Symbol | `:fixed` | Backoff strategy — `:fixed` or `:exponential` |
|
|
@@ -174,25 +261,51 @@ client.head("/resource")
|
|
|
174
261
|
|
|
175
262
|
| Method | Description |
|
|
176
263
|
|--------|-------------|
|
|
177
|
-
| `get(path, **opts)` | Send GET request |
|
|
178
|
-
| `post(path, **opts)` | Send POST request |
|
|
179
|
-
| `put(path, **opts)` | Send PUT request |
|
|
180
|
-
| `patch(path, **opts)` | Send PATCH request |
|
|
264
|
+
| `get(path, **opts, &block)` | Send GET request (block enables streaming) |
|
|
265
|
+
| `post(path, **opts, &block)` | Send POST request |
|
|
266
|
+
| `put(path, **opts, &block)` | Send PUT request |
|
|
267
|
+
| `patch(path, **opts, &block)` | Send PATCH request |
|
|
181
268
|
| `delete(path, **opts)` | Send DELETE request |
|
|
182
269
|
| `head(path, **opts)` | Send HEAD request |
|
|
183
270
|
| `request_count` | Total number of requests made by this client |
|
|
184
271
|
| `bearer_token(token)` | Set Bearer token auth for all subsequent requests |
|
|
185
272
|
| `basic_auth(user, pass)` | Set Basic auth for all subsequent requests |
|
|
186
273
|
|
|
274
|
+
### Per-request options
|
|
275
|
+
|
|
276
|
+
| Option | Type | Description |
|
|
277
|
+
|--------|------|-------------|
|
|
278
|
+
| `params` | Hash | Query parameters (GET, HEAD) |
|
|
279
|
+
| `json` | Hash/Array | JSON body (POST, PUT, PATCH) |
|
|
280
|
+
| `form` | Hash | Form-urlencoded body (POST, PUT, PATCH) |
|
|
281
|
+
| `multipart` | Hash | Multipart form data with file support (POST, PUT, PATCH) |
|
|
282
|
+
| `body` | String | Raw body string (POST, PUT, PATCH) |
|
|
283
|
+
| `headers` | Hash | Per-request headers |
|
|
284
|
+
| `timeout` | Integer | General per-request timeout |
|
|
285
|
+
| `open_timeout` | Integer | Per-request open timeout |
|
|
286
|
+
| `read_timeout` | Integer | Per-request read timeout |
|
|
287
|
+
| `write_timeout` | Integer | Per-request write timeout |
|
|
288
|
+
| `expect` | Array | Expected status codes — raises `HttpError` otherwise |
|
|
289
|
+
|
|
187
290
|
### `Response`
|
|
188
291
|
|
|
189
292
|
| Method | Returns | Description |
|
|
190
293
|
|-----------|---------|---------------------------------|
|
|
191
294
|
| `status` | Integer | HTTP status code |
|
|
192
|
-
| `body` | String | Raw response body
|
|
295
|
+
| `body` | String | Raw response body (`nil` if streamed) |
|
|
193
296
|
| `headers` | Hash | Response headers |
|
|
194
297
|
| `ok?` | Boolean | `true` if status is 200-299 |
|
|
195
298
|
| `json` | Hash | Parsed JSON body |
|
|
299
|
+
| `streaming?` | Boolean | `true` if response was streamed |
|
|
300
|
+
|
|
301
|
+
### Errors
|
|
302
|
+
|
|
303
|
+
| Class | Description |
|
|
304
|
+
|-------|-------------|
|
|
305
|
+
| `Error` | Base error class (inherits `StandardError`) |
|
|
306
|
+
| `TimeoutError` | Connection or read timeout |
|
|
307
|
+
| `NetworkError` | Connection refused, reset, unreachable |
|
|
308
|
+
| `HttpError` | Response status mismatch (has `.response` accessor) |
|
|
196
309
|
|
|
197
310
|
|
|
198
311
|
## Development
|
|
@@ -205,4 +318,4 @@ bundle exec rubocop
|
|
|
205
318
|
|
|
206
319
|
## License
|
|
207
320
|
|
|
208
|
-
|
|
321
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module HttpClient
|
|
5
|
+
# Handles encoding request bodies (JSON, form, multipart, raw).
|
|
6
|
+
module BodyEncoder
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def apply_body(request, opts, headers)
|
|
10
|
+
set_json_body(request, opts[:json], headers) ||
|
|
11
|
+
set_form_body(request, opts[:form], headers) ||
|
|
12
|
+
set_multipart_body(request, opts[:multipart], headers) ||
|
|
13
|
+
set_raw_body(request, opts[:body])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def set_json_body(request, json_body, headers)
|
|
17
|
+
return unless json_body
|
|
18
|
+
|
|
19
|
+
request.body = JSON.generate(json_body)
|
|
20
|
+
headers["content-type"] ||= "application/json"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def set_form_body(request, form_body, headers)
|
|
24
|
+
return unless form_body
|
|
25
|
+
|
|
26
|
+
request.body = URI.encode_www_form(form_body)
|
|
27
|
+
headers["content-type"] ||= "application/x-www-form-urlencoded"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def set_multipart_body(request, multipart_body, headers)
|
|
31
|
+
return unless multipart_body
|
|
32
|
+
|
|
33
|
+
built_body, content_type = Multipart.build(multipart_body)
|
|
34
|
+
request.body = built_body
|
|
35
|
+
headers["content-type"] = content_type
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def set_raw_body(request, body)
|
|
39
|
+
request.body = body if body
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -12,18 +12,19 @@ module Philiprehberger
|
|
|
12
12
|
|
|
13
13
|
# @param base_url [String] Base URL for all requests
|
|
14
14
|
# @param headers [Hash] Default headers applied to every request
|
|
15
|
-
# @param timeout [Integer]
|
|
15
|
+
# @param timeout [Integer] General read/open timeout in seconds
|
|
16
|
+
# @param open_timeout [Integer, nil] TCP connection timeout (overrides timeout)
|
|
17
|
+
# @param read_timeout [Integer, nil] Response read timeout (overrides timeout)
|
|
18
|
+
# @param write_timeout [Integer, nil] Request write timeout (overrides timeout)
|
|
16
19
|
# @param retries [Integer] Number of retry attempts on network errors
|
|
17
20
|
# @param retry_delay [Numeric] Seconds to wait between retries
|
|
18
21
|
# @param retry_backoff [Symbol] Backoff strategy (:fixed or :exponential)
|
|
19
|
-
def initialize(base_url:, headers: {}, timeout: 30, **
|
|
22
|
+
def initialize(base_url:, headers: {}, timeout: 30, **opts)
|
|
20
23
|
@base_url = base_url.chomp("/")
|
|
21
24
|
@default_headers = headers
|
|
22
25
|
@timeout = timeout
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@retry_backoff = retry_options.fetch(:retry_backoff, :fixed)
|
|
26
|
-
@retry_on_status = retry_options[:retry_on_status]
|
|
26
|
+
assign_timeout_opts(opts)
|
|
27
|
+
assign_retry_opts(opts)
|
|
27
28
|
@interceptors = []
|
|
28
29
|
@request_count = 0
|
|
29
30
|
end
|
|
@@ -52,11 +53,16 @@ module Philiprehberger
|
|
|
52
53
|
# @param params [Hash] Query parameters
|
|
53
54
|
# @param headers [Hash] Additional headers for this request
|
|
54
55
|
# @param timeout [Integer, nil] Optional per-request timeout override
|
|
56
|
+
# @param open_timeout [Integer, nil] Optional per-request open timeout
|
|
57
|
+
# @param read_timeout [Integer, nil] Optional per-request read timeout
|
|
58
|
+
# @param write_timeout [Integer, nil] Optional per-request write timeout
|
|
59
|
+
# @param expect [Array<Integer>, nil] Expected status codes (raises HttpError otherwise)
|
|
60
|
+
# @yield [String] response body chunks when streaming
|
|
55
61
|
# @return [Response]
|
|
56
|
-
def get(path, params: {}, headers: {},
|
|
62
|
+
def get(path, params: {}, headers: {}, expect: nil, **timeout_opts, &block)
|
|
57
63
|
uri = build_uri(path, params)
|
|
58
64
|
request = Net::HTTP::Get.new(uri)
|
|
59
|
-
execute(uri, request, headers,
|
|
65
|
+
execute(uri, request, headers, expect: expect, **timeout_opts, &block)
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
# Perform a HEAD request.
|
|
@@ -65,11 +71,15 @@ module Philiprehberger
|
|
|
65
71
|
# @param params [Hash] Query parameters
|
|
66
72
|
# @param headers [Hash] Additional headers for this request
|
|
67
73
|
# @param timeout [Integer, nil] Optional per-request timeout override
|
|
74
|
+
# @param open_timeout [Integer, nil] Optional per-request open timeout
|
|
75
|
+
# @param read_timeout [Integer, nil] Optional per-request read timeout
|
|
76
|
+
# @param write_timeout [Integer, nil] Optional per-request write timeout
|
|
77
|
+
# @param expect [Array<Integer>, nil] Expected status codes
|
|
68
78
|
# @return [Response]
|
|
69
|
-
def head(path, params: {}, headers: {},
|
|
79
|
+
def head(path, params: {}, headers: {}, expect: nil, **timeout_opts)
|
|
70
80
|
uri = build_uri(path, params)
|
|
71
81
|
request = Net::HTTP::Head.new(uri)
|
|
72
|
-
execute(uri, request, headers,
|
|
82
|
+
execute(uri, request, headers, expect: expect, **timeout_opts)
|
|
73
83
|
end
|
|
74
84
|
|
|
75
85
|
# Perform a POST request.
|
|
@@ -78,10 +88,12 @@ module Philiprehberger
|
|
|
78
88
|
# @param body [String, nil] Raw body string
|
|
79
89
|
# @param json [Hash, Array, nil] JSON-serializable body (sets Content-Type automatically)
|
|
80
90
|
# @param form [Hash, nil] Form-urlencoded body (sets Content-Type automatically)
|
|
91
|
+
# @param multipart [Hash, nil] Multipart form data (sets Content-Type automatically)
|
|
81
92
|
# @param headers [Hash] Additional headers
|
|
93
|
+
# @param expect [Array<Integer>, nil] Expected status codes
|
|
82
94
|
# @return [Response]
|
|
83
|
-
def post(path,
|
|
84
|
-
request_with_body(Net::HTTP::Post, path,
|
|
95
|
+
def post(path, ...)
|
|
96
|
+
request_with_body(Net::HTTP::Post, path, ...)
|
|
85
97
|
end
|
|
86
98
|
|
|
87
99
|
# Perform a PUT request.
|
|
@@ -90,10 +102,12 @@ module Philiprehberger
|
|
|
90
102
|
# @param body [String, nil] Raw body string
|
|
91
103
|
# @param json [Hash, Array, nil] JSON-serializable body
|
|
92
104
|
# @param form [Hash, nil] Form-urlencoded body
|
|
105
|
+
# @param multipart [Hash, nil] Multipart form data
|
|
93
106
|
# @param headers [Hash] Additional headers
|
|
107
|
+
# @param expect [Array<Integer>, nil] Expected status codes
|
|
94
108
|
# @return [Response]
|
|
95
|
-
def put(path,
|
|
96
|
-
request_with_body(Net::HTTP::Put, path,
|
|
109
|
+
def put(path, ...)
|
|
110
|
+
request_with_body(Net::HTTP::Put, path, ...)
|
|
97
111
|
end
|
|
98
112
|
|
|
99
113
|
# Perform a PATCH request.
|
|
@@ -102,21 +116,28 @@ module Philiprehberger
|
|
|
102
116
|
# @param body [String, nil] Raw body string
|
|
103
117
|
# @param json [Hash, Array, nil] JSON-serializable body
|
|
104
118
|
# @param form [Hash, nil] Form-urlencoded body
|
|
119
|
+
# @param multipart [Hash, nil] Multipart form data
|
|
105
120
|
# @param headers [Hash] Additional headers
|
|
121
|
+
# @param expect [Array<Integer>, nil] Expected status codes
|
|
106
122
|
# @return [Response]
|
|
107
|
-
def patch(path,
|
|
108
|
-
request_with_body(Net::HTTP::Patch, path,
|
|
123
|
+
def patch(path, ...)
|
|
124
|
+
request_with_body(Net::HTTP::Patch, path, ...)
|
|
109
125
|
end
|
|
110
126
|
|
|
111
127
|
# Perform a DELETE request.
|
|
112
128
|
#
|
|
113
129
|
# @param path [String] Request path
|
|
114
130
|
# @param headers [Hash] Additional headers
|
|
131
|
+
# @param timeout [Integer, nil] Optional per-request timeout override
|
|
132
|
+
# @param open_timeout [Integer, nil] Optional per-request open timeout
|
|
133
|
+
# @param read_timeout [Integer, nil] Optional per-request read timeout
|
|
134
|
+
# @param write_timeout [Integer, nil] Optional per-request write timeout
|
|
135
|
+
# @param expect [Array<Integer>, nil] Expected status codes
|
|
115
136
|
# @return [Response]
|
|
116
|
-
def delete(path, headers: {},
|
|
137
|
+
def delete(path, headers: {}, expect: nil, **timeout_opts)
|
|
117
138
|
uri = build_uri(path)
|
|
118
139
|
request = Net::HTTP::Delete.new(uri)
|
|
119
|
-
execute(uri, request, headers,
|
|
140
|
+
execute(uri, request, headers, expect: expect, **timeout_opts)
|
|
120
141
|
end
|
|
121
142
|
|
|
122
143
|
# Set a Bearer token for all subsequent requests.
|
|
@@ -138,6 +159,21 @@ module Philiprehberger
|
|
|
138
159
|
@default_headers["authorization"] = "Basic #{encoded}"
|
|
139
160
|
self
|
|
140
161
|
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def assign_timeout_opts(opts)
|
|
166
|
+
@open_timeout = opts[:open_timeout]
|
|
167
|
+
@read_timeout = opts[:read_timeout]
|
|
168
|
+
@write_timeout = opts[:write_timeout]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def assign_retry_opts(opts)
|
|
172
|
+
@retries = opts.fetch(:retries, 0)
|
|
173
|
+
@retry_delay = opts.fetch(:retry_delay, 1)
|
|
174
|
+
@retry_backoff = opts.fetch(:retry_backoff, :fixed)
|
|
175
|
+
@retry_on_status = opts[:retry_on_status]
|
|
176
|
+
end
|
|
141
177
|
end
|
|
142
178
|
end
|
|
143
179
|
end
|
|
@@ -5,19 +5,30 @@ module Philiprehberger
|
|
|
5
5
|
# Internal helpers for building URIs, HTTP connections, executing requests,
|
|
6
6
|
# and constructing Response objects. Mixed into Client to keep it concise.
|
|
7
7
|
module Connection
|
|
8
|
-
|
|
8
|
+
TIMEOUT_ERRORS = [
|
|
9
|
+
Net::OpenTimeout, Net::ReadTimeout
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
NETWORK_ERRORS = [
|
|
9
13
|
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
|
|
10
|
-
|
|
14
|
+
Errno::EHOSTUNREACH, Errno::ENETUNREACH, SocketError
|
|
11
15
|
].freeze
|
|
12
16
|
|
|
17
|
+
RETRYABLE_ERRORS = (TIMEOUT_ERRORS + NETWORK_ERRORS).freeze
|
|
18
|
+
|
|
19
|
+
include Retries
|
|
20
|
+
include BodyEncoder
|
|
21
|
+
|
|
13
22
|
private
|
|
14
23
|
|
|
15
|
-
def request_with_body(http_class, path, **opts)
|
|
24
|
+
def request_with_body(http_class, path, **opts, &)
|
|
16
25
|
headers = opts.fetch(:headers, {})
|
|
17
26
|
uri = build_uri(path)
|
|
18
27
|
request = http_class.new(uri)
|
|
19
|
-
|
|
20
|
-
execute(uri, request, headers, timeout: opts[:timeout]
|
|
28
|
+
apply_body(request, opts, headers)
|
|
29
|
+
execute(uri, request, headers, timeout: opts[:timeout], open_timeout: opts[:open_timeout],
|
|
30
|
+
read_timeout: opts[:read_timeout], write_timeout: opts[:write_timeout],
|
|
31
|
+
expect: opts[:expect], &)
|
|
21
32
|
end
|
|
22
33
|
|
|
23
34
|
def build_uri(path, params = {})
|
|
@@ -30,87 +41,79 @@ module Philiprehberger
|
|
|
30
41
|
uri
|
|
31
42
|
end
|
|
32
43
|
|
|
33
|
-
def set_body(request, body, json_body, form_body, headers)
|
|
34
|
-
if json_body
|
|
35
|
-
request.body = JSON.generate(json_body)
|
|
36
|
-
headers["content-type"] ||= "application/json"
|
|
37
|
-
elsif form_body
|
|
38
|
-
request.body = URI.encode_www_form(form_body)
|
|
39
|
-
headers["content-type"] ||= "application/x-www-form-urlencoded"
|
|
40
|
-
elsif body
|
|
41
|
-
request.body = body
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
44
|
def apply_headers(request, extra_headers)
|
|
46
45
|
merged = @default_headers.merge(extra_headers)
|
|
47
46
|
merged.each { |key, value| request[key] = value }
|
|
48
47
|
end
|
|
49
48
|
|
|
50
|
-
def execute(uri, request, extra_headers,
|
|
49
|
+
def execute(uri, request, extra_headers, expect: nil, **timeout_opts, &block)
|
|
51
50
|
apply_headers(request, extra_headers)
|
|
52
51
|
@request_count += 1
|
|
52
|
+
run_execute_pipeline(uri, request, expect, **timeout_opts, &block)
|
|
53
|
+
end
|
|
53
54
|
|
|
55
|
+
def run_execute_pipeline(uri, request, expect, **timeout_opts, &)
|
|
54
56
|
context = { request: { uri: uri, method: request.method, headers: request.to_hash } }
|
|
55
57
|
run_interceptors(context)
|
|
56
|
-
|
|
57
|
-
response = perform_with_retries(uri, request, timeout: timeout)
|
|
58
|
+
response = perform_with_retries(uri, request, **timeout_opts, &)
|
|
58
59
|
context[:response] = response
|
|
59
60
|
run_interceptors(context)
|
|
60
|
-
|
|
61
|
+
validate_response!(response, expect) if expect
|
|
61
62
|
response
|
|
62
63
|
end
|
|
63
64
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
loop do
|
|
67
|
-
response = perform_request(uri, request, timeout: timeout)
|
|
68
|
-
return response unless retry_on_status?(response.status, attempts)
|
|
69
|
-
|
|
70
|
-
wait_and_retry(attempts += 1)
|
|
71
|
-
rescue *RETRYABLE_ERRORS => e
|
|
72
|
-
raise e unless (attempts += 1) <= @retries
|
|
65
|
+
def perform_request(uri, request, **timeout_opts, &block)
|
|
66
|
+
http = build_http(uri, **timeout_opts)
|
|
73
67
|
|
|
74
|
-
|
|
68
|
+
if block
|
|
69
|
+
perform_streaming_request(http, request, &block)
|
|
70
|
+
else
|
|
71
|
+
raw = http.request(request)
|
|
72
|
+
build_response(raw)
|
|
75
73
|
end
|
|
76
74
|
end
|
|
77
75
|
|
|
78
|
-
def
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def wait_and_retry(attempt)
|
|
83
|
-
sleep(retry_delay_for(attempt))
|
|
84
|
-
end
|
|
76
|
+
def perform_streaming_request(http, request, &block)
|
|
77
|
+
response_headers = {}
|
|
78
|
+
status = nil
|
|
85
79
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
80
|
+
http.request(request) do |raw|
|
|
81
|
+
status = raw.code.to_i
|
|
82
|
+
raw.each_header { |k, v| response_headers[k] = v }
|
|
83
|
+
raw.read_body(&block)
|
|
84
|
+
end
|
|
89
85
|
|
|
90
|
-
|
|
91
|
-
http = build_http(uri, timeout: timeout)
|
|
92
|
-
raw = http.request(request)
|
|
93
|
-
build_response(raw)
|
|
86
|
+
Response.new(status: status, body: nil, headers: response_headers, streaming: true)
|
|
94
87
|
end
|
|
95
88
|
|
|
96
|
-
def build_http(uri,
|
|
97
|
-
effective_timeout = timeout || @timeout
|
|
89
|
+
def build_http(uri, **timeout_opts)
|
|
98
90
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
99
91
|
http.use_ssl = uri.scheme == "https"
|
|
100
|
-
http
|
|
101
|
-
http.read_timeout = effective_timeout
|
|
92
|
+
apply_timeouts(http, timeout_opts)
|
|
102
93
|
http
|
|
103
94
|
end
|
|
104
95
|
|
|
96
|
+
def apply_timeouts(http, timeout_opts)
|
|
97
|
+
effective = timeout_opts[:timeout] || @timeout
|
|
98
|
+
http.open_timeout = resolve_timeout(:open_timeout, timeout_opts, effective)
|
|
99
|
+
http.read_timeout = resolve_timeout(:read_timeout, timeout_opts, effective)
|
|
100
|
+
http.write_timeout = resolve_timeout(:write_timeout, timeout_opts, effective)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolve_timeout(key, timeout_opts, fallback)
|
|
104
|
+
timeout_opts[key] || instance_variable_get(:"@#{key}") || fallback
|
|
105
|
+
end
|
|
106
|
+
|
|
105
107
|
def build_response(raw)
|
|
106
108
|
response_headers = {}
|
|
107
109
|
raw.each_header { |k, v| response_headers[k] = v }
|
|
110
|
+
Response.new(status: raw.code.to_i, body: raw.body || "", headers: response_headers)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_response!(response, expected_statuses)
|
|
114
|
+
return if expected_statuses.include?(response.status)
|
|
108
115
|
|
|
109
|
-
|
|
110
|
-
status: raw.code.to_i,
|
|
111
|
-
body: raw.body || "",
|
|
112
|
-
headers: response_headers
|
|
113
|
-
)
|
|
116
|
+
raise HttpError, response
|
|
114
117
|
end
|
|
115
118
|
|
|
116
119
|
def run_interceptors(context)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module HttpClient
|
|
5
|
+
# Base error class for all HTTP client errors.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when a connection or read timeout occurs.
|
|
9
|
+
class TimeoutError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when a network-level error occurs (connection refused, reset, etc.).
|
|
12
|
+
class NetworkError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised when a response status does not match expected values.
|
|
15
|
+
class HttpError < Error
|
|
16
|
+
attr_reader :response
|
|
17
|
+
|
|
18
|
+
# @param response [Response] the HTTP response that triggered the error
|
|
19
|
+
def initialize(response)
|
|
20
|
+
@response = response
|
|
21
|
+
super("HTTP #{response.status}: #{response.body.to_s[0..200]}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Philiprehberger
|
|
6
|
+
module HttpClient
|
|
7
|
+
# Builds multipart/form-data request bodies from a hash of fields.
|
|
8
|
+
# Supports both string values and File/IO objects.
|
|
9
|
+
module Multipart
|
|
10
|
+
CRLF = "\r\n"
|
|
11
|
+
|
|
12
|
+
# Build a multipart/form-data body and content-type header.
|
|
13
|
+
#
|
|
14
|
+
# @param fields [Hash] field name => value pairs (String or File/IO)
|
|
15
|
+
# @return [Array(String, String)] the body string and content-type header value
|
|
16
|
+
def self.build(fields)
|
|
17
|
+
boundary = generate_boundary
|
|
18
|
+
body = build_body(fields, boundary)
|
|
19
|
+
content_type = "multipart/form-data; boundary=#{boundary}"
|
|
20
|
+
[body, content_type]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @api private
|
|
24
|
+
def self.generate_boundary
|
|
25
|
+
"----RubyFormBoundary#{SecureRandom.hex(16)}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @api private
|
|
29
|
+
def self.build_body(fields, boundary)
|
|
30
|
+
parts = fields.map { |name, value| build_part(name, value, boundary) }
|
|
31
|
+
parts.join + "--#{boundary}--#{CRLF}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @api private
|
|
35
|
+
def self.build_part(name, value, boundary)
|
|
36
|
+
if value.respond_to?(:read)
|
|
37
|
+
build_file_part(name, value, boundary)
|
|
38
|
+
else
|
|
39
|
+
build_field_part(name, value, boundary)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @api private
|
|
44
|
+
def self.build_file_part(name, file, boundary)
|
|
45
|
+
filename = file.respond_to?(:path) ? File.basename(file.path) : "upload"
|
|
46
|
+
content = read_file_content(file)
|
|
47
|
+
disposition = "Content-Disposition: form-data; name=\"#{name}\"; filename=\"#{filename}\"#{CRLF}"
|
|
48
|
+
"--#{boundary}#{CRLF}#{disposition}Content-Type: application/octet-stream#{CRLF}#{CRLF}#{content}#{CRLF}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.read_file_content(file)
|
|
52
|
+
content = file.read
|
|
53
|
+
file.rewind if file.respond_to?(:rewind)
|
|
54
|
+
content
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @api private
|
|
58
|
+
def self.build_field_part(name, value, boundary)
|
|
59
|
+
"".dup.tap do |part|
|
|
60
|
+
part << "--#{boundary}#{CRLF}"
|
|
61
|
+
part << "Content-Disposition: form-data; name=\"#{name}\"#{CRLF}"
|
|
62
|
+
part << CRLF
|
|
63
|
+
part << value.to_s
|
|
64
|
+
part << CRLF
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -8,12 +8,14 @@ module Philiprehberger
|
|
|
8
8
|
attr_reader :status, :body, :headers
|
|
9
9
|
|
|
10
10
|
# @param status [Integer] HTTP status code
|
|
11
|
-
# @param body [String] Response body
|
|
11
|
+
# @param body [String, nil] Response body
|
|
12
12
|
# @param headers [Hash] Response headers
|
|
13
|
-
|
|
13
|
+
# @param streaming [Boolean] Whether the response was streamed
|
|
14
|
+
def initialize(status:, body:, headers: {}, streaming: false)
|
|
14
15
|
@status = status
|
|
15
16
|
@body = body
|
|
16
17
|
@headers = headers
|
|
18
|
+
@streaming = streaming
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
# Returns true if the status code is in the 2xx range.
|
|
@@ -23,6 +25,13 @@ module Philiprehberger
|
|
|
23
25
|
status >= 200 && status < 300
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# Returns true if the response was streamed.
|
|
29
|
+
#
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def streaming?
|
|
32
|
+
@streaming
|
|
33
|
+
end
|
|
34
|
+
|
|
26
35
|
# Parses the response body as JSON.
|
|
27
36
|
#
|
|
28
37
|
# @return [Hash, Array] Parsed JSON
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module HttpClient
|
|
5
|
+
# Retry logic extracted from Connection to keep module length manageable.
|
|
6
|
+
module Retries
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def perform_with_retries(uri, request, **timeout_opts, &block)
|
|
10
|
+
attempts = 0
|
|
11
|
+
loop do
|
|
12
|
+
response = perform_request(uri, request, **timeout_opts, &block)
|
|
13
|
+
return response unless retry_on_status?(response.status, attempts)
|
|
14
|
+
|
|
15
|
+
wait_and_retry(attempts += 1)
|
|
16
|
+
rescue *Connection::TIMEOUT_ERRORS => e
|
|
17
|
+
handle_retry_error(attempts += 1, TimeoutError, e.message)
|
|
18
|
+
rescue *Connection::NETWORK_ERRORS => e
|
|
19
|
+
handle_retry_error(attempts += 1, NetworkError, e.message)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def handle_retry_error(attempts, error_class, message)
|
|
24
|
+
raise error_class, message unless attempts <= @retries
|
|
25
|
+
|
|
26
|
+
sleep(retry_delay_for(attempts))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def retry_on_status?(status, attempts)
|
|
30
|
+
@retry_on_status&.include?(status) && attempts < @retries
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def wait_and_retry(attempt)
|
|
34
|
+
sleep(retry_delay_for(attempt))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def retry_delay_for(attempt)
|
|
38
|
+
@retry_backoff == :exponential ? @retry_delay * (2**(attempt - 1)) : @retry_delay
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "http_client/version"
|
|
4
|
+
require_relative "http_client/errors"
|
|
4
5
|
require_relative "http_client/response"
|
|
6
|
+
require_relative "http_client/multipart"
|
|
7
|
+
require_relative "http_client/retries"
|
|
8
|
+
require_relative "http_client/body_encoder"
|
|
5
9
|
require_relative "http_client/connection"
|
|
6
10
|
require_relative "http_client/client"
|
|
7
11
|
|
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.4.1
|
|
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-03-
|
|
11
|
+
date: 2026-03-19 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.
|
|
@@ -22,9 +22,13 @@ files:
|
|
|
22
22
|
- LICENSE
|
|
23
23
|
- README.md
|
|
24
24
|
- lib/philiprehberger/http_client.rb
|
|
25
|
+
- lib/philiprehberger/http_client/body_encoder.rb
|
|
25
26
|
- lib/philiprehberger/http_client/client.rb
|
|
26
27
|
- lib/philiprehberger/http_client/connection.rb
|
|
28
|
+
- lib/philiprehberger/http_client/errors.rb
|
|
29
|
+
- lib/philiprehberger/http_client/multipart.rb
|
|
27
30
|
- lib/philiprehberger/http_client/response.rb
|
|
31
|
+
- lib/philiprehberger/http_client/retries.rb
|
|
28
32
|
- lib/philiprehberger/http_client/version.rb
|
|
29
33
|
homepage: https://github.com/philiprehberger/rb-http-client
|
|
30
34
|
licenses:
|
|
@@ -33,6 +37,7 @@ metadata:
|
|
|
33
37
|
homepage_uri: https://github.com/philiprehberger/rb-http-client
|
|
34
38
|
source_code_uri: https://github.com/philiprehberger/rb-http-client
|
|
35
39
|
changelog_uri: https://github.com/philiprehberger/rb-http-client/blob/main/CHANGELOG.md
|
|
40
|
+
bug_tracker_uri: https://github.com/philiprehberger/rb-http-client/issues
|
|
36
41
|
rubygems_mfa_required: 'true'
|
|
37
42
|
post_install_message:
|
|
38
43
|
rdoc_options: []
|
|
@@ -42,7 +47,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
42
47
|
requirements:
|
|
43
48
|
- - ">="
|
|
44
49
|
- !ruby/object:Gem::Version
|
|
45
|
-
version:
|
|
50
|
+
version: 3.1.0
|
|
46
51
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
47
52
|
requirements:
|
|
48
53
|
- - ">="
|