voiceml 0.9.2 → 0.9.3

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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -13
  3. data/lib/voiceml/client.rb +53 -13
  4. data/lib/voiceml/errors.rb +15 -2
  5. data/lib/voiceml/models/accounts.rb +47 -3
  6. data/lib/voiceml/models/applications.rb +6 -0
  7. data/lib/voiceml/models/assistants_v1.rb +40 -0
  8. data/lib/voiceml/models/calls.rb +4 -0
  9. data/lib/voiceml/models/common.rb +35 -4
  10. data/lib/voiceml/models/conferences.rb +8 -0
  11. data/lib/voiceml/models/conversations_v1.rb +131 -29
  12. data/lib/voiceml/models/diagnostics.rb +33 -12
  13. data/lib/voiceml/models/exports.rb +58 -0
  14. data/lib/voiceml/models/incoming_phone_numbers.rb +4 -0
  15. data/lib/voiceml/models/media.rb +4 -0
  16. data/lib/voiceml/models/messages.rb +29 -0
  17. data/lib/voiceml/models/messaging_v1.rb +35 -2
  18. data/lib/voiceml/models/monitor_v1.rb +54 -0
  19. data/lib/voiceml/models/notifications.rb +2 -0
  20. data/lib/voiceml/models/outgoing_caller_ids.rb +6 -0
  21. data/lib/voiceml/models/payments.rb +2 -0
  22. data/lib/voiceml/models/pricing.rb +38 -4
  23. data/lib/voiceml/models/queues.rb +8 -0
  24. data/lib/voiceml/models/recordings.rb +5 -1
  25. data/lib/voiceml/models/routes_v2.rb +6 -2
  26. data/lib/voiceml/models/sip.rb +34 -6
  27. data/lib/voiceml/models/sip_registrations.rb +49 -0
  28. data/lib/voiceml/models/siprec.rb +5 -17
  29. data/lib/voiceml/models/streams.rb +4 -16
  30. data/lib/voiceml/models/transcriptions.rb +35 -4
  31. data/lib/voiceml/models/usage_records.rb +48 -0
  32. data/lib/voiceml/models/user_defined_messages.rb +2 -0
  33. data/lib/voiceml/models/voice_v1.rb +29 -19
  34. data/lib/voiceml/request_validator.rb +125 -0
  35. data/lib/voiceml/resources/accounts.rb +75 -0
  36. data/lib/voiceml/resources/applications.rb +3 -1
  37. data/lib/voiceml/resources/assistants_v1.rb +53 -77
  38. data/lib/voiceml/resources/base.rb +138 -16
  39. data/lib/voiceml/resources/calls.rb +34 -49
  40. data/lib/voiceml/resources/conferences.rb +26 -5
  41. data/lib/voiceml/resources/conversations_v1.rb +147 -172
  42. data/lib/voiceml/resources/diagnostics.rb +20 -35
  43. data/lib/voiceml/resources/exports.rb +36 -0
  44. data/lib/voiceml/resources/incoming_phone_numbers.rb +53 -32
  45. data/lib/voiceml/resources/messages.rb +79 -15
  46. data/lib/voiceml/resources/messaging_v1.rb +47 -13
  47. data/lib/voiceml/resources/monitor_v1.rb +68 -0
  48. data/lib/voiceml/resources/notifications.rb +1 -1
  49. data/lib/voiceml/resources/outgoing_caller_ids.rb +77 -0
  50. data/lib/voiceml/resources/pricing.rb +13 -5
  51. data/lib/voiceml/resources/queues.rb +6 -5
  52. data/lib/voiceml/resources/recordings.rb +39 -5
  53. data/lib/voiceml/resources/routes_v2.rb +10 -4
  54. data/lib/voiceml/resources/sip.rb +30 -11
  55. data/lib/voiceml/resources/transcriptions.rb +40 -0
  56. data/lib/voiceml/resources/usage_records.rb +51 -0
  57. data/lib/voiceml/resources/voice_v1.rb +31 -38
  58. data/lib/voiceml/transport.rb +277 -230
  59. data/lib/voiceml/version.rb +1 -1
  60. data/lib/voiceml.rb +14 -15
  61. metadata +18 -6
