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 +4 -4
- data/.bumpversion.cfg +1 -1
- data/.github/workflows/ci.yml +5 -3
- data/CHANGELOG.md +12 -0
- data/GETTING_STARTED.md +1 -1
- data/README.md +27 -0
- data/lib/recurly/client.rb +164 -97
- data/lib/recurly/errors/network_errors.rb +25 -0
- data/lib/recurly/http/adapter.rb +22 -0
- data/lib/recurly/http/default_http_adapter.rb +107 -0
- data/lib/recurly/http/http_method.rb +13 -0
- data/lib/recurly/http/response.rb +48 -0
- data/lib/recurly/http.rb +4 -2
- data/lib/recurly/requests/coupon_create.rb +9 -1
- data/lib/recurly/requests/coupon_update.rb +9 -1
- data/lib/recurly/requests/invoice_create.rb +4 -0
- data/lib/recurly/requests/recovery_billing_info_create.rb +5 -1
- data/lib/recurly/requests/recovery_invoice_create.rb +4 -0
- data/lib/recurly/requests/recovery_payment_method_create.rb +34 -0
- data/lib/recurly/requests/subscription_create.rb +4 -0
- data/lib/recurly/requests/subscription_update.rb +4 -0
- data/lib/recurly/resources/coupon.rb +9 -1
- data/lib/recurly/resources/payment_method.rb +2 -2
- data/lib/recurly/resources/transaction.rb +4 -0
- data/lib/recurly/resources/unique_coupon_code.rb +4 -0
- data/lib/recurly/version.rb +1 -1
- data/lib/recurly.rb +4 -0
- data/openapi/api.yaml +10878 -90
- data/recurly.gemspec +1 -1
- metadata +18 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3fcb6e038e5c1315ddf4ea11a1de626cac3eff0f6116309902a13fe2dd4d7d8a
|
|
4
|
+
data.tar.gz: 593fca5091812565ae93b3f592fc07b30014d97c45aab72af066887d1183c7ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0710a16b6de8151e608d13c410fd0aa11dcce94e29f32a255133b5b8f07a6823810f1cba9c72cee2a6fe49638f8377ae665b3c4e2bc26697995eb04be5fea245
|
|
7
|
+
data.tar.gz: 55750c7429a93977fecc70e63f440d2932e340eedc55738cde3ba231110208517cac63f38e6bc774ae1d2e57516cef64f4d2782723b2e5074988381893df6c99
|
data/.bumpversion.cfg
CHANGED
data/.github/workflows/ci.yml
CHANGED
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.
|
|
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).
|
data/lib/recurly/client.rb
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
159
|
+
body = JSON.dump(request_data)
|
|
138
160
|
end
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
request.body = json_body
|
|
173
|
+
body = JSON.dump(request_data)
|
|
152
174
|
end
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
205
|
+
retries = 0
|
|
179
206
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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 =
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
242
|
+
response
|
|
243
|
+
rescue Recurly::Errors::TransportError => ex
|
|
244
|
+
retries += 1
|
|
245
|
+
if retries < MAX_RETRIES
|
|
246
|
+
retry
|
|
247
|
+
end
|
|
225
248
|
|
|
226
|
-
|
|
227
|
-
|
|
249
|
+
case ex.kind
|
|
250
|
+
when :timeout
|
|
228
251
|
raise Recurly::Errors::TimeoutError, "Request timed out"
|
|
229
|
-
|
|
252
|
+
when :ssl
|
|
230
253
|
raise Recurly::Errors::SSLError, ex.message
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
+
if additional_headers
|
|
276
|
+
additional_headers.each { |header, v| set_header(headers, header, v) }
|
|
277
|
+
end
|
|
240
278
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
246
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
317
|
+
if response.content_type&.include?(JSON_CONTENT_TYPE)
|
|
269
318
|
JSONParser.parse(self, response.body)
|
|
270
|
-
elsif BINARY_TYPES.include?(
|
|
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: #{
|
|
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!(
|
|
284
|
-
if response.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(
|
|
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(
|
|
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:
|
|
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,
|
|
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
|