recurly 4.80.0 → 4.82.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: f18ba4644a7a18b9af8bf1d8e2105b618e4b72c60ffece627e9ce0ac5c975df3
4
+ data.tar.gz: af4b805af51738af015cc8b3b75366b626e06c8cb4216b63e13c61b1c4da5d80
5
5
  SHA512:
6
- metadata.gz: 8edd7d42e45fd761b8620c37a32dec83c8a90b3cf14cabd2b6dc6b5f45dc436f002da5967cf4ea4ce6299275aec863a5e0c0ae691b82bd933cecae9cc53cb1ee
7
- data.tar.gz: 9acd5bcd20f19574e54bb8484954cf0d1d420d67197209b823d918aa6ba1b2a7962b1dd2ded9e45ef88ba37574bc9315dc4bec1afe8ad13b69ff5726d1ac2111
6
+ metadata.gz: 4b775860b96876758f1f5d80bde246d290816a2ac5363543c33092de01dfc11feb9c8392bac719fd62a7e86950565f0f33bc4af931683f8dfad859607984e504
7
+ data.tar.gz: 283ad6f4743597ffd73bfec1bef644896c9d557fe255b0cf270ed1838a393ae5de245be001f8dc0d5b7d489675dab409966c7eff9383e2a3b16eedb9c2bad9d5
data/.bumpversion.cfg CHANGED
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 4.80.0
2
+ current_version = 4.82.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.82.0](https://github.com/recurly/recurly-client-ruby/tree/4.82.0) (2026-09-15)
4
+
5
+ [Full Changelog](https://github.com/recurly/recurly-client-ruby/compare/4.81.0...4.82.0)
6
+
7
+
8
+
9
+
10
+
11
+ ## [4.81.0](https://github.com/recurly/recurly-client-ruby/tree/4.81.0) (2026-09-09)
12
+
13
+ [Full Changelog](https://github.com/recurly/recurly-client-ruby/compare/4.80.0...4.81.0)
14
+
15
+
16
+ **Merged Pull Requests**
17
+
18
+ - Generated Latest Changes for v2021-02-25 [#968](https://github.com/recurly/recurly-client-ruby/pull/968) ([recurly-integrations](https://github.com/recurly-integrations))
19
+ - Relax base64 dependency to allow 0.3.x [#965](https://github.com/recurly/recurly-client-ruby/pull/965) ([romanoff](https://github.com/romanoff))
20
+
21
+
22
+
3
23
  ## [4.80.0](https://github.com/recurly/recurly-client-ruby/tree/4.80.0) (2026-07-02)
4
24
 
5
25
  [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.82'
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, {}))
@@ -4,87 +4,57 @@
4
4
  # need and we will usher them to the appropriate places.
5
5
  module Recurly
6
6
  module Errors
7
- ERROR_MAP = {
8
- "500" => "InternalServerError",
9
- "502" => "BadGatewayError",
10
- "503" => "ServiceUnavailableError",
11
- "504" => "TimeoutError",
12
- "304" => "NotModifiedError",
13
- "400" => "BadRequestError",
14
- "401" => "UnauthorizedError",
15
- "402" => "PaymentRequiredError",
16
- "403" => "ForbiddenError",
17
- "404" => "NotFoundError",
18
- "406" => "NotAcceptableError",
19
- "412" => "PreconditionFailedError",
20
- "422" => "UnprocessableEntityError",
21
- "429" => "TooManyRequestsError",
22
- }
7
+ class APIError
8
+ self.error_map = {
9
+ "500" => "InternalServerError",
10
+ "502" => "BadGatewayError",
11
+ "503" => "ServiceUnavailableError",
12
+ "504" => "TimeoutError",
13
+ "304" => "NotModifiedError",
14
+ "400" => "BadRequestError",
15
+ "401" => "UnauthorizedError",
16
+ "402" => "PaymentRequiredError",
17
+ "403" => "ForbiddenError",
18
+ "404" => "NotFoundError",
19
+ "406" => "NotAcceptableError",
20
+ "412" => "PreconditionFailedError",
21
+ "422" => "UnprocessableEntityError",
22
+ "429" => "TooManyRequestsError",
23
+ }.freeze
24
+ end
23
25
 
24
26
  class ResponseError < Errors::APIError; end
25
-
26
27
  class ServerError < ResponseError; end
27
-
28
28
  class InternalServerError < ServerError; end
29
-
30
29
  class ServiceNotAvailableError < InternalServerError; end
31
-
32
30
  class TaxServiceError < InternalServerError; end
33
-
34
31
  class BadGatewayError < ServerError; end
35
-
36
32
  class ServiceUnavailableError < ServerError; end
37
-
38
33
  class TimeoutError < ServerError; end
39
-
40
34
  class RedirectionError < ResponseError; end
41
-
42
35
  class NotModifiedError < ResponseError; end
43
-
44
36
  class ClientError < Errors::APIError; end
45
-
46
37
  class BadRequestError < ClientError; end
47
-
48
38
  class InvalidContentTypeError < BadRequestError; end
49
-
50
39
  class UnauthorizedError < ClientError; end
51
-
52
40
  class PaymentRequiredError < ClientError; end
53
-
54
41
  class ForbiddenError < ClientError; end
55
-
56
42
  class InvalidApiKeyError < ForbiddenError; end
57
-
58
43
  class InvalidPermissionsError < ForbiddenError; end
59
-
60
44
  class NotFoundError < ClientError; end
61
-
62
45
  class NotAcceptableError < ClientError; end
63
-
64
46
  class UnknownApiVersionError < NotAcceptableError; end
65
-
66
47
  class UnavailableInApiVersionError < NotAcceptableError; end
67
-
68
48
  class InvalidApiVersionError < NotAcceptableError; end
69
-
70
49
  class PreconditionFailedError < ClientError; end
71
-
72
50
  class UnprocessableEntityError < ClientError; end
73
-
74
51
  class ValidationError < UnprocessableEntityError; end
75
-
76
52
  class MissingFeatureError < UnprocessableEntityError; end
77
-
78
53
  class TransactionError < UnprocessableEntityError; end
79
-
80
54
  class SimultaneousRequestError < UnprocessableEntityError; end
81
-
82
55
  class ImmutableSubscriptionError < UnprocessableEntityError; end
83
-
84
56
  class InvalidTokenError < UnprocessableEntityError; end
85
-
86
57
  class TooManyRequestsError < ClientError; end
87
-
88
58
  class RateLimitedError < TooManyRequestsError; end
89
59
  end
90
60
  end
@@ -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
@@ -5,6 +5,19 @@ module Recurly
5
5
  # @return [Recurly::Resources::Error] The {Recurly::Resources::Error} object
6
6
  attr_reader :recurly_error
7
7
 
8
+ class << self
9
+ private
10
+
11
+ # @param map [Hash] Maps response status codes (String) to error class names (String).
12
+ attr_writer :error_map
13
+
14
+ # Maps a response status code (String) to an error class name (String).
15
+ # Set by the generated errors/api_errors.rb file via .error_map=.
16
+ def error_map
17
+ @error_map || raise("error_map must be set by the generated api_errors.rb file")
18
+ end
19
+ end
20
+
8
21
  # Looks up an Error class by name
9
22
  # @example
10
23
  # Errors.error_class('BadRequestError')
@@ -23,8 +36,8 @@ module Recurly
23
36
  # @param response [Net::Response]
24
37
  # @return [Errors::APIError]
25
38
  def self.from_response(response)
26
- if Recurly::Errors::ERROR_MAP.has_key?(response.code)
27
- Recurly::Errors.const_get(Recurly::Errors::ERROR_MAP[response.code])
39
+ if error_map.has_key?(response.code)
40
+ Recurly::Errors.const_get(error_map[response.code])
28
41
  else
29
42
  Recurly::Errors::APIError
30
43
  end