@@ -1,323 +1,361 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
3
4
  require 'json'
4
5
  require 'net/http'
6
+ require 'openssl'
7
+ require 'time'
5
8
  require 'uri'
6
9
 
7
10
  require_relative 'errors'
8
11
  require_relative 'version'
9
12
 
10
13
  module VoiceML
11
- # HTTP transport for the VoiceML REST API.
12
- #
13
- # - Auth: HTTP Basic with `account_sid` (Twilio-format `AC` + 32 hex) as the username and the
14
- # per-tenant API key as the password. Drop-in compatible with the Twilio SDK constructor.
15
- # - Wire format: requests are form-urlencoded by default (Twilio convention). The server also
16
- # accepts JSON; pass `json: <hash>` to send JSON instead. Responses are always JSON.
17
- # - Retries: 429 + 5xx are retried up to `max_retries` times with exponential backoff,
18
- # honoring the `Retry-After` header when the server emits one.
19
- # - Binary fetch: `fetch_bytes` follows the 302 -> S3 redirect that
20
- # `GET /Recordings/{sid}.wav` issues when audio has been archived. Callers usually only
21
- # care about the final bytes.
22
- #
14
+ # HTTP transport with exclusive connection use, per-host keep-alive, and
15
+ # method-aware retries. Caller-supplied connections remain caller-owned.
23
16
  # @api private
24
17
  class Transport
25
- DEFAULT_BASE_URL = 'https://voiceml.voicetel.com'
26
- DEFAULT_TIMEOUT = 30
18
+ DEFAULT_BASE_URL = 'https://voiceml.voicetel.com'
19
+ DEFAULT_TIMEOUT = 30
27
20
  DEFAULT_MAX_RETRIES = 2
28
- RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
29
-
30
- # Linear-time trailing-slash strip. Equivalent to `base_url.sub(%r{/+\z}, '')` but
31
- # without the polynomial-backtracking shape CodeQL flags (`rb/polynomial-redos`).
32
- def self.strip_trailing_slashes(s)
33
- i = s.length
34
- i -= 1 while i.positive? && s.getbyte(i - 1) == 47 # '/'
35
- i == s.length ? s : s[0, i]
21
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
22
+ IDEMPOTENT_METHODS = %w[GET HEAD DELETE PUT].freeze
23
+ RETRY_AFTER_CAP = 60.0
24
+ MAX_REDIRECTS = 5
25
+
26
+ # Carries the phase separately from the exception type: an SSL error or
27
+ # ETIMEDOUT during a response is not proof that a POST was never sent.
28
+ class NetworkFailure < StandardError
29
+ attr_reader :original, :connecting
30
+
31
+ def initialize(original, connecting)
32
+ super(original.message)
33
+ @original = original
34
+ @connecting = connecting
35
+ end
36
+ end
37
+ private_constant :NetworkFailure
38
+
39
+ def self.strip_trailing_slashes(value)
40
+ i = value.length
41
+ i -= 1 while i.positive? && value.getbyte(i - 1) == 47
42
+ value[0, i]
36
43
  end
37
44
 
38
45
  attr_reader :account_sid, :base_url, :max_retries, :timeout, :user_agent
39
46
 
