recurly 4.80.0 → 4.81.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: a0e6c839c677863f0df569868d3aea1a97574cc249b3f0dfdfb6f71f4029b592
4
- data.tar.gz: f766e6760fb8c96a976b89d3c4a6e62536a0eae21fa3b36996da0400f9a700f9
3
+ metadata.gz: 3fcb6e038e5c1315ddf4ea11a1de626cac3eff0f6116309902a13fe2dd4d7d8a
4
+ data.tar.gz: 593fca5091812565ae93b3f592fc07b30014d97c45aab72af066887d1183c7ef
5
5
  SHA512:
6
- metadata.gz: 8edd7d42e45fd761b8620c37a32dec83c8a90b3cf14cabd2b6dc6b5f45dc436f002da5967cf4ea4ce6299275aec863a5e0c0ae691b82bd933cecae9cc53cb1ee
7
- data.tar.gz: 9acd5bcd20f19574e54bb8484954cf0d1d420d67197209b823d918aa6ba1b2a7962b1dd2ded9e45ef88ba37574bc9315dc4bec1afe8ad13b69ff5726d1ac2111
6
+ metadata.gz: 0710a16b6de8151e608d13c410fd0aa11dcce94e29f32a255133b5b8f07a6823810f1cba9c72cee2a6fe49638f8377ae665b3c4e2bc26697995eb04be5fea245
7
+ data.tar.gz: 55750c7429a93977fecc70e63f440d2932e340eedc55738cde3ba231110208517cac63f38e6bc774ae1d2e57516cef64f4d2782723b2e5074988381893df6c99
data/.bumpversion.cfg CHANGED
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 4.80.0
2
+ current_version = 4.81.0
3
3
  parse = (?P<major>\d+)
4
4
  \.(?P<minor>\d+)
5
5
  \.(?P<patch>\d+)
@@ -1,7 +1,9 @@
1
1
  name: CI
2
- on: [push]
3
- concurrency:
4
- group: ${{ github.ref }}
2
+ on:
3
+ push:
4
+ pull_request:
5
+ concurrency:
6
+ group: ${{ github.workflow }}-${{ github.ref }}
5
7
  cancel-in-progress: true
6
8
  jobs:
