net-http-ext 0.0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +5 -0
- data/lib/net/http/ext.rb +471 -0
- data/lib/net/http/version.rb +5 -0
- data/net-http-ext.gemspec +36 -0
- metadata +138 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: b26da458fd1f2d62e0fc16a68223f53615f93ecdada0ef979ce8af507149c246
|
4
|
+
data.tar.gz: 1ce8f34c41d3cc5e58490915ab6118c24139e0140f5a227b8d7f86df4bc59c02
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 45dd4238afd0639c6ca112eac1623f6629662ac693c92ac76d4a107105b61159e2ed0e667c17fbfb2ccc401fe6d7b580bf4ae7c2571a594c7c21566eaca48c1e
|
7
|
+
data.tar.gz: 0b522d8f0d0b1efaa7bcd2ef2dbec07982beb40c4193a51f7256158c4c5107c30402fab90b0332a57ef2844725f458a9cc3374eb30fbc29f33fc4b9cb479cb84
|
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Grant Birkinbine
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
data/lib/net/http/ext.rb
ADDED
@@ -0,0 +1,471 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Example usage:
|
4
|
+
#
|
5
|
+
# # Basic usage
|
6
|
+
# client = Net::HTTP::Ext.new("https://api.example.com")
|
7
|
+
# response = client.get("/users")
|
8
|
+
# puts response.body
|
9
|
+
#
|
10
|
+
# # With headers and query parameters
|
11
|
+
# response = client.get("/users",
|
12
|
+
# headers: {"Authorization" => "Bearer token123"},
|
13
|
+
# params: {status: "active", limit: 10})
|
14
|
+
#
|
15
|
+
# # POST JSON data
|
16
|
+
# response = client.post("/users",
|
17
|
+
# headers: {"X-Custom-Header" => "value"},
|
18
|
+
# params: {name: "John Doe", email: "john@example.com"})
|
19
|
+
#
|
20
|
+
# # With custom timeouts
|
21
|
+
# client = Net::HTTP::Ext.new("https://api.example.com",
|
22
|
+
# open_timeout: 5, # connection establishment timeout (seconds)
|
23
|
+
# read_timeout: 10, # response read timeout (seconds)
|
24
|
+
# idle_timeout: 30, # how long to keep idle connections open (seconds)
|
25
|
+
# request_timeout: 15 # overall request timeout (seconds)
|
26
|
+
# )
|
27
|
+
#
|
28
|
+
# # With default headers (applied to all requests)
|
29
|
+
# client = Net::HTTP::Ext.new("https://api.example.com",
|
30
|
+
# default_headers: {
|
31
|
+
# "User-Agent" => "MyApp/1.0",
|
32
|
+
# "Authorization" => "Bearer default-token"
|
33
|
+
# }
|
34
|
+
# )
|
35
|
+
|
36
|
+
# Benefits:
|
37
|
+
#
|
38
|
+
# 1. Reuse connections for multiple requests
|
39
|
+
# 2. Automatically rebuild the connection if it is closed by the server
|
40
|
+
# 3. Automatically retry requests on connection failures if max_retries is set to a value >1
|
41
|
+
# 4. Easy to use and configure
|
42
|
+
# 5. Supports timeouts for the entire request (open_timeout + read_timeout) and for idle connections (idle_timeout)
|
43
|
+
|
44
|
+
require "logger"
|
45
|
+
require "net/http/persistent"
|
46
|
+
require "timeout"
|
47
|
+
require "uri"
|
48
|
+
require "json"
|
49
|
+
require_relative "version"
|
50
|
+
|
51
|
+
class Net::HTTP::Ext
|
52
|
+
include NetHTTPExt
|
53
|
+
|
54
|
+
VERB_MAP = {
|
55
|
+
head: Net::HTTP::Head,
|
56
|
+
get: Net::HTTP::Get,
|
57
|
+
post: Net::HTTP::Post,
|
58
|
+
put: Net::HTTP::Put,
|
59
|
+
delete: Net::HTTP::Delete,
|
60
|
+
patch: Net::HTTP::Patch
|
61
|
+
}.freeze
|
62
|
+
|
63
|
+
# Expose the HTTP client so that we can customize client-level settings
|
64
|
+
attr_accessor :http, :default_headers
|
65
|
+
|
66
|
+
# Create a new persistent HTTP client
|
67
|
+
#
|
68
|
+
# @param endpoint [String] Endpoint URL to send requests to
|
69
|
+
# @param name [String] Name for the client (used in logs)
|
70
|
+
# @param log [Logger] Custom logger instance (optional)
|
71
|
+
# @param default_headers [Hash] Default headers to include in all requests
|
72
|
+
# @param request_timeout [Integer, nil] Overall timeout for the entire request (nil for no timeout)
|
73
|
+
# @param max_retries [Integer] Maximum number of retries on connection failures
|
74
|
+
# @param open_timeout [Integer] Timeout in seconds for connection establishment
|
75
|
+
# @param read_timeout [Integer] Timeout in seconds for reading response
|
76
|
+
# @param idle_timeout [Integer] How long to keep idle connections open in seconds (maps to keep_alive)
|
77
|
+
# @param ssl_cert_file [String] Path to a custom CA certificate file (optional)
|
78
|
+
# @param **options Additional options passed directly to Net::HTTP::Persistent
|
79
|
+
# Example:
|
80
|
+
# client = Net::HTTP::Ext.new("https://api.example.com", proxy: URI("http://proxy.example.com:8080"))
|
81
|
+
def initialize(
|
82
|
+
endpoint,
|
83
|
+
name: nil,
|
84
|
+
log: nil,
|
85
|
+
default_headers: { "user-agent" => "Net::HTTP::Ext/#{VERSION}" },
|
86
|
+
request_timeout: 30,
|
87
|
+
max_retries: 1,
|
88
|
+
# Default timeouts
|
89
|
+
# https://github.com/ruby/net-http/blob/1df862896825af04f7bf9711b9b4613bbb77cad6/lib/net/http.rb#L1152-L1154
|
90
|
+
open_timeout: nil, # generally 60
|
91
|
+
read_timeout: nil, # generally 60
|
92
|
+
idle_timeout: 5, # set specifically for keep_alive with Net::HTTP::Persistent - if a connection is idle for this long, it will be closed and automatically reopened on the next request
|
93
|
+
# Pass through any other options to Net::HTTP::Persistent
|
94
|
+
ssl_cert_file: nil,
|
95
|
+
**options
|
96
|
+
)
|
97
|
+
@uri = URI.parse(endpoint)
|
98
|
+
@name = name || ENV.fetch("HTTP_CLIENT_NAME", "http-client")
|
99
|
+
@request_timeout = request_timeout
|
100
|
+
@max_retries = max_retries
|
101
|
+
@default_headers = normalize_headers(default_headers)
|
102
|
+
@log = log || create_logger
|
103
|
+
@ssl_cert_file = ssl_cert_file || ENV.fetch("SSL_CERT_FILE", nil)
|
104
|
+
|
105
|
+
# Create options hash for Net::HTTP::Persistent
|
106
|
+
persistent_options = {
|
107
|
+
name: @name,
|
108
|
+
open_timeout: open_timeout,
|
109
|
+
read_timeout: read_timeout,
|
110
|
+
idle_timeout: idle_timeout
|
111
|
+
}
|
112
|
+
|
113
|
+
# Merge any additional options passed through
|
114
|
+
persistent_options.merge!(options)
|
115
|
+
|
116
|
+
@http = create_http_client(persistent_options)
|
117
|
+
end
|
118
|
+
|
119
|
+
# @param path [String] The path to request
|
120
|
+
# @param headers [Hash] Additional headers for this request
|
121
|
+
# @param params [Hash] Parameters to send as query parameters
|
122
|
+
# @return [Net::HTTPResponse] The HTTP response
|
123
|
+
# @example Make a HEAD request
|
124
|
+
# client = Net::HTTP::Ext.new("https://api.example.com")
|
125
|
+
# response = client.head("/users")
|
126
|
+
def head(path, headers: {}, params: {})
|
127
|
+
request(:head, path, headers: headers, body: params)
|
128
|
+
end
|
129
|
+
|
130
|
+
# @param path [String] The path to request
|
131
|
+
# @param headers [Hash] Additional headers for this request
|
132
|
+
# @param params [Hash, String] Parameters to send with the request
|
133
|
+
# @return [Net::HTTPResponse] The HTTP response
|
134
|
+
# @example Make a simple GET request
|
135
|
+
# client = Net::HTTP::Ext.new("https://api.example.com")
|
136
|
+
# response = client.get("/users")
|
137
|
+
def get(path, headers: {}, params: {})
|
138
|
+
request(:get, path, headers: headers, body: params)
|
139
|
+
end
|
140
|
+
|
141
|
+
# @param path [String] The path to request
|
142
|
+
# @param headers [Hash] Additional headers for this request
|
143
|
+
# @param payload [Hash, String] Parameters to send as request body
|
144
|
+
# @param params [Hash] Parameters to send as query parameters (deprecated - use payload instead)
|
145
|
+
# @return [Net::HTTPResponse] The HTTP response
|
146
|
+
# @example Create a new resource
|
147
|
+
# client = Net::HTTP::Ext.new("https://api.example.com")
|
148
|
+
# response = client.post("/users", payload: {name: "John", email: "john@example.com"})
|
149
|
+
def post(path, headers: {}, params: nil, payload: nil)
|
150
|
+
request(:post, path, headers: headers, body: payload || params)
|
151
|
+
end
|
152
|
+
|
153
|
+
# @param path [String] The path to request
|
154
|
+
# @param headers [Hash] Additional headers for this request
|
155
|
+
# @param payload [Hash, String] Parameters to send as request body
|
156
|
+
# @param params [Hash] Parameters to send as query parameters (deprecated - use payload instead)
|
157
|
+
# @return [Net::HTTPResponse] The HTTP response
|
158
|
+
# @example Update a resource
|
159
|
+
# client = Net::HTTP::Ext.new("https://api.example.com")
|
160
|
+
# response = client.put("/users/123", payload: {name: "John Updated"})
|
161
|
+
def put(path, headers: {}, params: nil, payload: nil)
|
162
|
+
request(:put, path, headers: headers, body: payload || params)
|
163
|
+
end
|
164
|
+
|
165
|
+
# @param path [String] The path to request
|
166
|
+
# @param headers [Hash] Additional headers for this request
|
167
|
+
# @param payload [Hash, String] Parameters to send as request body
|
168
|
+
# @param params [Hash] Parameters to send as query parameters (deprecated - use payload instead)
|
169
|
+
# @return [Net::HTTPResponse] The HTTP response
|
170
|
+
# @example Delete a resource
|
171
|
+
# client = Net::HTTP::Ext.new("https://api.example.com")
|
172
|
+
# response = client.delete("/users/123")
|
173
|
+
# response = client.delete("/users/123", payload: {confirm: true})
|
174
|
+
def delete(path, headers: {}, params: nil, payload: nil)
|
175
|
+
request(:delete, path, headers: headers, body: payload || params)
|
176
|
+
end
|
177
|
+
|
178
|
+
# @param path [String] The path to request
|
179
|
+
# @param headers [Hash] Additional headers for this request
|
180
|
+
# @param payload [Hash, String] Parameters to send as request body
|
181
|
+
# @param params [Hash] Parameters to send as query parameters (deprecated - use payload instead)
|
182
|
+
# @return [Net::HTTPResponse] The HTTP response
|
183
|
+
# @example Partially update a resource
|
184
|
+
# client = Net::HTTP::Ext.new("https://api.example.com")
|
185
|
+
# response = client.patch("/users/123", payload: {status: "inactive"})
|
186
|
+
def patch(path, headers: {}, params: nil, payload: nil)
|
187
|
+
request(:patch, path, headers: headers, body: payload || params)
|
188
|
+
end
|
189
|
+
|
190
|
+
# @param path [String] The path to request
|
191
|
+
# @param headers [Hash] Additional headers for this request
|
192
|
+
# @param params [Hash, String] Parameters to send with the request
|
193
|
+
# @return [Net::HTTPResponse] The HTTP response
|
194
|
+
# @example Make a simple GET request and automatically parse the JSON response
|
195
|
+
# client = Net::HTTP::Ext.new("https://api.example.com")
|
196
|
+
# response = client.get_json("/users")
|
197
|
+
def get_json(path, headers: {}, params: {})
|
198
|
+
response = get(path, headers: headers, params: params)
|
199
|
+
JSON.parse(response.body)
|
200
|
+
end
|
201
|
+
|
202
|
+
# Set or update default headers
|
203
|
+
#
|
204
|
+
# @param headers [Hash] Headers to set as default
|
205
|
+
def set_default_headers(headers)
|
206
|
+
@default_headers = normalize_headers(headers)
|
207
|
+
end
|
208
|
+
|
209
|
+
# Method to explicitly close all persistent connections
|
210
|
+
def close!
|
211
|
+
@http.shutdown
|
212
|
+
end
|
213
|
+
|
214
|
+
private
|
215
|
+
|
216
|
+
def create_logger
|
217
|
+
Logger.new($stdout, level: ENV.fetch("LOG_LEVEL", "INFO").upcase)
|
218
|
+
end
|
219
|
+
|
220
|
+
# Create a persistent HTTP client with configured timeouts and SSL settings
|
221
|
+
def create_http_client(options)
|
222
|
+
# Extract only the parameters accepted by Net::HTTP::Persistent.new
|
223
|
+
constructor_options = {}
|
224
|
+
constructor_options[:name] = options.delete(:name) if options.key?(:name)
|
225
|
+
constructor_options[:proxy] = options.delete(:proxy) if options.key?(:proxy)
|
226
|
+
constructor_options[:pool_size] = options.delete(:pool_size) if options.key?(:pool_size)
|
227
|
+
|
228
|
+
# Create the HTTP client with only the supported constructor options
|
229
|
+
http = Net::HTTP::Persistent.new(**constructor_options)
|
230
|
+
|
231
|
+
# Apply timeouts and other options as attributes after initialization
|
232
|
+
http.open_timeout = options[:open_timeout] if options.key?(:open_timeout)
|
233
|
+
http.read_timeout = options[:read_timeout] if options.key?(:read_timeout)
|
234
|
+
http.idle_timeout = options[:idle_timeout] if options.key?(:idle_timeout)
|
235
|
+
|
236
|
+
# Configure SSL if using HTTPS with safe defaults
|
237
|
+
configure_ssl(http, options)
|
238
|
+
|
239
|
+
# Apply any other options that might be supported as attributes
|
240
|
+
options.each do |key, value|
|
241
|
+
setter = "#{key}="
|
242
|
+
if http.respond_to?(setter)
|
243
|
+
http.send(setter, value) # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod
|
244
|
+
else
|
245
|
+
@log.debug("Ignoring unsupported option: #{key}")
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
http
|
250
|
+
end
|
251
|
+
|
252
|
+
# Normalize headers by converting keys to lowercase
|
253
|
+
#
|
254
|
+
# @param headers [Hash] Headers to normalize
|
255
|
+
# @return [Hash] Normalized headers with lowercase keys
|
256
|
+
def normalize_headers(headers)
|
257
|
+
return {} if headers.nil?
|
258
|
+
|
259
|
+
result = {}
|
260
|
+
headers.each do |key, value|
|
261
|
+
normalized_key = key.to_s.downcase
|
262
|
+
result[normalized_key] = value
|
263
|
+
end
|
264
|
+
result
|
265
|
+
end
|
266
|
+
|
267
|
+
# Build an HTTP request with proper headers and parameters
|
268
|
+
#
|
269
|
+
# @param method [Symbol] HTTP method (:get, :post, etc)
|
270
|
+
# @param path [String] Request path
|
271
|
+
# @param headers [Hash] Request headers
|
272
|
+
# @param params [Hash] Request parameters or body (optional)
|
273
|
+
# @return [Net::HTTP::Request] The prepared request object
|
274
|
+
def build_request(method, path, headers: {}, params: nil)
|
275
|
+
validate_querystring(path, params)
|
276
|
+
|
277
|
+
normalized_headers = prepare_headers(headers)
|
278
|
+
request = initialize_request(method, path, params, normalized_headers)
|
279
|
+
|
280
|
+
add_headers_to_request(request, normalized_headers)
|
281
|
+
request
|
282
|
+
end
|
283
|
+
|
284
|
+
def validate_querystring(path, params)
|
285
|
+
if path.include?("?") && params && !params.empty?
|
286
|
+
raise ArgumentError, "Querystring must be sent via `params` or `path` but not both."
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
def prepare_headers(headers)
|
291
|
+
normalized_headers = @default_headers.dup
|
292
|
+
normalize_headers(headers).each { |key, value| normalized_headers[key] = value }
|
293
|
+
validate_host_header(normalized_headers)
|
294
|
+
end
|
295
|
+
|
296
|
+
def initialize_request(method, path, params, headers)
|
297
|
+
case method
|
298
|
+
when :get, :head
|
299
|
+
full_path = encode_path_params(path, params)
|
300
|
+
VERB_MAP[method].new(full_path)
|
301
|
+
else
|
302
|
+
request = VERB_MAP[method].new(path)
|
303
|
+
set_request_body(request, params, headers)
|
304
|
+
request
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def set_request_body(request, params, headers)
|
309
|
+
# Early return for nil or empty params
|
310
|
+
return if params.nil? || (params.respond_to?(:empty?) && params.empty?)
|
311
|
+
|
312
|
+
# normalize headers to an empty hash if nil
|
313
|
+
headers = {} if headers.nil?
|
314
|
+
|
315
|
+
begin
|
316
|
+
# First handle the case where params is already a string
|
317
|
+
if params.is_a?(String)
|
318
|
+
# Use the string directly
|
319
|
+
request.body = params
|
320
|
+
# Set content-type if not present (use lowercase for consistency)
|
321
|
+
headers["content-type"] ||= "application/octet-stream"
|
322
|
+
else
|
323
|
+
# Get content type, normalize by downcasing and trimming
|
324
|
+
# First, find the content-type key in a case-insensitive way
|
325
|
+
content_type_key = headers.keys.find { |k| k.to_s.downcase == "content-type" }
|
326
|
+
content_type = content_type_key ? headers[content_type_key].downcase.strip : nil
|
327
|
+
|
328
|
+
# Handle different content types for non-string params
|
329
|
+
request.body = case
|
330
|
+
# No content type specified - use JSON as default
|
331
|
+
when content_type.nil?
|
332
|
+
headers["content-type"] = "application/json"
|
333
|
+
serialize_to_json(params)
|
334
|
+
|
335
|
+
# Form URL-encoded content
|
336
|
+
when content_type.start_with?("application/x-www-form-urlencoded")
|
337
|
+
if params.respond_to?(:to_h)
|
338
|
+
URI.encode_www_form(params.to_h)
|
339
|
+
else
|
340
|
+
raise ArgumentError, "Parameters must be Hash-like for form URL-encoded requests, got #{params.class}"
|
341
|
+
end
|
342
|
+
|
343
|
+
# JSON content type
|
344
|
+
when content_type.start_with?("application/json")
|
345
|
+
serialize_to_json(params)
|
346
|
+
|
347
|
+
# Any other content type - use what's provided
|
348
|
+
else
|
349
|
+
# For other content types, use the provided format but log a warning
|
350
|
+
@log.debug("Unknown content-type: #{content_type}, attempting to serialize as JSON")
|
351
|
+
serialize_to_json(params)
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
# Set content length based on the body if not already set
|
356
|
+
request["Content-Length"] ||= request.body.bytesize.to_s if request.body
|
357
|
+
rescue => e
|
358
|
+
error_message = "Failed to set request body: #{e.message}"
|
359
|
+
@log.error(error_message)
|
360
|
+
raise ArgumentError, error_message
|
361
|
+
end
|
362
|
+
end
|
363
|
+
|
364
|
+
# Helper method to safely serialize objects to JSON
|
365
|
+
def serialize_to_json(obj)
|
366
|
+
begin
|
367
|
+
case obj
|
368
|
+
when Hash, Array
|
369
|
+
obj.to_json
|
370
|
+
when ->(o) { o.respond_to?(:to_h) }
|
371
|
+
obj.to_h.to_json
|
372
|
+
when ->(o) { o.respond_to?(:to_json) }
|
373
|
+
obj.to_json
|
374
|
+
else
|
375
|
+
raise ArgumentError, "Cannot convert #{obj.class} to JSON"
|
376
|
+
end
|
377
|
+
rescue JSON::GeneratorError => e
|
378
|
+
raise ArgumentError, "Invalid JSON data: #{e.message}"
|
379
|
+
end
|
380
|
+
end
|
381
|
+
|
382
|
+
def add_headers_to_request(request, headers)
|
383
|
+
headers.each { |key, value| request[key] = value }
|
384
|
+
end
|
385
|
+
|
386
|
+
def validate_host_header(normalized_headers)
|
387
|
+
# Validate the Host header
|
388
|
+
if normalized_headers["host"] && normalized_headers["host"] != @uri.host
|
389
|
+
raise ArgumentError,
|
390
|
+
"Host header does not match the request URI host: expected #{@uri.host}, got #{normalized_headers['host']}"
|
391
|
+
end
|
392
|
+
|
393
|
+
# Ensure the Host header is set to the URI's host if not explicitly provided
|
394
|
+
normalized_headers["host"] ||= @uri.host
|
395
|
+
|
396
|
+
return normalized_headers
|
397
|
+
end
|
398
|
+
|
399
|
+
# Execute an HTTP request with automatic retries on connection failures
|
400
|
+
#
|
401
|
+
# @param method [Symbol] HTTP method (:get, :post, etc)
|
402
|
+
# @param path [String] Request path
|
403
|
+
# @param headers [Hash] Request headers
|
404
|
+
# @param body [Hash] Request parameters or body (optional)
|
405
|
+
# @return [Net::HTTPResponse] The HTTP response
|
406
|
+
def request(method, path, headers: {}, body: nil)
|
407
|
+
req = build_request(method, path, headers: headers, params: body)
|
408
|
+
attempts = 0
|
409
|
+
retries = 0
|
410
|
+
start_time = Time.now
|
411
|
+
|
412
|
+
begin
|
413
|
+
attempts += 1
|
414
|
+
response = if @request_timeout
|
415
|
+
Timeout.timeout(@request_timeout) do
|
416
|
+
@http.request(@uri, req)
|
417
|
+
end
|
418
|
+
else
|
419
|
+
@http.request(@uri, req)
|
420
|
+
end
|
421
|
+
|
422
|
+
duration = Time.now - start_time
|
423
|
+
@log.debug("Request completed: method=#{method}, path=#{path}, status=#{response.code}, duration=#{format_duration_ms(duration)}")
|
424
|
+
response
|
425
|
+
rescue Timeout::Error => e
|
426
|
+
duration = Time.now - start_time
|
427
|
+
@log.error("Request timed out after #{format_duration_ms(duration)}: method=#{method}, path=#{path}")
|
428
|
+
raise
|
429
|
+
rescue Net::HTTP::Persistent::Error, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET => e
|
430
|
+
retries += 1
|
431
|
+
if retries <= @max_retries
|
432
|
+
@log.debug("Connection failed: #{e.message} - rebuilding HTTP client (retry #{retries}/#{@max_retries})")
|
433
|
+
@http = create_http_client
|
434
|
+
retry
|
435
|
+
else
|
436
|
+
duration = Time.now - start_time
|
437
|
+
@log.error("Connection failed after #{retries - 1} retries (#{format_duration_ms(duration)}): #{e.message}")
|
438
|
+
raise
|
439
|
+
end
|
440
|
+
end
|
441
|
+
end
|
442
|
+
|
443
|
+
def configure_ssl(http, options)
|
444
|
+
return unless @uri.scheme == "https"
|
445
|
+
|
446
|
+
http.verify_mode = options.fetch(:verify_mode, OpenSSL::SSL::VERIFY_PEER)
|
447
|
+
http.verify_hostname = options.fetch(:verify_hostname, true) if http.respond_to?(:verify_hostname=)
|
448
|
+
http.ssl_version = options.fetch(:ssl_version, :TLSv1_2)
|
449
|
+
http.ca_file = options.fetch(:ca_file, @ssl_cert_file) if options.fetch(:ca_file, @ssl_cert_file)
|
450
|
+
end
|
451
|
+
|
452
|
+
# Format duration in milliseconds
|
453
|
+
#
|
454
|
+
# @param duration [Float] Duration in seconds
|
455
|
+
# @return [String] Formatted duration in milliseconds
|
456
|
+
def format_duration_ms(duration)
|
457
|
+
"#{(duration * 1000).round(2)} ms"
|
458
|
+
end
|
459
|
+
|
460
|
+
# Encode path parameters into a URL query string
|
461
|
+
#
|
462
|
+
# @param path [String] The base path
|
463
|
+
# @param params [Hash] Parameters to encode
|
464
|
+
# @return [String] The path with encoded parameters
|
465
|
+
def encode_path_params(path, params)
|
466
|
+
return path if params.nil? || params.empty?
|
467
|
+
|
468
|
+
encoded = URI.encode_www_form(params)
|
469
|
+
[path, encoded].join("?")
|
470
|
+
end
|
471
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/net/http/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "net-http-ext"
|
7
|
+
spec.version = NetHTTPExt::VERSION
|
8
|
+
spec.authors = ["Grant Birkinbine"]
|
9
|
+
spec.email = "grant.birkinbine@gmail.com"
|
10
|
+
spec.license = "MIT"
|
11
|
+
|
12
|
+
spec.summary = "Ruby Net::HTTP extended with sensible defaults"
|
13
|
+
spec.description = <<~SPEC_DESC
|
14
|
+
Safe defaults, persistent connections, thread safety, and basic logging for Ruby's Net::HTTP library
|
15
|
+
SPEC_DESC
|
16
|
+
|
17
|
+
spec.homepage = "https://github.com/grantbirki/net-http-ext"
|
18
|
+
spec.metadata = {
|
19
|
+
"source_code_uri" => "https://github.com/grantbirki/net-http-ext",
|
20
|
+
"documentation_uri" => "https://github.com/grantbirki/net-http-ext",
|
21
|
+
"bug_tracker_uri" => "https://github.com/grantbirki/net-http-ext/issues"
|
22
|
+
}
|
23
|
+
|
24
|
+
spec.add_dependency "timeout", "~> 0.4.3"
|
25
|
+
spec.add_dependency "json", "~> 2.10", ">= 2.10.2"
|
26
|
+
spec.add_dependency "uri", "~> 1.0", ">= 1.0.3"
|
27
|
+
spec.add_dependency "logger", "~> 1"
|
28
|
+
spec.add_dependency "net-http-persistent", "~> 4.0", ">= 4.0.5"
|
29
|
+
|
30
|
+
# https://github.com/drbrain/net-http-persistent/blob/234f3b2c6a0ed044e3c55e3de982257b4860ba0a/net-http-persistent.gemspec#L17C29-L17C37
|
31
|
+
spec.required_ruby_version = ">= 2.4"
|
32
|
+
|
33
|
+
spec.files = %w[LICENSE README.md net-http-ext.gemspec]
|
34
|
+
spec.files += Dir.glob("lib/**/*.rb")
|
35
|
+
spec.require_paths = ["lib"]
|
36
|
+
end
|
metadata
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: net-http-ext
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Grant Birkinbine
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 2025-04-11 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: timeout
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: 0.4.3
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: 0.4.3
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: json
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - "~>"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '2.10'
|
33
|
+
- - ">="
|
34
|
+
- !ruby/object:Gem::Version
|
35
|
+
version: 2.10.2
|
36
|
+
type: :runtime
|
37
|
+
prerelease: false
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - "~>"
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '2.10'
|
43
|
+
- - ">="
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: 2.10.2
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: uri
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
requirements:
|
50
|
+
- - "~>"
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: '1.0'
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: 1.0.3
|
56
|
+
type: :runtime
|
57
|
+
prerelease: false
|
58
|
+
version_requirements: !ruby/object:Gem::Requirement
|
59
|
+
requirements:
|
60
|
+
- - "~>"
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: '1.0'
|
63
|
+
- - ">="
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: 1.0.3
|
66
|
+
- !ruby/object:Gem::Dependency
|
67
|
+
name: logger
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
69
|
+
requirements:
|
70
|
+
- - "~>"
|
71
|
+
- !ruby/object:Gem::Version
|
72
|
+
version: '1'
|
73
|
+
type: :runtime
|
74
|
+
prerelease: false
|
75
|
+
version_requirements: !ruby/object:Gem::Requirement
|
76
|
+
requirements:
|
77
|
+
- - "~>"
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '1'
|
80
|
+
- !ruby/object:Gem::Dependency
|
81
|
+
name: net-http-persistent
|
82
|
+
requirement: !ruby/object:Gem::Requirement
|
83
|
+
requirements:
|
84
|
+
- - "~>"
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: '4.0'
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 4.0.5
|
90
|
+
type: :runtime
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '4.0'
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: 4.0.5
|
100
|
+
description: 'Safe defaults, persistent connections, thread safety, and basic logging
|
101
|
+
for Ruby''s Net::HTTP library
|
102
|
+
|
103
|
+
'
|
104
|
+
email: grant.birkinbine@gmail.com
|
105
|
+
executables: []
|
106
|
+
extensions: []
|
107
|
+
extra_rdoc_files: []
|
108
|
+
files:
|
109
|
+
- LICENSE
|
110
|
+
- README.md
|
111
|
+
- lib/net/http/ext.rb
|
112
|
+
- lib/net/http/version.rb
|
113
|
+
- net-http-ext.gemspec
|
114
|
+
homepage: https://github.com/grantbirki/net-http-ext
|
115
|
+
licenses:
|
116
|
+
- MIT
|
117
|
+
metadata:
|
118
|
+
source_code_uri: https://github.com/grantbirki/net-http-ext
|
119
|
+
documentation_uri: https://github.com/grantbirki/net-http-ext
|
120
|
+
bug_tracker_uri: https://github.com/grantbirki/net-http-ext/issues
|
121
|
+
rdoc_options: []
|
122
|
+
require_paths:
|
123
|
+
- lib
|
124
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
125
|
+
requirements:
|
126
|
+
- - ">="
|
127
|
+
- !ruby/object:Gem::Version
|
128
|
+
version: '2.4'
|
129
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
130
|
+
requirements:
|
131
|
+
- - ">="
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '0'
|
134
|
+
requirements: []
|
135
|
+
rubygems_version: 3.6.2
|
136
|
+
specification_version: 4
|
137
|
+
summary: Ruby Net::HTTP extended with sensible defaults
|
138
|
+
test_files: []
|