40
- def initialize(account_sid:, api_key:, base_url: DEFAULT_BASE_URL,
47
+ # `http_client:` is a Net::HTTP for one origin, or a factory
48
+ # `->(host, port) { Net::HTTP.new(host, port) }` for multiple origins.
49
+ # A one-argument factory receiving a URI is also accepted.
50
+ def initialize(account_sid: nil, api_key: nil, base_url: DEFAULT_BASE_URL,
41
51
  timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES,
42
52
  user_agent: nil, http_client: nil)
43
- raise ConfigurationError, 'account_sid is required' if account_sid.nil? || account_sid.empty?
44
- raise ConfigurationError, 'api_key is required' if api_key.nil? || api_key.empty?
45
- raise ConfigurationError, 'max_retries must be >= 0' if max_retries.negative?
53
+ if !account_sid.nil? && (!account_sid.is_a?(String) || account_sid.empty?)
54
+ raise ConfigurationError, 'account_sid must not be empty'
55
+ end
56
+ if !api_key.nil? && (!api_key.is_a?(String) || api_key.empty?)
57
+ raise ConfigurationError, 'api_key must not be empty'
58
+ end
59
+ unless max_retries.is_a?(Integer) && max_retries >= 0
60
+ raise ConfigurationError, 'max_retries must be an integer >= 0'
61
+ end
62
+ unless timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?
63
+ raise ConfigurationError, 'timeout must be positive and finite'
64
+ end
65
+ unless http_client.nil? || http_client.is_a?(Net::HTTP) || http_client.respond_to?(:call)
66
+ raise ConfigurationError, 'http_client must be a Net::HTTP or a callable'
67
+ end
46
68
 
47
69
  @account_sid = account_sid
48
- @api_key = api_key
49
- @base_url = self.class.strip_trailing_slashes(base_url)
50
- @timeout = timeout
70
+ @credential = -> { api_key }
71
+ begin
72
+ uri = URI.parse(base_url)
73
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
74
+ raise ArgumentError, 'expected an HTTP(S) base URL without credentials, query, or fragment'
75
+ end
76
+ @base_url = self.class.strip_trailing_slashes(base_url)
77
+ rescue URI::Error, ArgumentError, TypeError => e
78
+ raise ConfigurationError, "invalid base_url: #{e.message}"
79
+ end
80
+ @timeout = timeout
51
81
  @max_retries = max_retries
52
- @user_agent = user_agent || "voiceml-ruby/#{VoiceML::VERSION}"
53
- @uri_base = URI.parse(@base_url)
54
- @conn_mutex = Mutex.new
55
- @owns_client = http_client.nil?
56
- @persistent = http_client
82
+ @user_agent = user_agent || "voiceml-ruby/#{VoiceML::VERSION}"
83
+ @http_client = http_client
84
+ @connections = {}
85
+ @mutex = Mutex.new
57
86
  end
58
87
 
59
- # Close the persistent connection, releasing the underlying socket.
60
- # Safe to call multiple times. The transport remains usable — the next
61
- # request will transparently open a fresh connection.
88
+ # Release SDK-owned sockets. In-flight requests finish before close runs;
89
+ # caller-owned clients are retained and never finished by this method.
62
90
  def close
63
- @conn_mutex.synchronize { finish_connection } if @owns_client
91
+ @mutex.synchronize do
92
+ @connections.delete_if do |_origin, (http, owned)|
93
+ finish(http) if owned
94
+ owned
95
+ end
96
+ end
64
97
  end
65
98
 
66
- # Perform a request. Pass `form:` for a form-urlencoded POST body, `json:` for a JSON body,
67
- # or neither for a plain GET/DELETE. `params:` are query-string params.
68
- #
69
- # `base_url:` overrides the client's default host for this one call (used to route product
70
- # resources at their subdomain — Conversations, Messaging Service). When set, the request
71
- # goes to an absolute URL so the correct host/SNI is used per request; see `ScopedTransport`.
72
- #
73
- # @return [Hash, Array, nil] the parsed JSON body (or `nil` for empty 2xx).
74
- # @raise [VoiceML::ApiError] for non-2xx responses (subclasses by status family).
75
- def request(method, path, params: nil, form: nil, json: nil, base_url: nil)
76
- uri = build_uri(request_path(path, base_url), params)
77
-
78
- attempt = 0
79
- loop do
80
- response = perform_request(method, uri, form: form, json: json)
81
-
82
- if RETRYABLE_STATUSES.include?(response.code.to_i) && attempt < @max_retries
83
- sleep(backoff_delay(attempt, response))
84
- attempt += 1
85
- next
86
- end
99
+ def request(method, path, params: nil, form: nil, json: nil, base_url: nil, auth: true)
100
+ uri = build_uri(path, base_url, params)
101
+ parse_response(send_with_retries(method, uri, form: form, json: json, auth: auth))
102
+ end
87
103
 
88
- return parse_response(response)
89
- rescue *transport_errors => e
90
- raise ApiError.new("transport error after #{attempt + 1} attempts: #{e.message}",
91
- status_code: 0) if attempt >= @max_retries
104
+ # Raw text and JSON use the same client, timeout, auth and retry policy.
105
+ def request_raw(method, path, params: nil, base_url: nil, auth: true)
106
+ uri = build_uri(path, base_url, params)
107
+ response = send_with_retries(method, uri, auth: auth, accept: '*/*')
108
+ raise_for_response(response) unless response.is_a?(Net::HTTPSuccess)
92
109
 
93
- sleep(backoff_delay(attempt))
94
- attempt += 1
95
- end
110
+ [response.code.to_i, response.body.to_s, response.each_header.to_h]
96
111
  end
97
112
 
98
- # Fetch a binary payload (audio/wav recordings). Follows the single 302 -> presigned S3
99
- # redirect that `GET /Recordings/{sid}.wav` issues when audio has been archived.
100
- #
101
- # @return [Array(Integer, String, Hash)] status code, response body bytes, header hash.
113
+ # Authenticated binary GET with manual, origin-aware redirect handling.
102
114
  def fetch_bytes(path, base_url: nil)
103
- uri = build_uri(request_path(path, base_url), nil)
104
- visited = []
115
+ origin = build_uri(path, base_url, nil)
116
+ uri = origin
117
+ redirects = 0
105
118
  loop do
106
- raise ApiError.new('too many redirects', status_code: 0) if visited.length > 5
119
+ response = send_with_retries(:get, uri, auth: same_origin?(origin, uri), accept: '*/*')
120
+ unless response.is_a?(Net::HTTPRedirection)
121
+ raise_for_response(response) unless response.is_a?(Net::HTTPSuccess)
107
122
 
108
- req = Net::HTTP::Get.new(uri)
109
- apply_common_headers(req, auth: !visited.any? { |u| u.host != uri.host })
110
- response = send_on_persistent(uri, req)
123
+ return [response.code.to_i, response.body.to_s, response.each_header.to_h]
124
+ end
125
+ raise ConnectionError.new('too many redirects', status_code: 0) if redirects >= MAX_REDIRECTS
111
126
 
112
- case response
113
- when Net::HTTPRedirection
114
- visited << uri
115
- uri = URI.parse(response['location'])
116
- next
117
- when Net::HTTPSuccess
118
- headers = response.each_header.to_h
119
- return [response.code.to_i, response.body || '', headers]
120
- else
121
- raise_for_response(response)
127
+ target = resolve_redirect(uri, response['location'])
128
+ if uri.scheme == 'https' && target.scheme == 'http'
129
+ raise ConnectionError.new('refusing an https -> http redirect', status_code: 0)
122
130
  end
131
+ uri = target
132
+ redirects += 1
123
133
  end
124
134
  end
125
135
 
136
+ def inspect
137
+ "#<#{self.class.name} account_sid=#{@account_sid} base_url=#{@base_url}>"
138
+ end
139
+ alias to_s inspect
140
+
126
141
  private
127
142
 
128
- def transport_errors
129
- [Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET,
130
- Errno::EHOSTUNREACH, SocketError, EOFError]
143
+ def build_uri(path, override, params)
144
+ base = override || @base_url
145
+ uri = URI.parse(path)
146
+ uri = URI.join("#{base}/", path) unless uri.absolute?
147
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo
148
+ raise ArgumentError, 'expected an HTTP(S) request URL without userinfo'
149
+ end
150
+ uri.query = encode_values(params) if params && !params.empty?
151
+ uri
152
+ rescue URI::Error, ArgumentError, TypeError => e
153
+ raise ConnectionError.new("invalid request URI: #{e.message}", status_code: 0)
131
154
  end
132
155
 
133
- # Prefix `path` with a per-request `base_url` override, if any. Leaves absolute paths and
134
- # the default-host case untouched so `build_uri` handles them as before.
135
- def request_path(path, base_url)
136
- return path if base_url.nil? || base_url.empty?
156
+ def send_with_retries(method, uri, form: nil, json: nil, auth: true, accept: 'application/json')
157
+ verb = method.to_s.upcase
158
+ idempotent = IDEMPOTENT_METHODS.include?(verb)
159
+ attempt = 0
160
+ loop do
161
+ req = build_net_request(verb, uri)
162
+ req['Accept'] = accept
163
+ req['User-Agent'] = @user_agent
164
+ if auth
165
+ key = @credential.call
166
+ unless @account_sid && key
167
+ raise ConfigurationError, 'account_sid and api_key are required for authenticated requests'
168
+ end
169
+ req.basic_auth(@account_sid, key)
170
+ end
171
+ if json
172
+ req['Content-Type'] = 'application/json'
173
+ req.body = JSON.generate(json)
174
+ elsif form
175
+ req['Content-Type'] = 'application/x-www-form-urlencoded'
176
+ req.body = encode_values(form)
177
+ end
178
+
179
+ response = exchange(uri, req)
180
+ status = response.code.to_i
181
+ retry_after = retry_after_seconds(response)
182
+ if status == 429 && retry_after && retry_after > RETRY_AFTER_CAP
183
+ raise_for_response(response)
184
+ end
185
+ if attempt < @max_retries && (status == 429 || (idempotent && RETRYABLE_STATUSES.include?(status)))
186
+ response.body # Net::HTTP buffers the complete body before releasing the connection.
187
+ delay = retry_after || backoff_delay(attempt)
188
+ sleep_for(delay) if delay.positive?
189
+ attempt += 1
190
+ next
191
+ end
192
+ return response
193
+ rescue NetworkFailure => e
194
+ # These failures can only occur while establishing the connection
195
+ # (DNS, routing, TCP connect, TLS handshake), so the request was
196
+ # provably never sent — safe to retry even for POST.
197
+ safe_connect = [SocketError, Net::OpenTimeout, OpenSSL::SSL::SSLError,
198
+ Errno::ECONNREFUSED, Errno::ETIMEDOUT,
199
+ Errno::ENETUNREACH, Errno::EHOSTUNREACH].any? { |c| e.original.is_a?(c) }
200
+ if attempt < @max_retries && (idempotent || safe_connect)
201
+ sleep_for(backoff_delay(attempt))
202
+ attempt += 1
203
+ next
204
+ end
205
+ raise ConnectionError.new("transport error after #{attempt + 1} attempt(s): #{e.original.message}",
206
+ status_code: 0), cause: e.original
207
+ end
208
+ end
137
209
 
138
- "#{base_url}#{path}"
210
+ # The lock covers both connection selection and the entire response read.
211
+ # Host changes do not finish connections belonging to another origin.
212
+ def exchange(uri, req)
213
+ @mutex.synchronize do
214
+ origin = [uri.scheme, uri.host, uri.port]
215
+ connecting = true
216
+ http, owned = @connections[origin] ||= connection_for(uri)
217
+ http.max_retries = 0 # Only the counted SDK retry loop may resend a request.
218
+ http.start unless http.started?
219
+ connecting = false
220
+ http.request(req)
221
+ rescue Error
222
+ raise
223
+ rescue StandardError => e
224
+ @connections.delete(origin)
225
+ finish(http) if http && owned
226
+ raise NetworkFailure.new(e, connecting), cause: e
227
+ end
139
228
  end
140
229
 
141
- def build_uri(path, params)
142
- uri = if path.start_with?('http://', 'https://')
143
- URI.parse(path)
144
- else
145
- URI.parse("#{@base_url}#{path}")
146
- end
147
- if params && !params.empty?
148
- encoded = encode_query(params)
149
- uri.query = encoded unless encoded.empty?
230
+ def connection_for(uri)
231
+ owned = @http_client.nil?
232
+ http = if owned
233
+ Net::HTTP.new(uri.host, uri.port)
234
+ elsif @http_client.is_a?(Net::HTTP)
235
+ @http_client
236
+ elsif @http_client.method(:call).arity == 1 || (@http_client.respond_to?(:arity) && @http_client.arity == 1)
237
+ @http_client.call(uri)
238
+ else
239
+ @http_client.call(uri.host, uri.port)
240
+ end
241
+ unless http.is_a?(Net::HTTP) && http.address == uri.host && http.port == uri.port
242
+ raise ConfigurationError, 'http_client must match the requested host/port; use a factory for multiple hosts'
150
243
  end
151
- uri
244
+ if !http.started?
245
+ http.use_ssl = uri.scheme == 'https'
246
+ elsif http.use_ssl? != (uri.scheme == 'https')
247
+ raise ConfigurationError, 'http_client TLS scheme does not match the requested origin'
248
+ end
249
+ if owned
250
+ http.open_timeout = @timeout
251
+ http.read_timeout = @timeout
252
+ http.write_timeout = @timeout
253
+ http.keep_alive_timeout = 30
254
+ end
255
+ [http, owned]
152
256
  end
153
257
 
154
- def encode_query(params)
155
- pairs = []
156
- params.each do |k, v|
157
- next if v.nil?
258
+ def finish(http)
259
+ http.finish if http.started?
260
+ rescue IOError, SystemCallError
261
+ nil # A closed socket already satisfies close's postcondition.
262
+ end
158
263
 
159
- if v.is_a?(Array)
160
- v.each { |entry| pairs << [k.to_s, format_scalar(entry)] }
161
- else
162
- pairs << [k.to_s, format_scalar(v)]
163
- end
164
- end
165
- URI.encode_www_form(pairs)
264
+ def build_net_request(verb, uri)
265
+ klass = { 'GET' => Net::HTTP::Get, 'HEAD' => Net::HTTP::Head, 'POST' => Net::HTTP::Post,
266
+ 'PUT' => Net::HTTP::Put, 'PATCH' => Net::HTTP::Patch, 'DELETE' => Net::HTTP::Delete }[verb]
267
+ raise ArgumentError, "unsupported HTTP method: #{verb}" unless klass
268
+
269
+ klass.new(uri)
166
270
  end
167
271
 
168
- def encode_form(form)
272
+ def encode_values(values)
169
273
  pairs = []
170
- form.each do |k, v|
171
- next if v.nil?
274
+ values.each do |key, value|
275
+ next if value.nil?
172
276
 
173
- if v.is_a?(Array)
174
- v.each { |entry| pairs << [k.to_s, format_scalar(entry)] }
175
- else
176
- pairs << [k.to_s, format_scalar(v)]
177
- end
277
+ (value.is_a?(Array) ? value : [value]).each { |v| pairs << [key.to_s, format_scalar(v)] }
178
278
  end
179
279
  URI.encode_www_form(pairs)
180
280
  end
181
281
 
182
282
  def format_scalar(value)
183
283
  case value
184
- when true then 'true'
185
- when false then 'false'
284
+ when Time then value.getutc.iso8601
285
+ when DateTime then value.new_offset(0).iso8601
286
+ when Date then value.iso8601
287
+ when Hash then JSON.generate(value)
186
288
  else value.to_s
187
289
  end
188
290
  end
189
291
 
190
- def perform_request(method, uri, form:, json:)
191
- req = build_net_request(method, uri)
192
- apply_common_headers(req, auth: true)
193
-
194
- if !json.nil?
195
- req['Content-Type'] = 'application/json'
196
- req.body = JSON.generate(json)
197
- elsif !form.nil? && method.to_s.upcase != 'GET' && method.to_s.upcase != 'DELETE'
198
- req['Content-Type'] = 'application/x-www-form-urlencoded'
199
- req.body = encode_form(form)
200
- end
292
+ def retry_after_seconds(response)
293
+ text = response['retry-after']
294
+ return nil unless text
295
+ return text.to_f if text.match?(/\A\d+(?:\.\d+)?\z/)
201
296
 
202
- send_on_persistent(uri, req)
297
+ [Time.httpdate(text) - Time.now, 0.0].max
298
+ rescue ArgumentError
299
+ nil
203
300
  end
204
301
 
205
- def build_net_request(method, uri)
206
- case method.to_s.upcase
207
- when 'GET' then Net::HTTP::Get.new(uri)
208
- when 'POST' then Net::HTTP::Post.new(uri)
209
- when 'PUT' then Net::HTTP::Put.new(uri)
210
- when 'DELETE' then Net::HTTP::Delete.new(uri)
211
- when 'PATCH' then Net::HTTP::Patch.new(uri)
212
- else
213
- raise ArgumentError, "unsupported HTTP method: #{method}"
214
- end
302
+ def backoff_delay(attempt)
303
+ rand * [8.0, 0.5 * (2**[attempt, 5].min)].min
215
304
  end
216
305
 
217
- def apply_common_headers(req, auth:)
218
- req['Accept'] = 'application/json'
219
- req['User-Agent'] = @user_agent
220
- req.basic_auth(@account_sid, @api_key) if auth
306
+ def sleep_for(seconds)
307
+ sleep(seconds)
221
308
  end
222
309
 
223
- def send_on_persistent(uri, req)
224
- conn = persistent_connection(uri)
225
- conn.request(req)
226
- rescue IOError, Errno::EPIPE, Errno::ECONNRESET
227
- @conn_mutex.synchronize { finish_connection }
228
- persistent_connection(uri).request(req)
229
- end
310
+ def resolve_redirect(uri, location)
311
+ raise ArgumentError, 'redirect missing Location' if location.nil? || location.empty?
230
312
 
231
- def persistent_connection(uri)
232
- @conn_mutex.synchronize do
233
- if @persistent&.started? &&
234
- @persistent.address == uri.host &&
235
- @persistent.port == uri.port
236
- return @persistent
237
- end
238
- finish_connection
239
- h = Net::HTTP.new(uri.host, uri.port)
240
- h.use_ssl = uri.scheme == 'https'
241
- h.open_timeout = @timeout
242
- h.read_timeout = @timeout
243
- h.keep_alive_timeout = 30
244
- h.start
245
- @persistent = h
246
- end
313
+ target = URI.join(uri.to_s, location)
314
+ raise ArgumentError, 'redirect must use HTTP(S) without userinfo' unless target.is_a?(URI::HTTP) && target.host && !target.userinfo
315
+
316
+ target
317
+ rescue URI::Error, ArgumentError => e
318
+ raise ConnectionError.new("invalid redirect: #{e.message}", status_code: 0)
247
319
  end
248
320
 
249
- def finish_connection
250
- @persistent&.finish rescue nil
251
- @persistent = nil
321
+ def same_origin?(a, b)
322
+ a.scheme == b.scheme && a.host == b.host && a.port == b.port
252
323
  end
253
324
 
254
325
  def parse_response(response)
255
- status = response.code.to_i
256
- return nil if status >= 200 && status < 300 && (response.body.nil? || response.body.empty?)
257
-
258
- if status >= 200 && status < 300
259
- begin
260
- return JSON.parse(response.body)
261
- rescue JSON::ParserError => e
262
- raise ApiError.new("non-JSON success response (#{e.message}): #{response.body.to_s[0, 200]}",
263
- status_code: status, body: response.body)
264
- end
265
- end
326
+ raise_for_response(response) unless response.is_a?(Net::HTTPSuccess)
327
+ return nil if response.body.nil? || response.body.empty?
266
328
 
267
- raise_for_response(response)
329
+ JSON.parse(response.body)
330
+ rescue JSON::ParserError => e
331
+ raise ApiError.new("could not decode success response as JSON: #{e.message}",
332
+ status_code: response.code.to_i, body: response.body)
268
333
  end
269
334
 
270
335
  def raise_for_response(response)
271
- status = response.code.to_i
272
- raw_body = response.body
336
+ raw = response.body
273
337
  body = begin
274
- raw_body && !raw_body.empty? ? JSON.parse(raw_body) : raw_body
338
+ raw.nil? || raw.empty? ? raw : JSON.parse(raw)
275
339
  rescue JSON::ParserError
276
- raw_body
340
+ raw
277
341
  end
278
-
279
- code = nil
280
- message = "HTTP #{status}"
281
- more_info = nil
282
- if body.is_a?(Hash)
283
- rc = body['code']
284
- code = rc if rc.is_a?(Integer) || rc.is_a?(String)
285
- m = body['message']
286
- message = m if m.is_a?(String) && !m.empty?
287
- mi = body['more_info']
288
- more_info = mi if mi.is_a?(String) && !mi.empty?
289
- end
290
-
291
- raise VoiceML.error_from_response(status, message, code: code, body: body, more_info: more_info)
292
- end
293
-
294
- def backoff_delay(attempt, response = nil)
295
- if response
296
- ra = response['retry-after']
297
- if ra
298
- begin
299
- return [Float(ra), 0.0].max
300
- rescue ArgumentError, TypeError
301
- # fall through
302
- end
303
- end
304
- end
305
- [8.0, 0.5 * (2**attempt)].min
342
+ data = body.is_a?(Hash) ? body : {}
343
+ code = data['code']
344
+ code = code.to_i if code.is_a?(String) && code.match?(/\A\d+\z/)
345
+ error = VoiceML.error_from_response(response.code.to_i, data['message'] || "HTTP #{response.code}",
346
+ code: code, body: body, more_info: data['more_info'])
347
+ error.retry_after = retry_after_seconds(response) if error.is_a?(RateLimitError)
348
+ raise error
306
349
  end
307
350
  end
308
351
 
309
- # A transport view pinned to a specific product base URL.
310
- #
311
- # Wraps a real `Transport` and forwards every call, injecting a `base_url` override so an
312
- # entire resource group lands on its product subdomain (`conversations.voicetel.com` /
313
- # `messaging.voicetel.com`) without each resource needing to know its own host.
314
- #
352
+ # A redacting transport view pinned to one product origin.
315
353
  # @api private
316
354
  class ScopedTransport
317
355
  attr_reader :base_url
318
356
 
319
357
  def initialize(inner, base_url)
320
- @inner = inner
358
+ @inner = inner
321
359
  @base_url = Transport.strip_trailing_slashes(base_url)
322
360
  end
323
361
 
@@ -325,12 +363,21 @@ module VoiceML
325
363
  @inner.account_sid
326
364
  end
327
365
 
328
- def request(method, path, params: nil, form: nil, json: nil)
329
- @inner.request(method, path, params: params, form: form, json: json, base_url: @base_url)
366
+ def request(method, path, **options)
367
+ @inner.request(method, path, **options, base_url: @base_url)
368
+ end
369
+
370
+ def request_raw(method, path, **options)
371
+ @inner.request_raw(method, path, **options, base_url: @base_url)
330
372
  end
331
373
 
332
374
  def fetch_bytes(path)
333
375
  @inner.fetch_bytes(path, base_url: @base_url)
334
376
  end
377
+
378
+ def inspect
379
+ "#<#{self.class.name} base_url=#{@base_url}>"
380
+ end
381
+ alias to_s inspect
335
382
  end
336
383
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VoiceML
4
- VERSION = '0.9.2'
4
+ VERSION = '0.9.3'
5
5
  end