How to handle unsuccessful requests from external services
Last Updated: April 8, 2025
This page details how to handle unsuccessful requests and exceptions from external services in an API client class called a Service in vets-api
. External services are external APIs, typically from VA.
Error and exception are terms that are used interchangeably.
Implementation
The two most common approaches to handling unsuccessful requests are custom errors and client errors. Both are named after the type of middleware used to returned errors. The custom error approach offers a convention-based implementation, whereas the client error approach offers more fine-grained control. The least common approach is not raising any errors and directly handling an unsuccessful response.
Code from Common::Client::Base
that directly rescues unsuccessful requests from Faraday is omitted.
Custom errors approach
The following is an example process for a custom errors approach.
Build the configuration.
module Example
class Configuration < Common::Client::Configuration::REST
# Service name to use in breakers and metrics.
def service_name
'Example'
end
def connection
Faraday.new(...) do |conn|
conn.response :raise_custom_error, error_prefix: service_name
end
end
end
end
Build the service.
module Example
class Service < Common::Client::Base
def get_example(id)
perform(:get, "some/path/examples/#{id}").body
rescue Common::Exceptions::BackendServiceException => e
log_error(e)
raise e
end
end
end
Add the error data.
The key for the yaml data is the upcased service_name
and the HTTP status code separated by an underscore. If the external service response body has a code
key (typically an error code) at the top level, then the key needs to be the upcased service_name
and the body code
without an underscore separator (e.g., EXAMPLE1234
). Create a error for each HTTP status or body code
that the service returns for status codes between 400 and 599.
en:
common:
exceptions:
EXAMPLE_404:
<<: *external_defaults
title: Record not found
detail: "The record for the requested user could not be found"
code: 'EXAMPLE_404'
status: 404
SERVICE_WITH_CODE404:
<<: *external_defaults
title: Record not found
detail: "The record for the requested user could not be found"
code: 'SERVICE_WITH_CODE_404'
status: 404
Build the receiver.
module V0
class ExamplesController < ApplicationController
def show
example = Example::Service.new.get_example(params[:id])
render json: ExampleSerializer.new(example), status: :ok
end
end
end
In this approach, there is minimal logging and no metrics are collected. In practice, you need to rescue errors where you need to perform logging. Additional error transformation/modifications should be performed in the Service.
Client errors approach
The following is an example process for a client errors approach.
Build the configuration.
module Example
class Configuration < Common::Client::Configuration::REST
# Service name to use in breakers and metrics.
def service_name
'Example'
end
def connection
Faraday.new(...) do |conn|
conn.use Faraday::Response::RaiseError
end
end
end
end
Build the service.
module Example
class Service < Common::Client::Base
def get_example(id)
perform(:get, "some/path/examples/#{id}").body
rescue Common::Client::Errors::Error => e
log_error(e)
raise convert_to_different_error_class(e)
end
end
end
Choose your error.
In the previous step you can see raise convert_to_different_error_class(e)
. This converts the client error to either a Common::Exceptions::BackendServiceException
or another subclass of Common::Exceptions::BaseError
.
To convert to a Common::Exceptions::BackendServiceException
, you can follow the process in step 3, “Add the error data,” and add the following snippet:
def convert_to_different_error_class(error)
case error
when Common::Client::Errors::ClientError
key = "EXAMPLE_#{error.status}"
raise_backend_exception(key, self.class, error)
else
raise error
end
end
If you would like more control over the content of the error, you should use another subclass of Common::Exceptions::BaseError
. One approach is to map the HTTP status code to a BaseError with the same status code. The contents of these errors can be found in config/locales/exceptions.en.yml
.
ERROR_MAP = {
504 => Common::Exceptions::GatewayTimeout,
503 => Common::Exceptions::ServiceUnavailable,
502 => Common::Exceptions::BadGateway,
501 => Common::Exceptions::NotImplemented,
500 => Common::Exceptions::ExternalServerInternalServerError,
499 => Common::Exceptions::ClientDisconnected,
429 => Common::Exceptions::TooManyRequests,
422 => Common::Exceptions::UnprocessableEntity,
413 => Common::Exceptions::PayloadTooLarge,
404 => Common::Exceptions::ResourceNotFound,
403 => Common::Exceptions::Forbidden,
401 => Common::Exceptions::Unauthorized,
400 => Common::Exceptions::BadRequest
}.freeze
def convert_to_different_error_class(error)
raise error unless error.status
return Common::Exceptions::ServiceError unless ERROR_MAP.include?(error.status)
raise ERROR_MAP[error.status].new
end
If you want to override the contents of the error, such as the detail or source, you can pass in a hash to do so:
def convert_to_different_error_class(error)
raise error unless error.status
return Common::Exceptions::ServiceError unless ERROR_MAP.include?(error.status)
# TooManyRequests and GatewayTimeout can't accept errors as an input
if error.status == 429 || errors.status == 504
raise ERROR_MAP[error.status].new
else
errors = [{detail: 'Houston, we have an error', title: 'Apollo 13 error'}]
raise ERROR_MAP[error.status].new(errors: errors)
end
end
Unless the error is converted to a BaseError subclass or not re-raised, the API will render a Common::Exceptions::ServiceOutage
.
Build the receiver.
module V0
class ExamplesController < ApplicationController
def show
example = Example::Service.new.get_example(params[:id])
render json: ExampleSerializer.new(example), status: :ok
end
end
end
Alternatively, you could render the error without ExceptionHandling
.
module V0
class ExamplesController < ApplicationController
def show
example = Example::Service.new.get_example(params[:id])
if example[:error_message]
render { errors: Array(example[:error_message]) }
else
render json: ExampleSerializer.new(example), status: :ok
end
end
end
end
Monitoring
The easiest way to collect metrics is to include include Common::Client::Concerns::Monitoring
in your services. This allows you to use with_monitoring
in your service’s methods to automatically collect metrics for total and failed requests to the external service. The metrics collected include automatically created tags, such as the caller (the service), the error, and status.
class MyService
include Common::Client::Concerns::Monitoring
STATSD_KEY_PREFIX = "api.my_service"
def get_person(id)
with_monitoring do
raw_response = perform(:get, person_path(id))
MyService::PersonResponse.from(raw_response)
end
rescue => e
handle_error(e)
end
Collecting metrics this way requires a key prefix. The STATSD_KEY_PREFIX
must include api
and the service with any namespaces (e.g., api.va_profile.person
). It’s important to note that with_monitoring
doesn’t perform any logging. This approach to monitoring works with both the client and custom error approaches.
Logging
We use the Rails Logger to log to Datadog. What is logged is completely dependent on your app's requirements. You may want to log a failure with the input parameters, or log when a feature is used. Typically, successful requests aren’t logged, but state changes based on successful requests can be logged.
There are no conventions relating to log messages or contexts.
Here are some examples:
Rails.logger.error(Decision Review Upload failed PDF validation, error_details_hash)
Rails.logger.info('Vet Verification Status Success: confirmed')
Rails.logger.info('Parameters for document upload', loggable_params)
Rails.logger.warn('Access token expired. Fetching new token;, { request: 'get_access_token' })
Rails.logger.info "get_person_info too #{elapsed} seconds"
Rails.logger.info('LH - Created Evidence Submission Record')
It is essential that we don’t log PII. This typically happens in the context or options of the logger input. If working with PII, make sure to familiarize yourself with our PII guidelines.
For more information on the architecture and components used for handling errors from unsuccessful requests, visit External service error handling architecture.
Help and feedback
Get help from the Platform Support Team in Slack.
Submit a feature idea to the Platform.