email-validation-sdk 1.0.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.
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module EmailValidation
8
+ # Validated client settings. #inspect never shows the API key.
9
+ class Config
10
+ attr_reader :base_url, :timeout, :max_retries, :max_retry_wait
11
+
12
+ # No ASCII or Unicode whitespace (NBSP, U+2003, U+2028, BOM …).
13
+ KEY_PATTERN = /\A[^[:space:]\u{FEFF}]+\z/
14
+ private_constant :KEY_PATTERN
15
+
16
+ def initialize(api_key:, base_url: nil, timeout: 30, max_retries: 2, max_retry_wait: 10)
17
+ raise ArgumentError, "api_key is required (a non-empty key without whitespace)" unless valid_key?(api_key)
18
+
19
+ url = (base_url || DEFAULT_BASE_URL).to_s
20
+ raise ArgumentError, "base_url must start with http:// or https://" unless url.start_with?("https://", "http://")
21
+ raise ArgumentError, "timeout must be a number of seconds greater than 0" unless
22
+ timeout.is_a?(Numeric) && timeout.positive?
23
+
24
+ @api_key = api_key.dup.freeze
25
+ @base_url = url.sub(%r{/+\z}, "").freeze
26
+ @timeout = timeout
27
+ @max_retries = [Integer(max_retries), 0].max
28
+ @max_retry_wait = [max_retry_wait.to_f, 0.0].max
29
+ freeze
30
+ end
31
+
32
+ # @api private
33
+ def authorization
34
+ "Bearer #{@api_key}"
35
+ end
36
+
37
+ def inspect
38
+ "#<#{self.class.name} base_url=#{base_url.inspect} timeout=#{timeout} max_retries=#{max_retries} " \
39
+ "max_retry_wait=#{max_retry_wait} api_key=[hidden]>"
40
+ end
41
+ alias to_s inspect
42
+
43
+ def pretty_print(pp)
44
+ pp.text(inspect)
45
+ end
46
+
47
+ private
48
+
49
+ # A key in another encoding (or with invalid bytes) is refused with the same ArgumentError, never an
50
+ # Encoding error.
51
+ def valid_key?(api_key)
52
+ return false unless api_key.is_a?(String)
53
+
54
+ key = api_key.encoding == Encoding::UTF_8 ? api_key : api_key.dup.force_encoding(Encoding::UTF_8)
55
+ key.valid_encoding? && KEY_PATTERN.match?(key)
56
+ end
57
+ end
58
+
59
+ # Client for the email-validation.org REST API.
60
+ #
61
+ # client = EmailValidation::Client.new(api_key: ENV.fetch("EV_API_KEY"))
62
+ # result = client.validate("accepted@sandbox.email-validation.org")
63
+ # result.status # => "valid"
64
+ #
65
+ # Retries: answers 429 and 503 are never charged, so they are retried for every method with backoff (honouring
66
+ # +Retry-After+) up to +max_retries+ times, unless the wait would be longer than +max_retry_wait+ seconds (and never
67
+ # for +key_daily_cap+). Network failures and timeouts are retried only for GET: a check that reached the service may
68
+ # already have been charged.
69
+ #
70
+ # The client is safe to share between threads; build it once and reuse it.
71
+ class Client
72
+ USER_AGENT = "email-validation-ruby/#{VERSION} (ruby #{RUBY_VERSION})".freeze
73
+ JSON_TYPE = "application/json"
74
+ RETRY_STATUSES = [429, 503].freeze
75
+ NO_RETRY_CODES = ["key_daily_cap"].freeze
76
+ private_constant :JSON_TYPE, :RETRY_STATUSES, :NO_RETRY_CODES
77
+
78
+ # Settings (base URL, timeout, retries); the key is never shown.
79
+ attr_reader :config
80
+
81
+ # Bulk jobs: +client.bulk.create+, +get+, +start+, +cancel+, +results+, +wait+.
82
+ attr_reader :bulk
83
+
84
+ # Options:
85
+ # - +api_key+ (required): your key (+ev_live_…+). Keep it on the server.
86
+ # - +base_url+: default +https://api.email-validation.org+.
87
+ # - +timeout+: seconds per attempt (connect and the whole response), default 30. A check may take up to 15 s on
88
+ # our side, so keep it a few seconds above the +timeout+ you pass to #validate.
89
+ # - +max_retries+: retries after the first attempt, default 2 (negative values count as 0).
90
+ # - +max_retry_wait+: the longest single wait in seconds the client accepts before giving up, default 10.
91
+ # - +transport+: replaces the HTTP stack (see NetHttpTransport); a transport you pass is never closed by #close.
92
+ # - +sleep+, +clock+: injectable for tests (+sleep.call(seconds)+; +clock.call+ returns the current Time).
93
+ def initialize(api_key:, base_url: nil, timeout: 30, max_retries: 2, max_retry_wait: 10, transport: nil,
94
+ sleep: nil, clock: nil)
95
+ @config = Config.new(api_key: api_key, base_url: base_url, timeout: timeout, max_retries: max_retries,
96
+ max_retry_wait: max_retry_wait)
97
+ @owns_transport = transport.nil?
98
+ @transport = transport || NetHttpTransport.new
99
+ @sleep = sleep || ->(seconds) { Kernel.sleep(seconds) }
100
+ @clock = clock || -> { Time.now }
101
+ @bulk = Bulk.new(self)
102
+ end
103
+
104
+ # Checks one address and returns a ValidationResult. Costs 1 credit unless the result is +unknown+ or the answer
105
+ # is local (malformed and sandbox addresses are free).
106
+ #
107
+ # - +ip_address+: the address the signup came from (optional).
108
+ # - +timeout+: seconds the service may spend on the check (5–15, server default 15). This is not the client's
109
+ # per-attempt timeout, which should stay a few seconds above it.
110
+ # - +fresh+: +true+ checks the address again even when it was checked in the last 12 months (by default the
111
+ # earlier definite result may be returned; +processed_at+ is when the answer was produced).
112
+ #
113
+ # A validation is never retried after a network failure or timeout (it may already have been charged).
114
+ def validate(email, ip_address: nil, timeout: nil, fresh: nil)
115
+ raise ArgumentError, "email must be a String" unless email.is_a?(String)
116
+
117
+ body = { "email" => email, "ip_address" => ip_address, "timeout" => timeout, "fresh" => fresh }.compact
118
+ request_model(ValidationResult, "POST", "/v1/validate", json: body)
119
+ end
120
+
121
+ # Balance, debt, pending credits and credit lots (soonest-expiring first).
122
+ def credits
123
+ request_model(Credits, "GET", "/v1/credits")
124
+ end
125
+
126
+ # Credit usage by day (default) or by API key (+group_by: "key"+). Dates are +"YYYY-MM-DD"+ strings (UTC) or
127
+ # Date objects.
128
+ def usage(from: nil, to: nil, group_by: nil)
129
+ query = { "from" => from, "to" => to, "group_by" => group_by }
130
+ request_model(UsageResponse, "GET", "/v1/usage", query: query)
131
+ end
132
+
133
+ # The public service status (overall and per component).
134
+ def status
135
+ request_model(ServiceStatus, "GET", "/v1/status")
136
+ end
137
+
138
+ # Releases the HTTP connections of the built-in transport. A transport you passed in is left open.
139
+ def close
140
+ @transport.close if @owns_transport && @transport.respond_to?(:close)
141
+ nil
142
+ end
143
+
144
+ def inspect
145
+ "#<#{self.class.name} base_url=#{config.base_url.inspect} timeout=#{config.timeout} " \
146
+ "max_retries=#{config.max_retries} api_key=[hidden]>"
147
+ end
148
+ alias to_s inspect
149
+
150
+ def pretty_print(pp)
151
+ pp.text(inspect)
152
+ end
153
+
154
+ # @api private
155
+ def request_model(model, method, path, **options)
156
+ model.from_h(decode_json(perform(method, path, **options)))
157
+ end
158
+
159
+ # @api private
160
+ def sleep_for(seconds)
161
+ @sleep.call(seconds)
162
+ end
163
+
164
+ # @api private
165
+ # Sends one call with the retry policy and returns the 2xx HttpResponse. +stream+, when given, is an object with
166
+ # +on_head(status, headers)+ (passed to the transport as its block) and +started?+ (true once a chunk reached the
167
+ # caller, after which nothing is retried).
168
+ def perform(method, path, query: nil, json: nil, body: nil, content_type: nil, accept: JSON_TYPE, headers: nil,
169
+ stream: nil)
170
+ request = build_request(method, path, query, json, body, content_type, accept, headers)
171
+ attempt = 0
172
+ loop do
173
+ begin
174
+ response = stream ? @transport.call(request) { |s, h| stream.on_head(s, h) } : @transport.call(request)
175
+ rescue TransportError => e
176
+ if method == "GET" && attempt < config.max_retries && !stream&.started?
177
+ sleep_for(backoff(attempt))
178
+ attempt += 1
179
+ next
180
+ end
181
+ raise connection_error(e)
182
+ end
183
+ return response if (200..299).cover?(response.status)
184
+
185
+ error = error_from(response)
186
+ wait = retry_wait(error, attempt)
187
+ raise error if wait.nil?
188
+
189
+ sleep_for(wait)
190
+ attempt += 1
191
+ end
192
+ end
193
+
194
+ # @api private
195
+ def parse_retry_after(value)
196
+ return nil if value.nil?
197
+
198
+ value = value.strip
199
+ return Integer(value, 10) if value.match?(/\A\d+\z/)
200
+
201
+ begin
202
+ at = Time.httpdate(value)
203
+ rescue ArgumentError
204
+ return nil
205
+ end
206
+ [(at - @clock.call).ceil, 0].max
207
+ end
208
+
209
+ private
210
+
211
+ def build_request(method, path, query, json, body, content_type, accept, extra_headers)
212
+ url = config.base_url + path
213
+ encoded = encode_query(query)
214
+ url = "#{url}?#{encoded}" unless encoded.empty?
215
+ headers = { "authorization" => config.authorization, "accept" => accept, "user-agent" => USER_AGENT }
216
+ if json
217
+ body = JSON.generate(json).b
218
+ headers["content-type"] = JSON_TYPE
219
+ elsif body
220
+ body = body.b
221
+ headers["content-type"] = content_type if content_type
222
+ end
223
+ extra_headers&.each { |name, value| headers[name.to_s.downcase] = value.to_s }
224
+ HttpRequest.new(method: method, url: url, headers: headers, body: body, timeout: config.timeout)
225
+ end
226
+
227
+ def encode_query(query)
228
+ return "" if query.nil?
229
+
230
+ pairs = query.filter_map do |name, value|
231
+ next if value.nil?
232
+
233
+ [name.to_s, value.to_s] # true/false become "true"/"false"; integers decimal; Dates ISO 8601
234
+ end
235
+ URI.encode_www_form(pairs)
236
+ end
237
+
238
+ def decode_json(response)
239
+ parsed = JSON.parse(response.body.dup.force_encoding(Encoding::UTF_8))
240
+ raise JSON::ParserError, "not an object" unless parsed.is_a?(Hash)
241
+
242
+ parsed
243
+ rescue JSON::ParserError, EncodingError
244
+ raise Error.new("The API answered with something other than JSON.", status: response.status,
245
+ code: "unexpected_response",
246
+ request_id: response.header("x-request-id"))
247
+ end
248
+
249
+ def error_from(response)
250
+ request_id = response.header("x-request-id")
251
+ retry_after = parse_retry_after(response.header("retry-after"))
252
+ klass = EmailValidation.error_class_for_status(response.status)
253
+ envelope = begin
254
+ JSON.parse(response.body.dup.force_encoding(Encoding::UTF_8))
255
+ rescue JSON::ParserError, EncodingError
256
+ nil
257
+ end
258
+ err = envelope.is_a?(Hash) ? envelope["error"] : nil
259
+ if err.is_a?(Hash) && ERROR_CODES.include?(err["code"])
260
+ code = err["code"]
261
+ message = err["message"].is_a?(String) ? err["message"] : code
262
+ request_id = err["request_id"] if err["request_id"].is_a?(String)
263
+ else
264
+ code = "unexpected_response"
265
+ message = "Unexpected HTTP #{response.status} from the API."
266
+ end
267
+ klass.new(message, status: response.status, code: code, request_id: request_id, retry_after: retry_after)
268
+ end
269
+
270
+ def retry_wait(error, attempt)
271
+ return nil unless RETRY_STATUSES.include?(error.status)
272
+ return nil if NO_RETRY_CODES.include?(error.code) || attempt >= config.max_retries
273
+
274
+ wait = error.retry_after.nil? ? backoff(attempt) : error.retry_after
275
+ wait <= config.max_retry_wait ? wait : nil
276
+ end
277
+
278
+ # Exponential backoff with jitter: 0.25–0.5 s, 0.5–1 s, 1–2 s … capped at 4–8 s.
279
+ def backoff(attempt)
280
+ base = [8.0, 0.5 * (2**attempt)].min
281
+ (base / 2) + (rand * base / 2)
282
+ end
283
+
284
+ def connection_error(transport_error)
285
+ if transport_error.timeout?
286
+ ConnectionError.new("The request timed out.", status: 0, code: "timeout")
287
+ else
288
+ ConnectionError.new("The API could not be reached.", status: 0, code: "connection_error")
289
+ end
290
+ end
291
+ end
292
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EmailValidation
4
+ # Public vocabulary of the email-validation.org API.
5
+ #
6
+ # Every value is a plain String: results keep the raw string even when a newer API version sends a value this SDK
7
+ # does not list yet (forward compatibility), so compare with the constants below rather than with a closed enum.
8
+
9
+ DEFAULT_BASE_URL = "https://api.email-validation.org"
10
+
11
+ # EU data residency: validated and stored in the EU. Pass it as +base_url:+ to use the EU endpoint.
12
+ EU_BASE_URL = "https://api-eu.email-validation.org"
13
+
14
+ # Free test addresses: +<reason>@sandbox.email-validation.org+ answers that reason and costs nothing.
15
+ SANDBOX_DOMAIN = "sandbox.email-validation.org"
16
+
17
+ # Default webhook timestamp tolerance, in seconds.
18
+ DEFAULT_WEBHOOK_TOLERANCE = 300
19
+
20
+ STATUSES = %w[valid invalid catch_all unknown disposable].freeze
21
+
22
+ REASONS_BY_STATUS = {
23
+ "valid" => %w[
24
+ accepted alias normalized low_engagement high_engagement trusted_catch_all ai_agent role_account abuse_linked
25
+ suppressed trap_pattern spamtrap complainer
26
+ ].freeze,
27
+ "invalid" => %w[no_mailbox no_inbound_mail invalid_syntax likely_typo no_dns mailbox_full unroutable].freeze,
28
+ "catch_all" => %w[catch_all role_catch_all forwarding_domain].freeze,
29
+ "unknown" => %w[server_unavailable temporary_error greylisted anti_spam_block timeout internal_error].freeze,
30
+ "disposable" => %w[disposable].freeze
31
+ }.freeze
32
+
33
+ # Catalogue order: by status, unknown last.
34
+ REASONS = %w[valid invalid catch_all disposable unknown].flat_map { |s| REASONS_BY_STATUS[s] }.freeze
35
+
36
+ ACTIONS = %w[send send_with_caution remove retry_later].freeze
37
+
38
+ # The fixed status and action of every reason.
39
+ REASON_INFO = {
40
+ "accepted" => %w[valid send], "alias" => %w[valid send], "normalized" => %w[valid send],
41
+ "low_engagement" => %w[valid send_with_caution], "high_engagement" => %w[valid send],
42
+ "trusted_catch_all" => %w[valid send], "ai_agent" => %w[valid send_with_caution],
43
+ "role_account" => %w[valid send_with_caution], "abuse_linked" => %w[valid remove],
44
+ "suppressed" => %w[valid remove], "trap_pattern" => %w[valid remove], "spamtrap" => %w[valid remove],
45
+ "complainer" => %w[valid remove],
46
+ "no_mailbox" => %w[invalid remove], "no_inbound_mail" => %w[invalid remove],
47
+ "invalid_syntax" => %w[invalid remove], "likely_typo" => %w[invalid remove], "no_dns" => %w[invalid remove],
48
+ "mailbox_full" => %w[invalid remove], "unroutable" => %w[invalid remove],
49
+ "catch_all" => %w[catch_all send_with_caution], "role_catch_all" => %w[catch_all send_with_caution],
50
+ "forwarding_domain" => %w[catch_all send_with_caution],
51
+ "disposable" => %w[disposable remove],
52
+ "server_unavailable" => %w[unknown retry_later], "temporary_error" => %w[unknown retry_later],
53
+ "greylisted" => %w[unknown retry_later], "anti_spam_block" => %w[unknown retry_later],
54
+ "timeout" => %w[unknown retry_later], "internal_error" => %w[unknown retry_later]
55
+ }.transform_values { |(status, action)| { status: status, action: action }.freeze }.freeze
56
+
57
+ SMTP_PROVIDERS = %w[
58
+ google microsoft yahoo apple zoho proton fastmail gmx yandex mailru amazon mimecast proofpoint barracuda other
59
+ ].freeze
60
+
61
+ BULK_JOB_STATUSES = %w[
62
+ analyzing ready queued running cancelling finalizing completed failed cancelled expired
63
+ ].freeze
64
+
65
+ TERMINAL_BULK_JOB_STATUSES = %w[completed failed cancelled expired].freeze
66
+
67
+ # Why a bulk job +failed+ or waits in +ready+ (its +error_code+), mapped to the job status it comes with.
68
+ BULK_JOB_ERROR_CODES = {
69
+ "invalid_csv" => "failed", "too_many_rows" => "failed", "empty_file" => "failed",
70
+ "email_column_not_found" => "failed", "upload_missing" => "failed", "pipeline_mismatch" => "failed",
71
+ "missing_results" => "failed", "internal_error" => "failed",
72
+ "insufficient_credits" => "ready", "upstream_capacity" => "ready", "account_suspended" => "ready",
73
+ "billing_hold" => "ready", "invalid_key" => "ready", "forbidden_scope" => "ready",
74
+ "key_daily_cap" => "ready", "upload_expired" => "ready"
75
+ }.freeze
76
+
77
+ # API error codes, in the API's order. Branch on these (or on the error class), never on messages.
78
+ ERROR_CODES = %w[
79
+ invalid_request unauthorized insufficient_credits forbidden forbidden_origin not_found conflict length_required
80
+ payload_too_large rate_limited abuse_throttled key_daily_cap internal_error service_unavailable upstream_capacity
81
+ ].freeze
82
+
83
+ # Codes the client itself uses when it got no usable answer.
84
+ CLIENT_ERROR_CODES = %w[connection_error timeout unexpected_response aborted].freeze
85
+
86
+ # Every +type+ a webhook delivery can carry; +webhook.test+ is sent only by "Send test" in the dashboard.
87
+ WEBHOOK_EVENTS = %w[bulk.completed bulk.failed credits.low credits.expiring webhook.test].freeze
88
+
89
+ # Named constants: +EmailValidation::Status::VALID+, +Reason::CATCH_ALL+, +Action::SEND_WITH_CAUTION+,
90
+ # +SmtpProvider::GOOGLE+, +BulkJobStatus::COMPLETED+, +ErrorCode::RATE_LIMITED+, +WebhookEventType::BULK_COMPLETED+ …
91
+ module Status; end
92
+ module Reason; end
93
+ module Action; end
94
+ module SmtpProvider; end
95
+ module BulkJobStatus; end
96
+ module ErrorCode; end
97
+ module WebhookEventType; end
98
+
99
+ {
100
+ Status => STATUSES, Reason => REASONS, Action => ACTIONS, SmtpProvider => SMTP_PROVIDERS,
101
+ BulkJobStatus => BULK_JOB_STATUSES, ErrorCode => ERROR_CODES + CLIENT_ERROR_CODES,
102
+ WebhookEventType => WEBHOOK_EVENTS
103
+ }.each do |mod, values|
104
+ values.each do |value|
105
+ name = value.upcase.tr(".", "_")
106
+ mod.const_set(name, value) unless mod.const_defined?(name, false)
107
+ end
108
+ mod.freeze
109
+ end
110
+
111
+ module_function
112
+
113
+ # True when +value+ is one of the five result statuses.
114
+ def status?(value)
115
+ STATUSES.include?(value)
116
+ end
117
+
118
+ # True when +value+ is a known reason.
119
+ def reason?(value)
120
+ REASONS.include?(value)
121
+ end
122
+
123
+ # +<reason>@sandbox.email-validation.org+: a free test address that answers +reason+.
124
+ # Raises ArgumentError for an unknown reason.
125
+ def sandbox_address(reason)
126
+ reason = reason.to_s
127
+ raise ArgumentError, "unknown reason: #{reason.inspect}" unless reason?(reason)
128
+
129
+ "#{reason}@#{SANDBOX_DOMAIN}"
130
+ end
131
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EmailValidation
4
+ # Base class of every error the client raises. Branch on the class or on #code, never on #message.
5
+ #
6
+ # Raised directly only when a 2xx answer is not JSON (code +unexpected_response+).
7
+ #
8
+ # Attributes:
9
+ # - +status+: the HTTP status, or 0 when no answer arrived (or the SDK refused the call before sending).
10
+ # - +code+: an API error code (+insufficient_credits+, +rate_limited+ …) or a client code (+connection_error+,
11
+ # +timeout+, +unexpected_response+, +aborted+).
12
+ # - +request_id+: our id for the request (quote it to support), when the API answered.
13
+ # - +retry_after+: whole seconds from the +Retry-After+ header, or nil.
14
+ # - +cause+: the underlying exception, when there is one (Ruby sets it automatically).
15
+ class Error < StandardError
16
+ attr_reader :status, :code, :request_id, :retry_after
17
+
18
+ def initialize(message = nil, status: 0, code: "unexpected_response", request_id: nil, retry_after: nil)
19
+ super(message || code)
20
+ @status = status
21
+ @code = code
22
+ @request_id = request_id
23
+ @retry_after = retry_after
24
+ end
25
+
26
+ # The canonical kind name, e.g. "RateLimitError" (the class name without the module).
27
+ def kind
28
+ self.class.name.split("::").last
29
+ end
30
+
31
+ # Shown for uncaught errors: kind, status, code and request id. Never the key, the body or the checked address.
32
+ def detailed_message(highlight: false, **)
33
+ "#{message} (#{self.class.name}: status=#{status} code=#{code} request_id=#{request_id || "-"})"
34
+ end
35
+
36
+ def inspect
37
+ "#<#{self.class.name} status=#{status} code=#{code.inspect} request_id=#{request_id.inspect} " \
38
+ "retry_after=#{retry_after.inspect} message=#{message.inspect}>"
39
+ end
40
+ end
41
+
42
+ # The canonical name of the base kind.
43
+ EmailValidationError = Error
44
+
45
+ # 400, 411, 413, any other 4xx without its own class, checks refused before sending (status 0), and a ranged
46
+ # download answered without 206: fix the request.
47
+ class InvalidRequestError < Error; end
48
+
49
+ # 401: missing, malformed, revoked or rolled key.
50
+ class AuthenticationError < Error; end
51
+
52
+ # 402: not enough credits. Nothing was charged.
53
+ class InsufficientCreditsError < Error; end
54
+
55
+ # 403: missing scope, disallowed origin, suspended account or billing hold.
56
+ class PermissionDeniedError < Error; end
57
+
58
+ # 404: no such resource for this account.
59
+ class NotFoundError < Error; end
60
+
61
+ # 409: the resource is in the wrong state.
62
+ class ConflictError < Error; end
63
+
64
+ # 429: slow down. Retried automatically when the wait is short (except +key_daily_cap+).
65
+ class RateLimitError < Error; end
66
+
67
+ # 503: temporarily unavailable, nothing charged. Retried automatically like 429.
68
+ class ServiceUnavailableError < Error; end
69
+
70
+ # 500, any other status >= 500 and any other unexpected status (e.g. a 3xx redirect, which is never followed).
71
+ class ServerError < Error; end
72
+
73
+ # No HTTP answer: network failure (+connection_error+), attempt timeout (+timeout+), or a #wait deadline
74
+ # (+timeout+). Status is 0.
75
+ class ConnectionError < Error; end
76
+
77
+ # A webhook delivery failed a header, timestamp, secret or signature check. Not part of the Error family.
78
+ class WebhookVerificationError < StandardError; end
79
+
80
+ # @api private
81
+ ERRORS_BY_STATUS = {
82
+ 400 => InvalidRequestError, 401 => AuthenticationError, 402 => InsufficientCreditsError,
83
+ 403 => PermissionDeniedError, 404 => NotFoundError, 409 => ConflictError, 411 => InvalidRequestError,
84
+ 413 => InvalidRequestError, 429 => RateLimitError, 503 => ServiceUnavailableError
85
+ }.freeze
86
+ private_constant :ERRORS_BY_STATUS
87
+
88
+ # @api private
89
+ def self.error_class_for_status(status)
90
+ ERRORS_BY_STATUS.fetch(status) { (400..499).cover?(status) ? InvalidRequestError : ServerError }
91
+ end
92
+ end