calmlens 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +222 -0
- data/lib/calmlens/assets.rb +328 -0
- data/lib/calmlens/cdn.rb +63 -0
- data/lib/calmlens/error_codes.rb +133 -0
- data/lib/calmlens/errors.rb +154 -0
- data/lib/calmlens/events.rb +46 -0
- data/lib/calmlens/folders.rb +63 -0
- data/lib/calmlens/internal.rb +141 -0
- data/lib/calmlens/lenses.rb +16 -0
- data/lib/calmlens/resource.rb +51 -0
- data/lib/calmlens/response.rb +82 -0
- data/lib/calmlens/server_client.rb +134 -0
- data/lib/calmlens/session.rb +246 -0
- data/lib/calmlens/testing/fake_api.rb +570 -0
- data/lib/calmlens/testing.rb +141 -0
- data/lib/calmlens/text.rb +29 -0
- data/lib/calmlens/transforms.rb +104 -0
- data/lib/calmlens/transport.rb +129 -0
- data/lib/calmlens/upload_body.rb +149 -0
- data/lib/calmlens/upload_tokens.rb +52 -0
- data/lib/calmlens/version.rb +6 -0
- data/lib/calmlens/webhooks.rb +122 -0
- data/lib/calmlens.rb +26 -0
- data/sig/calmlens.rbs +273 -0
- data/sig/error_codes.rbs +67 -0
- data/sig/types.rbs +682 -0
- metadata +75 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# An HTTP response. {CDN#serve} returns one; custom transports and the mock produce them.
|
|
5
|
+
#
|
|
6
|
+
# The body is read at most once: either stream it with {#each_chunk} or read it with {#body}.
|
|
7
|
+
class Response
|
|
8
|
+
# @return [Integer] the HTTP status code.
|
|
9
|
+
attr_reader :status
|
|
10
|
+
# @return [Hash{String => String}] headers with lowercase names.
|
|
11
|
+
attr_reader :headers
|
|
12
|
+
|
|
13
|
+
# @param status [Integer]
|
|
14
|
+
# @param headers [Hash{String => String}]
|
|
15
|
+
# @param body [String, nil] a complete body. Give a block instead to stream chunks.
|
|
16
|
+
# @yield [chunk] a block that yields each body chunk to its block argument.
|
|
17
|
+
def initialize(status:, headers: {}, body: nil, &reader)
|
|
18
|
+
@status = Integer(status)
|
|
19
|
+
@headers = headers.to_h { |name, value| [name.to_s.downcase, value.is_a?(Array) ? value.join(', ') : value.to_s] }.freeze
|
|
20
|
+
@body = body&.b
|
|
21
|
+
@reader = reader
|
|
22
|
+
@consumed = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [String, nil] the `X-Request-Id` header.
|
|
26
|
+
def request_id = headers['x-request-id']
|
|
27
|
+
# @return [String, nil] the `Content-Type` header.
|
|
28
|
+
def content_type = headers['content-type']
|
|
29
|
+
|
|
30
|
+
# @return [Integer, nil] the `Content-Length` header.
|
|
31
|
+
def content_length
|
|
32
|
+
Integer(headers['content-length'], 10, exception: false)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] true for a 2xx status.
|
|
36
|
+
def success? = status.between?(200, 299)
|
|
37
|
+
|
|
38
|
+
# Streams the body in chunks without holding it in memory.
|
|
39
|
+
# @yieldparam chunk [String] binary bytes
|
|
40
|
+
# @return [void]
|
|
41
|
+
def each_chunk(&block)
|
|
42
|
+
return enum_for(:each_chunk) unless block
|
|
43
|
+
|
|
44
|
+
if @body
|
|
45
|
+
block.call(@body) unless @body.empty?
|
|
46
|
+
else
|
|
47
|
+
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, 'The response body was already read') if @consumed
|
|
48
|
+
|
|
49
|
+
@reader&.call(&block)
|
|
50
|
+
end
|
|
51
|
+
@consumed = true
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Reads the whole body into memory.
|
|
56
|
+
# @param limit [Integer, nil] maximum bytes; a longer body raises {InvalidResponseError}.
|
|
57
|
+
# @return [String] binary bytes
|
|
58
|
+
def body(limit: nil)
|
|
59
|
+
if @body
|
|
60
|
+
too_large!(limit) if limit && @body.bytesize > limit
|
|
61
|
+
return @body
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
buffer = String.new(capacity: [content_length || 0, 1 << 20].min, encoding: Encoding::BINARY)
|
|
65
|
+
each_chunk do |chunk|
|
|
66
|
+
buffer << chunk
|
|
67
|
+
too_large!(limit) if limit && buffer.bytesize > limit
|
|
68
|
+
end
|
|
69
|
+
@body = buffer
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [String]
|
|
73
|
+
def inspect = "#<#{self.class.name} status=#{status} content_type=#{content_type.inspect}>"
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def too_large!(limit)
|
|
78
|
+
raise InvalidResponseError.new(SdkErrorCodes::RESPONSE_TOO_LARGE, "Response body exceeds #{limit} bytes",
|
|
79
|
+
headers: headers)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module CalmLens
|
|
6
|
+
# The CalmLens API client. Create one per process (or per project) and reuse it: it keeps
|
|
7
|
+
# keep-alive connections, is safe to share between threads, and reconnects after `fork`.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# client = CalmLens::ServerClient.new(api_key: ENV.fetch('CALMLENS_API_KEY'),
|
|
11
|
+
# project_id: ENV.fetch('CALMLENS_PROJECT_ID'))
|
|
12
|
+
# asset = client.text.moderate(text: 'Hello world')
|
|
13
|
+
# client.close
|
|
14
|
+
class ServerClient
|
|
15
|
+
DEFAULT_BASE_URL = 'https://api.calmlens.com/v1'
|
|
16
|
+
DEFAULT_CDN_BASE_URL = 'https://cdn.calmlens.com/v1'
|
|
17
|
+
|
|
18
|
+
# @return [Assets]
|
|
19
|
+
attr_reader :assets
|
|
20
|
+
# @return [Folders]
|
|
21
|
+
attr_reader :folders
|
|
22
|
+
# @return [Text]
|
|
23
|
+
attr_reader :text
|
|
24
|
+
# @return [UploadTokens]
|
|
25
|
+
attr_reader :upload_tokens
|
|
26
|
+
# @return [Events]
|
|
27
|
+
attr_reader :events
|
|
28
|
+
# @return [CDN]
|
|
29
|
+
attr_reader :cdn
|
|
30
|
+
|
|
31
|
+
# @param api_key [String] a project API key. Keep it on your server.
|
|
32
|
+
# @param project_id [String]
|
|
33
|
+
# @param base_url [String] the API base URL.
|
|
34
|
+
# @param cdn_base_url [String] the CDN base URL.
|
|
35
|
+
# @param timeout [Numeric] seconds allowed for each socket read or write, including waiting for a
|
|
36
|
+
# moderation decision. It is not a deadline for a whole transfer, so large uploads and downloads
|
|
37
|
+
# are not cut off. Override per call with `request_options: {timeout:}`.
|
|
38
|
+
# @param open_timeout [Numeric] seconds allowed to open a connection.
|
|
39
|
+
# @param max_retries [Integer] retries of requests that are safe to repeat: GET/HEAD, and uploads,
|
|
40
|
+
# which carry an `Idempotency-Key`. Connection failures, timeouts, 429, 502, 503, 504 and
|
|
41
|
+
# `idempotency/request-in-progress` are retried with jittered exponential backoff, honouring
|
|
42
|
+
# `Retry-After`. Other writes are never retried. 0 disables retries.
|
|
43
|
+
# @param retry_base_delay [Numeric] seconds before the first retry when the API gives no
|
|
44
|
+
# `Retry-After`; later retries wait up to twice as long each time, at most 8 seconds.
|
|
45
|
+
# @param transport [#call, nil] a custom {Transport}; defaults to {Transport::NetHTTP}.
|
|
46
|
+
# @raise [ConfigurationError] `sdk/invalid-config` for a missing or malformed setting.
|
|
47
|
+
def initialize(api_key:, project_id:, base_url: DEFAULT_BASE_URL, cdn_base_url: DEFAULT_CDN_BASE_URL,
|
|
48
|
+
timeout: 120, open_timeout: 10, max_retries: 2, retry_base_delay: 0.5, transport: nil)
|
|
49
|
+
check_config(api_key, project_id, timeout, open_timeout, max_retries, retry_base_delay, transport)
|
|
50
|
+
@project_id = project_id
|
|
51
|
+
@base_url = ServerClient.check_url(base_url, 'base_url')
|
|
52
|
+
@cdn_base_url = ServerClient.check_url(cdn_base_url, 'cdn_base_url')
|
|
53
|
+
@session = Session.new(api_key: api_key, project_id: project_id, base_url: @base_url,
|
|
54
|
+
cdn_base_url: @cdn_base_url, timeout: timeout, open_timeout: open_timeout,
|
|
55
|
+
max_retries: max_retries, retry_base_delay: retry_base_delay,
|
|
56
|
+
transport: transport || Transport::NetHTTP.new)
|
|
57
|
+
@assets = Assets.new(@session)
|
|
58
|
+
@folders = Folders.new(@session)
|
|
59
|
+
@text = Text.new(@session, @assets)
|
|
60
|
+
@upload_tokens = UploadTokens.new(@session, api_key)
|
|
61
|
+
@events = Events.new(@session)
|
|
62
|
+
@cdn = CDN.new(@session)
|
|
63
|
+
@lenses = Lenses.new(@session)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [String]
|
|
67
|
+
attr_reader :project_id
|
|
68
|
+
# @return [String] the API base URL, without a trailing slash.
|
|
69
|
+
attr_reader :base_url
|
|
70
|
+
# @return [String] the CDN base URL, without a trailing slash.
|
|
71
|
+
attr_reader :cdn_base_url
|
|
72
|
+
|
|
73
|
+
# Never shows the API key, including in resource objects that reference the client.
|
|
74
|
+
def inspect = "#<#{self.class.name} project_id=#{@project_id.inspect} base_url=#{@base_url.inspect} api_key=[REDACTED]>"
|
|
75
|
+
|
|
76
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
77
|
+
|
|
78
|
+
# Closes pooled connections. Requests made afterwards fail with `sdk/transport-error`.
|
|
79
|
+
# @return [void]
|
|
80
|
+
def close
|
|
81
|
+
@session.close
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Creates or updates named lenses (saved transform pipelines) and returns them. Definitions
|
|
86
|
+
# this client already saved are not sent again. Supplying the same definition and version is
|
|
87
|
+
# idempotent; changing a lens needs a higher `version`. More than 100 new definitions are sent in
|
|
88
|
+
# consecutive requests, in order.
|
|
89
|
+
# @option options [Array<Hash>] :lenses `[{identifier:, name:, type:, version:, transforms:, ...}]`
|
|
90
|
+
# @return [Array<Hash>] the saved lenses, in the given order.
|
|
91
|
+
def ensure_lenses(options) = @lenses.ensure_lenses(options)
|
|
92
|
+
|
|
93
|
+
# Creates or updates one lens. Takes one lens definition; see {#ensure_lenses}.
|
|
94
|
+
# @return [Hash] the saved lens
|
|
95
|
+
def ensure_lens(options) = @lenses.ensure_lens(options)
|
|
96
|
+
|
|
97
|
+
# @api private
|
|
98
|
+
def self.check_url(value, name)
|
|
99
|
+
uri = URI.parse(value.to_s)
|
|
100
|
+
unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty? && uri.userinfo.nil? &&
|
|
101
|
+
uri.query.nil? && uri.fragment.nil?
|
|
102
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG,
|
|
103
|
+
"#{name} must be an http(s) URL without credentials, query or fragment")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
value.to_s.sub(%r{/+\z}, '')
|
|
107
|
+
rescue URI::Error
|
|
108
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, "#{name} is not a valid URL")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def check_config(api_key, project_id, timeout, open_timeout, max_retries, retry_base_delay, transport)
|
|
114
|
+
unless api_key.is_a?(String) && !api_key.empty? && api_key.match?(/\A[\x21-\x7e]+\z/)
|
|
115
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'api_key must be a non-empty printable ASCII String')
|
|
116
|
+
end
|
|
117
|
+
unless project_id.is_a?(String) && !project_id.empty? && !%w[. ..].include?(project_id)
|
|
118
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'project_id is required')
|
|
119
|
+
end
|
|
120
|
+
unless Internal.positive?(timeout) && Internal.positive?(open_timeout)
|
|
121
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'timeout and open_timeout must be positive numbers of seconds')
|
|
122
|
+
end
|
|
123
|
+
unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
124
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'max_retries must be a non-negative Integer')
|
|
125
|
+
end
|
|
126
|
+
unless retry_base_delay.is_a?(Numeric) && retry_base_delay.real? && retry_base_delay.to_f.finite? && retry_base_delay >= 0
|
|
127
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'retry_base_delay must be a non-negative number')
|
|
128
|
+
end
|
|
129
|
+
return if transport.nil? || transport.respond_to?(:call)
|
|
130
|
+
|
|
131
|
+
raise ConfigurationError.new(SdkErrorCodes::INVALID_CONFIG, 'transport must respond to call')
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
module CalmLens
|
|
9
|
+
# Sends API and CDN requests: authentication, JSON encoding, retries and error mapping.
|
|
10
|
+
# The resources share one session per client. It never prints the API key.
|
|
11
|
+
# @api private
|
|
12
|
+
class Session
|
|
13
|
+
MAX_JSON_BYTES = 32 * 1024 * 1024
|
|
14
|
+
MAX_ERROR_BYTES = 1024 * 1024
|
|
15
|
+
MAX_RETRY_DELAY = 60
|
|
16
|
+
MAX_BACKOFF = 8
|
|
17
|
+
ENSURE_LENSES_MAX_PER_REQUEST = 100
|
|
18
|
+
USER_AGENT = "calmlens-ruby/#{VERSION} (ruby #{RUBY_VERSION})".freeze
|
|
19
|
+
|
|
20
|
+
# Raised through the transport so an exception from the caller's streaming block is
|
|
21
|
+
# re-raised unchanged instead of being mistaken for a network failure.
|
|
22
|
+
class CallerError < Exception # rubocop:disable Lint/InheritException
|
|
23
|
+
attr_reader :error
|
|
24
|
+
|
|
25
|
+
def initialize(error)
|
|
26
|
+
super(error.message)
|
|
27
|
+
@error = error
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :project_id, :base_url, :cdn_base_url
|
|
32
|
+
|
|
33
|
+
def initialize(api_key:, project_id:, base_url:, cdn_base_url:, timeout:, open_timeout:, max_retries:,
|
|
34
|
+
retry_base_delay:, transport:)
|
|
35
|
+
@api_key = api_key
|
|
36
|
+
@project_id = project_id
|
|
37
|
+
@base_url = base_url
|
|
38
|
+
@cdn_base_url = cdn_base_url
|
|
39
|
+
@timeout = timeout
|
|
40
|
+
@open_timeout = open_timeout
|
|
41
|
+
@max_retries = max_retries
|
|
42
|
+
@retry_base_delay = retry_base_delay
|
|
43
|
+
@transport = transport
|
|
44
|
+
@lenses = {}
|
|
45
|
+
@lens_lock = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def inspect = "#<#{self.class.name} project_id=#{@project_id.inspect} api_key=[REDACTED]>"
|
|
49
|
+
|
|
50
|
+
def close
|
|
51
|
+
@transport.close if @transport.respond_to?(:close)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def project(path) = "/projects/#{Internal.segment(@project_id)}/#{path}"
|
|
55
|
+
|
|
56
|
+
# Sends an API request and returns the parsed JSON body.
|
|
57
|
+
#
|
|
58
|
+
# @param idempotent [Boolean] send an `Idempotency-Key` (generated unless the caller gave one),
|
|
59
|
+
# reused across retries so the API runs the request at most once.
|
|
60
|
+
def request(method, path, request_options:, query: {}, body: nil, raw: nil, headers: {}, idempotent: false)
|
|
61
|
+
url = @base_url + path
|
|
62
|
+
params = Internal.query(query)
|
|
63
|
+
url += "?#{URI.encode_www_form(params)}" unless params.empty?
|
|
64
|
+
headers = headers.merge('Accept' => 'application/json')
|
|
65
|
+
unless body.nil?
|
|
66
|
+
raw = JSON.generate(body)
|
|
67
|
+
headers['Content-Type'] = 'application/json'
|
|
68
|
+
end
|
|
69
|
+
if raw.is_a?(UploadBody)
|
|
70
|
+
headers['Content-Length'] = raw.size.to_s
|
|
71
|
+
elsif raw
|
|
72
|
+
raw = UploadBody.new(raw)
|
|
73
|
+
end
|
|
74
|
+
headers['Idempotency-Key'] = request_options[:idempotency_key] || SecureRandom.uuid if idempotent || request_options[:idempotency_key]
|
|
75
|
+
execute(method, url, headers, raw, request_options) { |response| parse(response) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Sends a CDN request. With a block, yields the streaming {Response} and returns the block's
|
|
79
|
+
# value; otherwise returns the response with its body read.
|
|
80
|
+
def cdn(method, url, headers, request_options, &block)
|
|
81
|
+
execute(method, url, headers, nil, request_options, stream: !block.nil?) do |response|
|
|
82
|
+
next response.tap(&:body) unless block
|
|
83
|
+
|
|
84
|
+
streaming = Response.new(status: response.status, headers: response.headers) do |&sink|
|
|
85
|
+
response.each_chunk do |chunk|
|
|
86
|
+
sink.call(chunk)
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
raise CallerError, e
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
begin
|
|
92
|
+
block.call(streaming)
|
|
93
|
+
rescue CallerError
|
|
94
|
+
raise
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
raise CallerError, e
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Resolves lens definitions, sending only definitions this client has not saved before, at most
|
|
102
|
+
# ENSURE_LENSES_MAX_PER_REQUEST per request, in order.
|
|
103
|
+
def ensure_lenses(lenses, request_options)
|
|
104
|
+
if !lenses.is_a?(Array) || lenses.empty? || lenses.any? { |lens| !lens.is_a?(Hash) || lens['identifier'].to_s.empty? }
|
|
105
|
+
raise Internal.invalid('At least one lens with an identifier is required')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@lens_lock.synchronize do
|
|
109
|
+
keys = lenses.map { |lens| JSON.generate(lens.sort.to_h) }
|
|
110
|
+
missing = keys.zip(lenses).to_h.reject { |key, _| @lenses.key?(key) }
|
|
111
|
+
resolved = @lenses.dup
|
|
112
|
+
missing.each_slice(ENSURE_LENSES_MAX_PER_REQUEST) do |slice|
|
|
113
|
+
chunk = slice.to_h
|
|
114
|
+
returned = request('PUT', project('lenses'), body: { 'lenses' => chunk.values },
|
|
115
|
+
request_options: request_options)
|
|
116
|
+
returned = returned['lenses'] if returned.is_a?(Hash)
|
|
117
|
+
unless lenses_match?(returned, chunk.values)
|
|
118
|
+
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Unexpected lens response')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
resolved.merge!(chunk.keys.zip(returned).to_h)
|
|
122
|
+
# A new version of a lens replaces the cached definition with the same identifier.
|
|
123
|
+
chunk.keys.zip(returned).each do |key, lens|
|
|
124
|
+
@lenses.delete_if { |_, saved| saved['identifier'] == lens['identifier'] }
|
|
125
|
+
@lenses[key] = lens
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
JSON.parse(JSON.generate(keys.map { |key| resolved.fetch(key) }))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Whether the saved lenses line up, one for one and in order, with the definitions that were sent.
|
|
133
|
+
def lenses_match?(returned, sent)
|
|
134
|
+
returned.is_a?(Array) && returned.size == sent.size &&
|
|
135
|
+
returned.zip(sent).all? { |saved, definition| saved.is_a?(Hash) && saved['identifier'] == definition['identifier'] }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def headers_for(extra)
|
|
139
|
+
extra.reject { |name, _| %w[x-api-key user-agent].include?(name.downcase) }
|
|
140
|
+
.merge('X-Api-Key' => @api_key, 'User-Agent' => USER_AGENT)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def execute(method, url, headers, body, request_options, stream: false, &handler)
|
|
146
|
+
retries = request_options.fetch(:max_retries, @max_retries)
|
|
147
|
+
retry_allowed = %w[GET HEAD].include?(method) || headers.key?('Idempotency-Key')
|
|
148
|
+
request = Transport::Request.new(method: method, url: url, headers: headers_for(headers), body: body&.content,
|
|
149
|
+
open_timeout: request_options.fetch(:open_timeout, @open_timeout),
|
|
150
|
+
timeout: request_options.fetch(:timeout, @timeout))
|
|
151
|
+
attempt = 0
|
|
152
|
+
begin
|
|
153
|
+
body&.rewind if attempt.positive?
|
|
154
|
+
delivered = false
|
|
155
|
+
send_request(request) do |response|
|
|
156
|
+
delivered = true if stream
|
|
157
|
+
handler.call(response)
|
|
158
|
+
end
|
|
159
|
+
rescue Error => e
|
|
160
|
+
raise unless retry_allowed && !delivered && attempt < retries && e.retryable? && (body.nil? || body.replayable?)
|
|
161
|
+
|
|
162
|
+
delay = retry_delay(attempt, e.retry_after)
|
|
163
|
+
raise if delay.nil?
|
|
164
|
+
|
|
165
|
+
sleep(delay)
|
|
166
|
+
attempt += 1
|
|
167
|
+
retry
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def send_request(request, &handler)
|
|
172
|
+
yielded = false
|
|
173
|
+
result = nil
|
|
174
|
+
returned = @transport.call(request) do |response|
|
|
175
|
+
yielded = true
|
|
176
|
+
result = handler.call(response)
|
|
177
|
+
end
|
|
178
|
+
if yielded
|
|
179
|
+
result
|
|
180
|
+
else
|
|
181
|
+
handler.call(returned.is_a?(Response) ? returned : adapt(returned))
|
|
182
|
+
end
|
|
183
|
+
rescue CallerError => e
|
|
184
|
+
raise e.error
|
|
185
|
+
rescue Error
|
|
186
|
+
raise
|
|
187
|
+
rescue ::Timeout::Error => e
|
|
188
|
+
raise TimeoutError.new(SdkErrorCodes::TIMEOUT, e.message), cause: e
|
|
189
|
+
rescue *Transport::ERRORS => e
|
|
190
|
+
raise ConnectionError.new(SdkErrorCodes::TRANSPORT_ERROR, e.message), cause: e
|
|
191
|
+
rescue ArgumentError, URI::Error => e
|
|
192
|
+
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT, e.message), cause: e
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Accepts any response object with a status (or code), headers and body.
|
|
196
|
+
def adapt(value)
|
|
197
|
+
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'The transport returned no response') if value.nil?
|
|
198
|
+
|
|
199
|
+
status = value.respond_to?(:status) ? value.status : value.code
|
|
200
|
+
headers = value.respond_to?(:to_hash) ? value.to_hash : (value.respond_to?(:headers) && value.headers) || {}
|
|
201
|
+
Response.new(status: status, headers: headers, body: value.body.to_s)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def retry_delay(attempt, retry_after)
|
|
205
|
+
return (retry_after <= MAX_RETRY_DELAY ? retry_after : nil) if retry_after
|
|
206
|
+
|
|
207
|
+
rand * [@retry_base_delay * (2**attempt), MAX_BACKOFF].min
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def parse(response)
|
|
211
|
+
text = response.body(limit: response.success? ? MAX_JSON_BYTES : MAX_ERROR_BYTES).dup.force_encoding(Encoding::UTF_8)
|
|
212
|
+
begin
|
|
213
|
+
data = text.empty? ? nil : JSON.parse(text)
|
|
214
|
+
rescue JSON::ParserError
|
|
215
|
+
raise invalid_body(response) if response.success?
|
|
216
|
+
|
|
217
|
+
data = nil
|
|
218
|
+
end
|
|
219
|
+
return data if response.success?
|
|
220
|
+
|
|
221
|
+
raise api_error(response, data)
|
|
222
|
+
rescue InvalidResponseError
|
|
223
|
+
raise if response.success?
|
|
224
|
+
|
|
225
|
+
raise api_error(response, nil)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def invalid_body(response)
|
|
229
|
+
InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, "Invalid JSON response (HTTP #{response.status})",
|
|
230
|
+
headers: response.headers)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def api_error(response, data)
|
|
234
|
+
unless data.is_a?(Hash) && data['code'].is_a?(String)
|
|
235
|
+
return Error.build(SdkErrorCodes::HTTP_ERROR, "HTTP #{response.status}", status: response.status,
|
|
236
|
+
headers: response.headers)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
details = (data['details'].is_a?(Hash) ? data['details'] : {}).merge('network' => true)
|
|
240
|
+
details['traceId'] = data['traceId'] if data.key?('traceId')
|
|
241
|
+
Error.build(data['code'], data['message'].is_a?(String) ? data['message'] : nil,
|
|
242
|
+
status: response.status, details: details, headers: response.headers)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
private_constant :Session
|
|
246
|
+
end
|