lib/asana/http_client/error_handling.rb
# frozen_string_literal: true
require_relative '../errors'
module Asana
class HttpClient
# Internal: Handles errors from the API and re-raises them as proper
# exceptions.
module ErrorHandling
include Errors
module_function
MAX_RETRIES = 5
# Public: Perform a request handling any API errors correspondingly.
#
# request - [Proc] a block that will execute the request.
#
# Returns a [Faraday::Response] object.
#
# Raises [Asana::Errors::InvalidRequest] for invalid requests.
# Raises [Asana::Errors::NotAuthorized] for unauthorized requests.
# Raises [Asana::Errors::Forbidden] for forbidden requests.
# Raises [Asana::Errors::NotFound] when a resource can't be found.
# Raises [Asana::Errors::RateLimitEnforced] when the API is throttling.
# Raises [Asana::Errors::ServerError] when there's a server problem.
# Raises [Asana::Errors::APIError] when the API returns an unknown error.
#
# rubocop:disable Metrics/AbcSize
def handle(num_retries = 0, &request)
request.call
rescue Faraday::ClientError => e
raise e unless e.response
case e.response[:status]
when 400 then raise invalid_request(e.response)
when 401 then raise not_authorized(e.response)
when 402 then raise payment_required(e.response)
when 403 then raise forbidden(e.response)
when 404 then raise not_found(e.response)
when 412 then recover_response(e.response)
when 429 then raise rate_limit_enforced(e.response)
when 500 then raise server_error(e.response)
else raise api_error(e.response)
end
# Retry for timeouts or 500s from Asana
rescue Faraday::ServerError => e
raise server_error(e.response) unless num_retries < MAX_RETRIES
handle(num_retries + 1, &request)
rescue Net::ReadTimeout => e
raise e unless num_retries < MAX_RETRIES
handle(num_retries + 1, &request)
end
# rubocop:enable Metrics/AbcSize
# Internal: Returns an InvalidRequest exception including a list of
# errors.
def invalid_request(response)
errors = body(response).fetch('errors', []).map { |e| e['message'] }
InvalidRequest.new(errors).tap do |exception|
exception.response = response
end
end
# Internal: Returns a NotAuthorized exception.
def not_authorized(response)
NotAuthorized.new.tap { |exception| exception.response = response }
end
# Internal: Returns a PremiumOnly exception.
def payment_required(response)
PremiumOnly.new.tap { |exception| exception.response = response }
end
# Internal: Returns a Forbidden exception.
def forbidden(response)
Forbidden.new.tap { |exception| exception.response = response }
end
# Internal: Returns a NotFound exception.
def not_found(response)
NotFound.new.tap { |exception| exception.response = response }
end
# Internal: Returns a RateLimitEnforced exception with a retry after
# field.
def rate_limit_enforced(response)
retry_after_seconds = response[:headers]['Retry-After']
RateLimitEnforced.new(retry_after_seconds).tap do |exception|
exception.response = response
end
end
# Internal: Returns a ServerError exception with a unique phrase.
def server_error(response)
phrase = body(response).fetch('errors', []).first['phrase']
ServerError.new(phrase).tap do |exception|
exception.response = response
end
end
# Internal: Returns an APIError exception.
def api_error(response)
APIError.new.tap { |exception| exception.response = response }
end
# Internal: Parser a response body from JSON.
def body(response)
JSON.parse(response[:body])
end
# rubocop:disable Style/OpenStructUse
def recover_response(response)
r = response.dup.tap { |res| res[:body] = body(response) }
Response.new(OpenStruct.new(env: OpenStruct.new(r)))
end
# rubocop:enable Style/OpenStructUse
end
end
end