philiprehberger-http_client 0.3.1 → 0.4.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: dc47cd54c2873fe7bf5dacad5d62bb028b4ccd9b36e028182e7700cbfeb589de
4
- data.tar.gz: 26739ada42f419275cada8b35136f0b35fe1c4c2744067466f5e20fc851c4a58
3
+ metadata.gz: 94b2fd66c1f84f7184ca9767b09e05a4f9872106bec895519d409142d798a552
4
+ data.tar.gz: 460680dc0188632f403f45261ed505dc095d45cbaceaba50a0301abd721788d1
5
5
  SHA512:
6
- metadata.gz: d4a8181bf4925a8314d54cbe34db75dd19a79af46d5c7f432bb33172df6c89d1c6b6e0bede1cdbdada2613f8291c1c15a7dee6559ddbf73d432437891ae67531
7
- data.tar.gz: 48a8ca6c36e77ca87950da44209d7e39eecfe23ee7ed7d9bebf6c63f717c849fb021e69a7aada4a81d52996be7a206c5bff5c53099283abf9cb467f1709907bb
6
+ metadata.gz: 3e3add0cf7a23e8e852224ab9428469e7d603166307e9aa4702c85e15df3c8fadf5a0e756d558f2bb89b3c606b4eba63e26d26bd3063a7c665fd15587cf12316
7
+ data.tar.gz: 197ef15006f59be6093cfcb982bd507784c128c47125415f19dc86d63e35570a346bb3bfbb5c014a270d6921724e1603ba3d5f9ddf6dc19ae93f428ee1042eeb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Added
6
+
7
+ - Custom error hierarchy: `Error`, `TimeoutError`, `NetworkError`, `HttpError` for structured error handling
8
+ - Streaming responses via block parameter — yield body chunks instead of buffering entire response
9
+ - Multipart form data support via `multipart:` parameter for file uploads
10
+ - Per-phase timeouts: `open_timeout:`, `read_timeout:`, `write_timeout:` on constructor and per-request
11
+ - Response validation via `expect:` option — auto-raises `HttpError` if status not in expected list
12
+
13
+ ### Changed
14
+
15
+ - Network errors (`Errno::ECONNREFUSED`, `Errno::ECONNRESET`, etc.) now raise `NetworkError` instead of raw system errors
16
+ - Timeout errors (`Net::OpenTimeout`, `Net::ReadTimeout`) now raise `TimeoutError` instead of raw Net errors
17
+
18
+ ## 0.3.3
19
+
20
+ - Fix CI: version test and rubocop compliance
21
+
22
+ ## 0.3.2
23
+
24
+ - Add License badge to README
25
+ - Add bug_tracker_uri to gemspec
26
+ - Add Development section to README
27
+ - Add Requirements section to README
28
+
3
29
  All notable changes to this project will be documented in this file.
4
30
 
5
31
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
data/README.md CHANGED
@@ -2,9 +2,14 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-http_client.svg)](https://badge.fury.io/rb/philiprehberger-http_client)
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
+ [![License](https://img.shields.io/github/license/philiprehberger/rb-http-client)](LICENSE)
5
6
 
6
7
  Lightweight HTTP client wrapper with retries and interceptors. Zero dependencies — built on Ruby's stdlib `net/http`.
7
8
 
9
+ ## Requirements
10
+
11
+ - Ruby >= 3.1
12
+
8
13
  ## Installation
9
14
 
10
15
  Add to your Gemfile:
@@ -83,6 +88,41 @@ response = client.post("/login", form: { username: "alice", password: "secret" }
83
88
  # Content-Type: application/x-www-form-urlencoded is set automatically
84
89
  ```
85
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
+
86
126
  ### Authentication helpers
87
127
 
88
128
  ```ruby
@@ -95,6 +135,61 @@ client.basic_auth("username", "password")
95
135
  client.get("/protected") # Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
96
136
  ```
97
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
+
98
193
  ### Retries
99
194
 
100
195
  Automatically retry on network errors (connection refused, timeouts, etc.):
@@ -134,12 +229,6 @@ client = Philiprehberger::HttpClient.new(
134
229
  # Delay sequence: 1s, 2s, 4s
135
230
  ```
136
231
 
137
- ### Per-request timeout
138
-
139
- ```ruby
140
- response = client.get("/slow-endpoint", timeout: 60)
141
- ```
142
-
143
232
  ### All HTTP methods
144
233
 
145
234
  ```ruby
@@ -159,7 +248,10 @@ client.head("/resource")
159
248
  |---------------|---------|---------|--------------------------------------|
160
249
  | `base_url` | String | — | Base URL for all requests (required) |
161
250
  | `headers` | Hash | `{}` | Default headers for every request |
162
- | `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`) |
163
255
  | `retries` | Integer | `0` | Retry attempts on network errors |
164
256
  | `retry_delay` | Numeric | `1` | Seconds between retries |
165
257
  | `retry_backoff` | Symbol | `:fixed` | Backoff strategy — `:fixed` or `:exponential` |
@@ -169,25 +261,60 @@ client.head("/resource")
169
261
 
170
262
  | Method | Description |
171
263
  |--------|-------------|
172
- | `get(path, **opts)` | Send GET request |
173
- | `post(path, **opts)` | Send POST request |
174
- | `put(path, **opts)` | Send PUT request |
175
- | `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 |
176
268
  | `delete(path, **opts)` | Send DELETE request |
177
269
  | `head(path, **opts)` | Send HEAD request |
178
270
  | `request_count` | Total number of requests made by this client |
179
271
  | `bearer_token(token)` | Set Bearer token auth for all subsequent requests |
180
272
  | `basic_auth(user, pass)` | Set Basic auth for all subsequent requests |
181
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
+
182
290
  ### `Response`
183
291
 
184
292
  | Method | Returns | Description |
185
293
  |-----------|---------|---------------------------------|
186
294
  | `status` | Integer | HTTP status code |
187
- | `body` | String | Raw response body |
295
+ | `body` | String | Raw response body (`nil` if streamed) |
188
296
  | `headers` | Hash | Response headers |
189
297
  | `ok?` | Boolean | `true` if status is 200-299 |
190
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) |
309
+
310
+
311
+ ## Development
312
+
313
+ ```bash
314
+ bundle install
315
+ bundle exec rspec
316
+ bundle exec rubocop
317
+ ```
191
318
 
192
319
  ## License
193
320
 
@@ -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.1"
5
+ VERSION = "0.4.0"
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.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-13 00:00:00.000000000 Z
11
+ date: 2026-03-17 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: