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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f90f0c61352c51eb415419155b236effd46e0cccb98b5b5fd1c71fc2b9bcd34d
4
- data.tar.gz: d28746e0a72c05317e64f688b74e9c1d6f5e7ed479588a1a39a4e617a3f79f2d
3
+ metadata.gz: 97333340c609f0644e26f98a211046453d8a9a2a25d481ed7e32bdc692e59c34
4
+ data.tar.gz: 6de9a47bb6e9d51cce945257c8dfd2c7ab8dc9b4d6d06e98ca2b88fac0ea3a88
5
5
  SHA512:
6
- metadata.gz: aed704d45a0088aaf546a42379ebe72a3463a0bd363a4f42c41ab6e560a02ac5ee82b5b347a4994e61e968c71e81ff076a95eb422c2575bc370a62d13451da5a
7
- data.tar.gz: d8f06fec4769f736d327e9003160f8cb952a4d0edd493c4dc3de6eabdb2b7ddb63cfd1701feff47535620570dff4aab8911104fd435d3bf050ff1f7095627681
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
  [![CI](https://github.com/philiprehberger/rb-http-client/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-http-client/actions/workflows/ci.yml)
5
5
  [![License](https://img.shields.io/github/license/philiprehberger/rb-http-client)](LICENSE)
6
6
 
7
- Lightweight HTTP client wrapper with retries and interceptors. Zero dependencies — built on Ruby's stdlib `net/http`.
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
- ```sh
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` | Read/open timeout in seconds |
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
- [MIT](LICENSE)
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] Read/open timeout in seconds
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, **retry_options)
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
- @retries = retry_options.fetch(:retries, 0)
24
- @retry_delay = retry_options.fetch(:retry_delay, 1)
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: {}, timeout: nil)
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, timeout: timeout)
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: {}, timeout: nil)
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, timeout: timeout)
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, **opts)
84
- request_with_body(Net::HTTP::Post, path, **opts)
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, **opts)
96
- request_with_body(Net::HTTP::Put, path, **opts)
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, **opts)
108
- request_with_body(Net::HTTP::Patch, path, **opts)
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: {}, timeout: nil)
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, timeout: timeout)
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
- RETRYABLE_ERRORS = [
8
+ TIMEOUT_ERRORS = [
9
+ Net::OpenTimeout, Net::ReadTimeout
10
+ ].freeze
11
+
12
+ NETWORK_ERRORS = [
9
13
  Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
10
- Net::OpenTimeout, Net::ReadTimeout, SocketError
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
- set_body(request, opts[:body], opts[:json], opts[:form], headers)
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, timeout: nil)
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 perform_with_retries(uri, request, timeout: nil)
65
- attempts = 0
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
- sleep(retry_delay_for(attempts))
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 retry_on_status?(status, attempts)
79
- @retry_on_status&.include?(status) && attempts < @retries
80
- end
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
- def retry_delay_for(attempt)
87
- @retry_backoff == :exponential ? @retry_delay * (2**(attempt - 1)) : @retry_delay
88
- end
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
- def perform_request(uri, request, timeout: nil)
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, timeout: nil)
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.open_timeout = effective_timeout
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
- Response.new(
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
- def initialize(status:, body:, headers: {})
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module HttpClient
5
- VERSION = "0.3.3"
5
+ VERSION = "0.4.1"
6
6
  end
7
7
  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.3.3
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-16 00:00:00.000000000 Z
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: '3.1'
50
+ version: 3.1.0
46
51
  required_rubygems_version: !ruby/object:Gem::Requirement
47
52
  requirements:
48
53
  - - ">="