7
9
  build:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.81.0](https://github.com/recurly/recurly-client-ruby/tree/4.81.0) (2026-09-09)
4
+
5
+ [Full Changelog](https://github.com/recurly/recurly-client-ruby/compare/4.80.0...4.81.0)
6
+
7
+
8
+ **Merged Pull Requests**
9
+
10
+ - Generated Latest Changes for v2021-02-25 [#968](https://github.com/recurly/recurly-client-ruby/pull/968) ([recurly-integrations](https://github.com/recurly-integrations))
11
+ - Relax base64 dependency to allow 0.3.x [#965](https://github.com/recurly/recurly-client-ruby/pull/965) ([romanoff](https://github.com/romanoff))
12
+
13
+
14
+
3
15
  ## [4.80.0](https://github.com/recurly/recurly-client-ruby/tree/4.80.0) (2026-07-02)
4
16
 
5
17
  [Full Changelog](https://github.com/recurly/recurly-client-ruby/compare/4.79.0...4.80.0)
data/GETTING_STARTED.md CHANGED
@@ -5,7 +5,7 @@ This repository houses the official ruby client for Recurly's V3 API.
5
5
  In your Gemfile, add `recurly` as a dependency.
6
6
 
7
7
  ```ruby
8
- gem 'recurly', '~> 4.80'
8
+ gem 'recurly', '~> 4.81'
9
9
  ```
10
10
 
11
11
  > *Note*: We try to follow [semantic versioning](https://semver.org/) and will only apply breaking changes to major versions.
data/README.md CHANGED
@@ -12,6 +12,33 @@ This repository houses the official ruby client for Recurly's V3 API.
12
12
 
13
13
  Getting Started Guide and reference documentation can be found on [Github Pages](https://recurly.github.io/recurly-client-ruby/).
14
14
 
15
+ ## Custom HTTP Adapter
16
+
17
+ The client ships with a default `Net::HTTP`-backed transport, but you can inject
18
+ your own HTTP layer (for observability, proxy/TLS control, or transport mocking)
19
+ by passing an adapter that responds to `#call`:
20
+
21
+ ```ruby
22
+ client = Recurly::Client.new(api_key: API_KEY, http_adapter: MyAdapter.new)
23
+ ```
24
+
25
+ An adapter must implement:
26
+
27
+ ```ruby
28
+ # @return [Recurly::HTTP::AdapterResponse]
29
+ # @raise [Recurly::Errors::TransportError] on transport-level failure only
30
+ def call(method, url, headers, body, open_timeout: nil, read_timeout: nil)
31
+ end
32
+ ```
33
+
34
+ - `method` is a `Recurly::HTTP::HttpMethod` string (`"GET"`, `"POST"`, ...).
35
+ - `url` is an **absolute** URL (scheme + host + path + query).
36
+ - `headers` is a plain `Hash` of app-level headers (the SDK owns `Authorization`,
37
+ `Idempotency-Key`, `Content-Type`, etc.).
38
+ - `body` is a serialized `String` or `nil`.
39
+ - HTTP `4xx`/`5xx` responses must be **returned** as an `AdapterResponse`, never
40
+ raised. Only genuine transport failures raise `Recurly::Errors::TransportError`.
41
+
15
42
  ## Contributing
16
43
 
17
44
  Please see our [Contributing Guide](CONTRIBUTING.md).
@@ -24,6 +24,22 @@ module Recurly
24
24
  MAX_RETRIES = 3
25
25
  LOG_LEVELS = %i(debug info warn error fatal).freeze
26
26
  BASE36_ALPHABET = (("0".."9").to_a + ("a".."z").to_a).freeze
27
+
28
+ # Vendored HTTP status → reason-phrase table. Used to synthesize a
29
+ # reason_phrase when a (custom) adapter did not surface one. On the default
30
+ # path the adapter provides Net::HTTP's reason phrase, so this is not
31
+ # consulted and behavior is byte-identical. Phrases are bare (no status code
32
+ # prefix) since callers build "#{code}: #{phrase}".
33
+ HTTP_STATUS_MESSAGES = {
34
+ 200 => "OK", 201 => "Created", 202 => "Accepted", 204 => "No Content",
35
+ 301 => "Moved Permanently", 302 => "Found", 304 => "Not Modified",
36
+ 400 => "Bad Request", 401 => "Unauthorized", 402 => "Payment Required",
37
+ 403 => "Forbidden", 404 => "Not Found", 406 => "Not Acceptable",
38
+ 409 => "Conflict", 412 => "Precondition Failed", 422 => "Unprocessable Entity",
39
+ 429 => "Too Many Requests", 500 => "Internal Server Error", 502 => "Bad Gateway",
40
+ 503 => "Service Unavailable", 504 => "Gateway Timeout",
41
+ }.freeze
42
+
27
43
  ALLOWED_OPTIONS = [
28
44
  :site_id,
29
45
  :open_timeout,
@@ -61,7 +77,7 @@ module Recurly
61
77
  # @param ca_file [String] The CA bundle to use when connecting to the API. Defaults to "data/ca-certificates.crt"
62
78
  # @param api_key [String] The private API key
63
79
  # @param logger [Logger] A logger to use. Defaults to creating a new STDOUT logger with level WARN.
64
- def initialize(region: REGION, base_url: API_HOSTS[:us], ca_file: CA_FILE, api_key:, logger: nil, keep_alive_timeout: 600)
80
+ def initialize(region: REGION, base_url: API_HOSTS[:us], ca_file: CA_FILE, api_key:, logger: nil, keep_alive_timeout: 600, http_adapter: nil)
65
81
  raise ArgumentError, "'api_key' must be set to a non-nil value" if api_key.nil?
66
82
 
67
83
  raise ArgumentError, "Invalid region type. Expected one of: #{API_HOSTS.keys.join(", ")}" if !API_HOSTS.key?(region)
@@ -92,6 +108,12 @@ module Recurly
92
108
  log_warn("SECURITY_WARNING", message: msg)
93
109
  end
94
110
 
111
+ @http_adapter = http_adapter || HTTP::DefaultHttpAdapter.new(
112
+ connection_pool: self.class.connection_pool,
113
+ keep_alive_timeout: @keep_alive_timeout,
114
+ ca_file: @ca_file,
115
+ )
116
+
95
117
  # execute block with this client if given
96
118
  yield(self) if block_given?
97
119
  end
@@ -111,55 +133,57 @@ module Recurly
111
133
 
112
134
  def head(path, **options)
113
135
  validate_options!(**options)
114
- request = Net::HTTP::Head.new build_url(path, options)
115
- set_headers(request, options[:headers])
116
- http_response = run_request(request, options)
117
- handle_response! request, http_response
136
+ relative_path = build_url(path, options)
137
+ headers = build_headers(HTTP::HttpMethod::HEAD, options[:headers])
138
+ request = HTTP::Request.new(HTTP::HttpMethod::HEAD, relative_path, nil)
139
+ response = run_request(request, headers, options)
140
+ handle_response! request, response
118
141
  end
119
142
 
120
143
  def get(path, **options)
121
144
  validate_options!(**options)
122
-
123
- request = Net::HTTP::Get.new build_url(path, options)
124
-
125
- set_headers(request, options[:headers])
126
- http_response = run_request(request, options)
127
- handle_response! request, http_response
145
+ relative_path = build_url(path, options)
146
+ headers = build_headers(HTTP::HttpMethod::GET, options[:headers])
147
+ request = HTTP::Request.new(HTTP::HttpMethod::GET, relative_path, nil)
148
+ response = run_request(request, headers, options)
149
+ handle_response! request, response
128
150
  end
129
151
 
130
152
  def post(path, request_data = nil, request_class = nil, **options)
131
153
  validate_options!(**options)
132
- request = Net::HTTP::Post.new build_url(path, options)
133
- request.set_content_type(JSON_CONTENT_TYPE)
134
- set_headers(request, options[:headers])
154
+ relative_path = build_url(path, options)
155
+ headers = build_headers(HTTP::HttpMethod::POST, options[:headers])
156
+ body = nil
135
157
  if request_data
136
158
  request_class.new(request_data).validate!
137
- request.body = JSON.dump(request_data)
159
+ body = JSON.dump(request_data)
138
160
  end
139
- http_response = run_request(request, options)
140
- handle_response! request, http_response
161
+ request = HTTP::Request.new(HTTP::HttpMethod::POST, relative_path, body)
162
+ response = run_request(request, headers, options)
163
+ handle_response! request, response
141
164
  end
142
165
 
143
166
  def put(path, request_data = nil, request_class = nil, **options)
144
167
  validate_options!(**options)
145
- request = Net::HTTP::Put.new build_url(path, options)
146
- request.set_content_type(JSON_CONTENT_TYPE)
147
- set_headers(request, options[:headers])
168
+ relative_path = build_url(path, options)
169
+ headers = build_headers(HTTP::HttpMethod::PUT, options[:headers])
170
+ body = nil
148
171
  if request_data
149
172
  request_class.new(request_data).validate!
150
- json_body = JSON.dump(request_data)
151
- request.body = json_body
173
+ body = JSON.dump(request_data)
152
174
  end
153
- http_response = run_request(request, options)
154
- handle_response! request, http_response
175
+ request = HTTP::Request.new(HTTP::HttpMethod::PUT, relative_path, body)
176
+ response = run_request(request, headers, options)
177
+ handle_response! request, response
155
178
  end
156
179
 
157
180
  def delete(path, **options)
158
181
  validate_options!(**options)
159
- request = Net::HTTP::Delete.new build_url(path, options)
160
- set_headers(request, options[:headers])
161
- http_response = run_request(request, options)
162
- handle_response! request, http_response
182
+ relative_path = build_url(path, options)
183
+ headers = build_headers(HTTP::HttpMethod::DELETE, options[:headers])
184
+ request = HTTP::Request.new(HTTP::HttpMethod::DELETE, relative_path, nil)
185
+ response = run_request(request, headers, options)
186
+ handle_response! request, response
163
187
  end
164
188
 
165
189
  private
@@ -171,80 +195,110 @@ module Recurly
171
195
  attr_accessor :connection_pool
172
196
  end
173
197
 
174
- def run_request(request, options = {})
175
- self.class.connection_pool.with_connection(uri: @base_uri, keep_alive_timeout: @keep_alive_timeout, ca_file: @ca_file) do |http|
176
- set_http_options(http, options)
198
+ def run_request(request, headers, options = {})
199
+ method = request.method
200
+ body = request.body
201
+ url = request_url(request.path)
202
+ open_timeout = options[:open_timeout]
203
+ read_timeout = options[:read_timeout]
177
204
 
178
- retries = 0
205
+ retries = 0
179
206
 
180
- begin
181
- http.start unless http.started?
182
-
183
- log_attrs = {
184
- method: request.method,
185
- path: request.path,
186
- }
187
- if @logger.level < Logger::INFO
188
- log_attrs[:request_body] = request.body
189
- # No need to log the authorization header
190
- headers = request.to_hash.reject { |k, _| k&.downcase == "authorization" }
191
- log_attrs[:request_headers] = headers
192
- end
207
+ log_attrs = {
208
+ method: method,
209
+ path: request.path,
210
+ }
211
+ if @logger.level < Logger::INFO
212
+ log_attrs[:request_body] = body
213
+ # No need to log the authorization header
214
+ loggable_headers = headers.reject { |k, _| k&.downcase == "authorization" }
215
+ log_attrs[:request_headers] = loggable_headers
216
+ end
193
217
 
194
- log_info("Request", **log_attrs)
195
- start = Time.now
196
- response = http.request(request)
197
- elapsed = Time.now - start
218
+ begin
219
+ log_info("Request", **log_attrs)
220
+ start = Time.now
221
+ response = @http_adapter.call(method, url, headers, body, open_timeout: open_timeout, read_timeout: read_timeout)
222
+ elapsed = Time.now - start
198
223
 
199
- # GETs are safe to retry after a server error, requests with an Idempotency-Key will return the prior response
200
- if response.kind_of?(Net::HTTPServerError) && request.is_a?(Net::HTTP::Get)
201
- retries += 1
224
+ # GETs are safe to retry after a server error, requests with an Idempotency-Key will return the prior response.
225
+ # This is a SINGLE inline re-issue (not a loop) that shares the same `retries` counter as the transport rescue.
226
+ if response.status_code >= 500 && method == HTTP::HttpMethod::GET
227
+ retries += 1
228
+ if retries < MAX_RETRIES
202
229
  log_info("Retrying", retries: retries, **log_attrs)
203
230
  start = Time.now
204
- response = http.request(request) if retries < MAX_RETRIES
231
+ response = @http_adapter.call(method, url, headers, body, open_timeout: open_timeout, read_timeout: read_timeout)
205
232
  elapsed = Time.now - start
206
233
  end
234
+ end
207
235
 
208
- if @logger.level < Logger::INFO
209
- log_attrs[:response_body] = response.body
210
- log_attrs[:response_headers] = response.to_hash
211
- end
212
- log_info("Response", time_ms: (elapsed * 1_000).floor, status: response.code, **log_attrs)
213
-
214
- response
215
- rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ECONNABORTED,
216
- Errno::EPIPE, Errno::ETIMEDOUT, Net::OpenTimeout, EOFError, SocketError => ex
217
- retries += 1
218
- if retries < MAX_RETRIES
219
- retry
220
- end
236
+ if @logger.level < Logger::INFO
237
+ log_attrs[:response_body] = response.body
238
+ log_attrs[:response_headers] = response.headers
239
+ end
240
+ log_info("Response", time_ms: (elapsed * 1_000).floor, status: response.status_code, **log_attrs)
221
241
 
222
- if ex.kind_of?(Net::OpenTimeout) || ex.kind_of?(Errno::ETIMEDOUT)
223
- raise Recurly::Errors::TimeoutError, "Request timed out"
224
- end
242
+ response
243
+ rescue Recurly::Errors::TransportError => ex
244
+ retries += 1
245
+ if retries < MAX_RETRIES
246
+ retry
247
+ end
225
248
 
226
- raise Recurly::Errors::ConnectionFailedError, "Failed to connect to Recurly: #{ex.message}"
227
- rescue Timeout::Error
249
+ case ex.kind
250
+ when :timeout
228
251
  raise Recurly::Errors::TimeoutError, "Request timed out"
229
- rescue OpenSSL::SSL::SSLError => ex
252
+ when :ssl
230
253
  raise Recurly::Errors::SSLError, ex.message
231
- rescue StandardError => ex
254
+ when :connection
255
+ raise Recurly::Errors::ConnectionFailedError, ex.message
256
+ else
232
257
  raise Recurly::Errors::NetworkError, ex.message
233
258
  end
234
259
  end
235
260
  end
236
261
 
237
- def set_headers(request, additional_headers = {})
262
+ # Builds the app-level request headers as a plain Hash. Header names are
263
+ # compared case-insensitively so a caller-supplied header (any case)
264
+ # overrides the SDK default instead of producing a duplicate.
265
+ def build_headers(method, additional_headers = {})
266
+ headers = {}
267
+
268
+ # Content-Type must be applied FIRST for bodied verbs, so caller headers
269
+ # can override it (parity with the old set_content_type-then-set_headers order).
270
+ if method == HTTP::HttpMethod::POST || method == HTTP::HttpMethod::PUT
271
+ headers["Content-Type"] = JSON_CONTENT_TYPE
272
+ end
273
+
238
274
  # TODO this is undocumented until we finalize it
239
- additional_headers.each { |header, v| request[header] = v } if additional_headers
275
+ if additional_headers
276
+ additional_headers.each { |header, v| set_header(headers, header, v) }
277
+ end
240
278
 
241
- request["Accept"] = "application/vnd.recurly.#{api_version}".chomp # got this method from operations.rb
242
- request["Authorization"] = "Basic #{Base64.encode64(@api_key)}".chomp
243
- request["User-Agent"] = "Recurly/#{VERSION}; #{RUBY_DESCRIPTION}"
279
+ set_header(headers, "Accept", "application/vnd.recurly.#{api_version}".chomp) # got this method from operations.rb
280
+ set_header(headers, "Authorization", "Basic #{Base64.encode64(@api_key)}".chomp)
281
+ set_header(headers, "User-Agent", "Recurly/#{VERSION}; #{RUBY_DESCRIPTION}")
244
282
 
245
- unless request.is_a?(Net::HTTP::Get) || request.is_a?(Net::HTTP::Head)
246
- request["Idempotency-Key"] ||= generate_idempotency_key
283
+ unless method == HTTP::HttpMethod::GET || method == HTTP::HttpMethod::HEAD
284
+ # Only generate an Idempotency-Key if the caller did not supply one (any case).
285
+ set_header(headers, "Idempotency-Key", generate_idempotency_key) unless header_key?(headers, "Idempotency-Key")
247
286
  end
287
+
288
+ headers
289
+ end
290
+
291
+ # Sets a header, replacing any existing entry whose name matches
292
+ # case-insensitively (so we never emit two headers differing only in case).
293
+ def set_header(headers, name, value)
294
+ existing = headers.keys.find { |k| k.to_s.downcase == name.to_s.downcase }
295
+ headers.delete(existing) if existing
296
+ headers[name] = value
297
+ end
298
+
299
+ # @return [Boolean] whether a header (case-insensitive) is already present
300
+ def header_key?(headers, name)
301
+ headers.keys.any? { |k| k.to_s.downcase == name.to_s.downcase }
248
302
  end
249
303
 
250
304
  # from https://github.com/rails/rails/blob/6-0-stable/activesupport/lib/active_support/core_ext/securerandom.rb
@@ -256,21 +310,16 @@ module Recurly
256
310
  end.join
257
311
  end
258
312
 
259
- def set_http_options(http, options)
260
- http.open_timeout = options[:open_timeout] || 20
261
- http.read_timeout = options[:read_timeout] || 60
262
- end
263
-
264
- def handle_response!(request, http_response)
265
- response = HTTP::Response.new(http_response, request)
266
- raise_api_error!(http_response, response) unless http_response.kind_of?(Net::HTTPSuccess)
313
+ def handle_response!(request, adapter_response)
314
+ response = HTTP::Response.new(adapter_response, request)
315
+ raise_api_error!(adapter_response, response) unless adapter_response.status_code.between?(200, 299)
267
316
  resource = if response.body
268
- if http_response.content_type&.include?(JSON_CONTENT_TYPE)
317
+ if response.content_type&.include?(JSON_CONTENT_TYPE)
269
318
  JSONParser.parse(self, response.body)
270
- elsif BINARY_TYPES.include?(http_response.content_type)
319
+ elsif BINARY_TYPES.include?(response.content_type)
271
320
  FileParser.parse(response.body)
272
321
  else
273
- raise Recurly::Errors::InvalidContentTypeError, "Unexpected content type: #{http_response.content_type}"
322
+ raise Recurly::Errors::InvalidContentTypeError, "Unexpected content type: #{response.content_type}"
274
323
  end
275
324
  else
276
325
  Resources::Empty.new
@@ -280,28 +329,37 @@ module Recurly
280
329
  resource
281
330
  end
282
331
 
283
- def raise_api_error!(http_response, response)
284
- if response.content_type.include?(JSON_CONTENT_TYPE)
332
+ def raise_api_error!(adapter_response, response)
333
+ if response.content_type&.include?(JSON_CONTENT_TYPE) && response.body
285
334
  error = JSONParser.parse(self, response.body)
286
335
  begin
287
336
  error_class = Errors::APIError.error_class(error.type)
288
337
  raise error_class.new(error.message, response, error)
289
338
  rescue NameError
290
- error_class = Errors::APIError.from_response(http_response)
339
+ error_class = Errors::APIError.from_response(adapter_response)
291
340
  raise error_class.new("Unknown Error", response, error)
292
341
  end
293
342
  end
294
343
 
295
- error_class = Errors::APIError.from_response(http_response)
344
+ error_class = Errors::APIError.from_response(adapter_response)
345
+ status_line = status_line_for(adapter_response)
296
346
 
297
347
  if error_class <= Recurly::Errors::APIError
298
- error = Recurly::Resources::Error.new(message: "#{http_response.code}: #{http_response.message}")
348
+ error = Recurly::Resources::Error.new(message: status_line)
299
349
  raise error_class.new(error.message, response, error)
300
350
  else
301
- raise error_class, "#{http_response.code}: #{http_response.message}"
351
+ raise error_class, status_line
302
352
  end
303
353
  end
304
354
 
355
+ # Builds a "<code>: <phrase>" status line, falling back to the vendored
356
+ # reason-phrase table and omitting the trailing ": " entirely when no
357
+ # phrase can be found (e.g. a custom adapter on an uncommon status code).
358
+ def status_line_for(adapter_response)
359
+ phrase = adapter_response.reason_phrase || HTTP_STATUS_MESSAGES[adapter_response.status_code]
360
+ phrase ? "#{adapter_response.status_code}: #{phrase}" : adapter_response.status_code.to_s
361
+ end
362
+
305
363
  def read_headers(response)
306
364
  if !@_ignore_deprecation_warning && response.headers["Recurly-Deprecated"]&.upcase == "TRUE"
307
365
  log_warn("DEPRECTATION WARNING", message: "Your current API version \"#{api_version}\" is deprecated and will be sunset on #{response.headers["Recurly-Sunset-Date"]}")
@@ -359,6 +417,15 @@ module Recurly
359
417
  @keep_alive_timeout = keep_alive_timeout
360
418
  end
361
419
 
420
+ # Absolute URL for the adapter: base_url ORIGIN (scheme, host, port) + the
421
+ # relative path. Any path/trailing-slash on base_url is ignored, matching the
422
+ # pre-adapter behavior of opening by host:port and sending the path alone.
423
+ def request_url(relative_path)
424
+ origin = "#{@base_uri.scheme}://#{@base_uri.host}"
425
+ origin += ":#{@base_uri.port}" unless @base_uri.port == @base_uri.default_port
426
+ origin + relative_path
427
+ end
428
+
362
429
  def build_url(path, options)
363
430
  path = scope_by_site(path, options)
364
431
  query_params = map_array_params(options.fetch(:params, {}))
@@ -3,5 +3,30 @@ module Recurly
3
3
  class NetworkError < APIError; end
4
4
  class ConnectionFailedError < NetworkError; end
5
5
  class SSLError < NetworkError; end
6
+
7
+ # Neutral transport error raised by HTTP adapters for ALL transport-level
8
+ # failures (timeouts, connection failures, SSL errors, generic network
9
+ # errors). This is the single error class the client's retry loop rescues.
10
+ #
11
+ # The +kind+ symbol (+:timeout+, +:connection+, +:ssl+, +:network+) lets the
12
+ # client reproduce its historical typed-error mapping after retries are
13
+ # exhausted, without needing to know about adapter-specific exception classes.
14
+ # Custom adapters that only set +:network+ degrade gracefully to +NetworkError+.
15
+ class TransportError < NetworkError
16
+ # @return [Symbol] one of +:timeout+, +:connection+, +:ssl+, +:network+
17
+ attr_reader :kind
18
+
19
+ # @return [Exception, nil] the underlying transport exception, if any.
20
+ # Named distinctly from Ruby's built-in +Exception#cause+ (which is
21
+ # auto-populated on +raise+ inside a +rescue+ and read by Sentry,
22
+ # logging frameworks, and +pp+) so we don't shadow it.
23
+ attr_reader :original_exception
24
+
25
+ def initialize(message, kind: :network, cause: nil)
26
+ super(message)
27
+ @kind = kind
28
+ @original_exception = cause
29
+ end
30
+ end
6
31
  end
7
32
  end
@@ -0,0 +1,22 @@
1
+ module Recurly
2
+ module HTTP
3
+ # Base class documenting the HTTP adapter contract. Custom adapters should
4
+ # subclass this (or simply respond to +#call+) and implement +#call+.
5
+ #
6
+ # Contract:
7
+ # - +#call(method, url, headers, body, open_timeout: nil, read_timeout: nil)+
8
+ # performs the HTTP request. +method+ is a {HttpMethod} string, +url+ is an
9
+ # ABSOLUTE url (scheme+host+path+query), +headers+ is a plain Hash of
10
+ # app-level headers, and +body+ is a serialized String or nil.
11
+ # - On success (including HTTP 4xx and 5xx) it MUST return a
12
+ # {Recurly::HTTP::AdapterResponse}. 4xx/5xx are RETURNED, never raised.
13
+ # - On a transport-level failure (timeout, connection refused, SSL error,
14
+ # etc.) it MUST raise {Recurly::Errors::TransportError}, ideally carrying a
15
+ # +kind+ so the client can reproduce its typed-error mapping.
16
+ class Adapter
17
+ def call(method, url, headers, body, open_timeout: nil, read_timeout: nil)
18
+ raise NotImplementedError, "#{self.class} must implement #call"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,107 @@
1
+ require "net/https"
2
+
3
+ module Recurly
4
+ module HTTP
5
+ # The default transport adapter, backed by +Net::HTTP+. It uses the shared
6
+ # connection pool passed in at construction (it does NOT own a private pool)
7
+ # and preserves the byte-identical behavior the client had before the adapter
8
+ # seam was introduced.
9
+ class DefaultHttpAdapter < Adapter
10
+ # @param connection_pool [Recurly::ConnectionPool] the shared, class-level pool
11
+ # @param keep_alive_timeout [Integer] seconds; forwarded to the pool
12
+ # @param ca_file [String, nil] CA bundle path; forwarded to the pool
13
+ # @param read_timeout [Numeric] default read timeout in SECONDS (default 60).
14
+ # Same unit as the per-request `read_timeout:` override accepted by {#call}.
15
+ # @param open_timeout [Numeric] default open timeout in SECONDS (default 20).
16
+ # Same unit as the per-request `open_timeout:` override accepted by {#call}.
17
+ def initialize(connection_pool:, keep_alive_timeout:, ca_file: nil, read_timeout: 60, open_timeout: 20)
18
+ @connection_pool = connection_pool
19
+ @keep_alive_timeout = keep_alive_timeout
20
+ @ca_file = ca_file
21
+ @read_timeout = read_timeout
22
+ @open_timeout = open_timeout
23
+ end
24
+
25
+ # @param method [String] a {HttpMethod} constant
26
+ # @param url [String] the ABSOLUTE request url (scheme+host+path+query)
27
+ # @param headers [Hash] app-level request headers
28
+ # @param body [String, nil] serialized request body
29
+ # @param open_timeout [Numeric, nil] per-request open timeout override, in SECONDS
30
+ # @param read_timeout [Numeric, nil] per-request read timeout override, in SECONDS
31
+ # @return [Recurly::HTTP::AdapterResponse]
32
+ # @raise [Recurly::Errors::TransportError] on any transport-level failure
33
+ def call(method, url, headers, body, open_timeout: nil, read_timeout: nil)
34
+ # Parse/build outside the transport rescue: a bad URL or unsupported verb
35
+ # is a programming error and must raise immediately, not be wrapped in a
36
+ # (retried) TransportError.
37
+ uri = parse_url(url)
38
+ request = build_request(method, uri, headers, body)
39
+
40
+ net_response = begin
41
+ @connection_pool.with_connection(uri: uri, keep_alive_timeout: @keep_alive_timeout, ca_file: @ca_file) do |http|
42
+ http.open_timeout = open_timeout || @open_timeout
43
+ http.read_timeout = read_timeout || @read_timeout
44
+ http.start unless http.started?
45
+ http.request(request)
46
+ end
47
+ rescue Net::OpenTimeout, Errno::ETIMEDOUT => ex
48
+ raise Recurly::Errors::TransportError.new("Request timed out", kind: :timeout, cause: ex)
49
+ rescue Timeout::Error => ex
50
+ # Net::ReadTimeout < Timeout::Error. This clause MUST precede the
51
+ # StandardError clause so read timeouts map to :timeout (invariant A).
52
+ raise Recurly::Errors::TransportError.new("Request timed out", kind: :timeout, cause: ex)
53
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ECONNABORTED,
54
+ Errno::EPIPE, EOFError, SocketError => ex
55
+ raise Recurly::Errors::TransportError.new("Failed to connect to Recurly: #{ex.message}", kind: :connection, cause: ex)
56
+ rescue OpenSSL::SSL::SSLError => ex
57
+ raise Recurly::Errors::TransportError.new(ex.message, kind: :ssl, cause: ex)
58
+ rescue StandardError => ex
59
+ raise Recurly::Errors::TransportError.new(ex.message, kind: :network, cause: ex)
60
+ end
61
+
62
+ to_adapter_response(net_response)
63
+ end
64
+
65
+ private
66
+
67
+ # Raises ArgumentError (not a retried TransportError) on a malformed URL,
68
+ # so a programming error fails fast instead of looking like a network blip.
69
+ def parse_url(url)
70
+ URI.parse(url)
71
+ rescue URI::InvalidURIError => ex
72
+ raise ArgumentError, "Invalid request URL #{url.inspect}: #{ex.message}"
73
+ end
74
+
75
+ NET_VERBS = {
76
+ HttpMethod::GET => Net::HTTP::Get,
77
+ HttpMethod::HEAD => Net::HTTP::Head,
78
+ HttpMethod::POST => Net::HTTP::Post,
79
+ HttpMethod::PUT => Net::HTTP::Put,
80
+ HttpMethod::DELETE => Net::HTTP::Delete,
81
+ }.freeze
82
+
83
+ def build_request(method, uri, headers, body)
84
+ verb = NET_VERBS.fetch(method) do
85
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
86
+ end
87
+ # Net verbs take a path (+ query), NOT an absolute url.
88
+ request = verb.new(uri.request_uri)
89
+ request.body = body unless body.nil?
90
+ (headers || {}).each { |k, v| request[k] = v }
91
+ request
92
+ end
93
+
94
+ def to_adapter_response(net_response)
95
+ response_headers = {}
96
+ net_response.each_header { |k, v| response_headers[k.downcase] = v }
97
+
98
+ AdapterResponse.new(
99
+ status_code: net_response.code.to_i,
100
+ headers: response_headers,
101
+ body: net_response.body,
102
+ reason_phrase: net_response.message,
103
+ )
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,13 @@
1
+ module Recurly
2
+ module HTTP
3
+ # String constants for the HTTP methods the client issues. Used as the
4
+ # +method+ argument crossing the adapter seam (`#call(method, ...)`).
5
+ module HttpMethod
6
+ GET = "GET".freeze
7
+ POST = "POST".freeze
8
+ PUT = "PUT".freeze
9
+ DELETE = "DELETE".freeze
10
+ HEAD = "HEAD".freeze
11
+ end
12
+ end
13
+ end