Module: Posthaste::ErrorMapping

Defined in:
lib/posthaste/errors.rb

Overview

Mapping a refusal onto a class.

Constant Summary collapse

BY_TYPE =

By TYPE first, because the type is the stable contract and the status is not specific enough. Only types that say more than their status does are listed; everything else falls through to BY_STATUS, which is the honest answer for a refusal reason this version has never seen.

{
  'unauthorized' => AuthenticationError,
  'unauthenticated' => AuthenticationError,
  'csrf_failed' => AuthenticationError,
  'email_unverified' => AuthenticationError,
  'forbidden' => PermissionDeniedError,
  'invalid_request' => InvalidRequestError,
  'not_found' => NotFoundError,
  'conflict' => ConflictError,
  'address_taken' => ConflictError,
  'suppressed' => SuppressedError,
  'suppression_protected' => UnprocessableError,
  'suppression_platform' => UnprocessableError,
  'suppression_hard_bounce' => UnprocessableError,
  'domain_not_verified' => DomainNotVerifiedError,
  'content_blocked' => ContentBlockedError,
  'attachments_too_many' => AttachmentError,
  'attachments_too_large' => AttachmentError,
  'attachment_type_blocked' => AttachmentError,
  'attachment_invalid' => AttachmentError,
  'schedule_too_far' => ScheduleError,
  'not_scheduled' => ScheduleError,
  'unknown_template' => TemplateError,
  'invalid_template' => TemplateError,
  'template_in_use' => TemplateError,
  'unknown_stream' => StreamError,
  # The four 429s. Two transient, two not.
  'rate_limited' => RateLimited,
  'platform_paused' => RateLimited,
  'daily_limit_reached' => QuotaExhausted,
  'monthly_limit_reached' => QuotaExhausted,
  'internal' => ServerError
  # `connection_error` and `timeout` are deliberately ABSENT. They are
  # synthesised by the transport, which constructs the exception directly,
  # so an entry here would only ever fire on a response that carried one of
  # those strings as an HTTP-level refusal — and mapping a real 408 onto
  # ConnectionError ("never reached the server") would be a lie about what
  # happened.
}.freeze
BY_STATUS =
{
  400 => InvalidRequestError,
  401 => AuthenticationError,
  403 => PermissionDeniedError,
  404 => NotFoundError,
  409 => ConflictError,
  422 => UnprocessableError,
  # An unrecognised 429 is treated as transient rather than as spent quota.
  # That direction is the safe one: a wrongly-retried throttle costs a few
  # seconds, while a wrongly-abandoned one drops mail that would have gone.
  429 => RateLimited
}.freeze

Class Method Summary collapse

Class Method Details

.class_for(error_type, status) ⇒ Object



259
260
261
262
263
264
265
# File 'lib/posthaste/errors.rb', line 259

def class_for(error_type, status)
  known = BY_TYPE[error_type]
  return known if known
  return ServerError if status >= 500

  BY_STATUS.fetch(status, APIStatusError)
end

.from_response(status, raw_body, retry_after_header, request_id: nil, api_key: nil) ⇒ Object

Build the exception for one refused response.



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/posthaste/errors.rb', line 268

def from_response(status, raw_body, retry_after_header, request_id: nil, api_key: nil)
  body = begin
    parsed = raw_body.to_s.empty? ? nil : JSON.parse(raw_body)
    parsed.is_a?(Hash) ? parsed : nil
  rescue JSON::ParserError
    nil
  end

  envelope = body.is_a?(Hash) && body['error'].is_a?(Hash) ? body['error'] : {}
  type = envelope['type'].is_a?(String) ? envelope['type'] : 'unknown_error'
  message = envelope['message'].is_a?(String) ? envelope['message'] : "HTTP #{status}"

  class_for(type, status).new(
    message,
    type: type,
    status: status,
    body: body,
    request_id: request_id,
    retry_after_seconds: parse_retry_after(retry_after_header, envelope),
    api_key: api_key
  )
end

.parse_retry_after(header, envelope = {}) ⇒ Object

Retry-After is seconds or an HTTP-date. The body may also carry it as retryAfterSeconds, and the body wins when both are present because it is the value the limiter actually computed.



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/posthaste/errors.rb', line 294

def parse_retry_after(header, envelope = {})
  from_body = envelope['retryAfterSeconds']
  return from_body.to_f if from_body.is_a?(Numeric)

  return nil if header.nil? || header.to_s.strip.empty?

  text = header.to_s.strip
  return text.to_f if /\A\d+(\.\d+)?\z/.match?(text)

  begin
    seconds = Time.httpdate(text) - Time.now
    seconds.positive? ? seconds : 0.0
  rescue ArgumentError
    nil
  end
end