External service error handling architecture
Last Updated: April 8, 2025
This page describes the architecture and components used for handling errors from unsuccessful requests from external services in an API client class called a Service in vets-api
. External services are external APIs, typically from VA.
Components
Service
Common::Client::Base
and its subclasses, called Services, initiate the request to the external service, handle errors, monitor for outages, log messages, and collect metrics.
Faraday connection
Typically, the connection is found in the configuration (Common::Client::Configuration::Base
) associated with the Service. The connection object sends the request and returns the response or error.
Faraday middleware
The middleware class included in the Faraday connection configuration determines which type of Error to raise for a failed response. This determines which implementation needs to be followed.
Errors
All errors are subclasses of StandardError
. There are several types and subtypes of errors related to external APIs.
Error and exception are terms that are used interchangeably.
Receiver
This is the class that calls the Service to initiate the request. It’s often a controller, but could also be a background job, model, or even another Service. This class is often used to either render or log the error.

External service error flow diagram
Raising errors
By default, Faraday connections do not raise errors for unsuccessful requests. To raise an error, add one of the following middleware:
Faraday::Response::RaiseError
Common::Client::Middleware::Response::RaiseCustomError
# configuration.rb
# Faraday::Response::RaiseError
Faraday.new do |conn|
conn.use Faraday::Response::RaiseError
end
# Common::Client::Middleware::Response::RaiseCustomError
Faraday.new do |conn|
conn.response :raise_custom_error
end
The response raises different errors depending on the middleware used. You can see that in action in Common::Client::Base#perform
:
module Common
module Client
class Base
def request(method, path, params = {}, headers = {}, options = {})
connection.send(...)
rescue Common::Exceptions::BackendServiceException => e
# this error will be raised if using RaiseCustomError
rescue Timeout::Error, Faraday::TimeoutError => e
# this error will be raised if using RaiseError
rescue Faraday::ClientError, Faraday::ServerError, Faraday::Error => e
# this error will be raised if using RaiseError
end
end
end
end
Handling errors
Handling in this case means logging, converting, and/or re-raising.
Faraday errors
A timeout error is converted to a Common::Exception::Gateway
, then re-raised. All other Faraday errors are converted to either a ParsingError
or ClientError
, then re-raised.
rescue Timeout::Error, Faraday::TimeoutError => e
raise Common::Exceptions::GatewayTimeout, e.class.name
rescue Faraday::ClientError, Faraday::ServerError, Faraday::Error => e
error_class = case e
when Faraday::ParsingError
Common::Client::Errors::ParsingError
else
Common::Client::Errors::ClientError
end
response_hash = e.response&.to_hash
client_error = error_class.new(e.message, response_hash&.dig(:status), response_hash&.dig(:body),
headers: response_hash&.dig(:headers))
raise client_error
end
The full list of Faraday errors can be found in their documentation.
Custom errors
Custom errors are “Backend Service Exceptions” and are converted to ServiceName::Exception
; for example, Search::Exception
or Chip::Exception
, based on the namespace. These exceptions are Common::Exceptions::BackendServiceException
subclasses.
You are not required to create an ServiceName::Exception
class. It is generated at run time if it doesn’t exist. There are very few reasons to have a ServiceName::Exception
class.
# client/base.rb
rescue Common::Exceptions::BackendServiceException => e
raise config.service_exception.new(
e.key, e.response_values, e.original_status, e.original_body
)
Handling errors in the Service
Both custom errors and client errors should be rescued, optionally transformed or converted, logged, and then re-raised in the Service.
# lib/chip/service.rb
def get_demographics(patient_dfn:, station_no:)
with_monitoring_and_error_handling do
perform(:get, "/#{config.base_path}/actions/authenticated-demographics", ...)
end
end
private
def with_monitoring_and_error_handling(&)
with_monitoring(2, &)
rescue => e
log_exception_to_sentry(e, context)
raise e
end
Handling errors in the receiver
The receiver must then rescue the error from the Service and perform any rendering, value assignment, or re-raising of the exception. If the receiver is a controller, the ExceptionHandling
concern rescues the error. If the receiver is a Job or Model, the final error handling depends on business requirements.
# modules/mobile/app/controllers/mobile/v0/check_in_demographics_controller.rb
module Mobile
module V0
class CheckInDemographicsController < ApplicationController
def show
begin
response = chip_service.get_demographics(patient_dfn:, station_no: params[:location_id])
rescue Chip::ServiceException
raise Common::Exceptions::BackendServiceException, 'MOBL_502_upstream_error'
end
parsed_response = Mobile::V0::Adapters::CheckInDemographics.new.parse(response)
render json: Mobile::V0::CheckInDemographicsSerializer.new(@current_user.uuid, parsed_response)
end
(Note: Much of ExceptionHandling
in the example below has been removed for brevity.)
# app/controllers/concerns/exception_handling.rb
module ExceptionHandling
extend ActiveSupport::Concern
private
included do
rescue_from 'Exception' do |exception|
va_exception =
case exception
when Common::Exceptions::TokenValidationError,
Common::Exceptions::BaseError, JsonSchema::JsonApiMissingAttribute,
Common::Exceptions::ServiceUnavailable, Common::Exceptions::BadGateway
exception
when Common::Client::Errors::ClientError
# SSLError, ConnectionFailed, SerializationError, etc
Common::Exceptions::ServiceOutage.new(nil, detail: 'Backend Service Outage')
else
Common::Exceptions::InternalServerError.new(exception)
end
render_errors(va_exception)
end
end
def render_errors(va_exception)
case va_exception
when JsonSchema::JsonApiMissingAttribute
render json: va_exception.to_json_api, status: va_exception.code
else
render json: { errors: va_exception.errors }, status: va_exception.status_code
end
end
end
Rendering errors
All Common::Exceptions::BaseError
errors are rendered to json using the errors
method seen below.
def errors
Array(SerializableError.new(i18n_data)
end
In the example above, i18n_data
comes from config/locales/exceptions.en.yml
. Different types of errors use different strategies to retrieve the corresponding data. The selected key (and therefor data) is determined by the class name (for internal errors), error code (for BackendServiceException
), or class name and HTTP status code (for ServiceException
).
The JSON structure of rendered errors are the same based on SerializableError
. For example, a BackendServiceException
would be rendered like this:
{
errors: [
{
'title': 'Operation failed',
'code': 'VA900',
'status': 400,
'detail: 'example detail message'
}
]
}
Overwrite i18n data
Common::Exceptions::BaseError
(except Common::Exceptions::BackendServiceException
) errors allow you to override data from i18n, such as the detail or source. In the example below, "You are not allowed"
overwrites the default detail value.
module Common
module Exceptions
class Unauthorized < BaseError
def initialize(options = {})
@detail = options[:detail]
end
def errors
Array(SerializableError.new(i18n_data.merge(detail: @detail)))
end
end
end
end
## Usage
raise Common::Exceptions::Unauthorized, detail: "You are not allowed"
## i18n data
filter_not_allowed:
<<: *defaults
title: Not authorized
detail: "Example default detail message"
code: 401
status: 401
## Rendered Error
{
errors: [
{
'title': 'Not authorized',
'code': 401,
'status': 401,
'detail': 'You are not allowed'
}
]
}
Passing parameters
Common::Exceptions::BaseError
(except Common::Exceptions::BackendServiceException
) errors can pass parameters to i18n data for errors that use i18n_interpolated
. In the example below, the bad_filter
is passed through the error to the i18n data.
module Common
module Exceptions
class FilterNotAllowed < BaseError
attr_reader :filter
def initialize(filter)
@filter = filter
end
def errors
Array(SerializableError.new(i18n_interpolated(detail: { filter: @filter })))
end
end
end
end
## Usage
raise Common::Exceptions::FilterNotAllowed, "bad_filter"
## i18n data
filter_not_allowed:
<<: *defaults
title: 'Filter not allowed'
detail: "\"%{filter}\" is not allowed for filtering"
code: 104
status: 400
## Rendered Error
{
errors: [
{
'title': 'Filter not allowed',
'code': 104,
'status': 400,
'detail': '"bad_filter" is not allowed for filtering'
}
]
}
Error types
Depending on the error-related middleware chosen in the Configuration class, the connection request will return one of these base error classes.
Common::Client::Errors::Error
These errors have the following attributes:
status
body
headers
response
code (only
HTTPError
)
Common::Exceptions::BaseError
These errors have the following attributes from config/locales/exceptions.en.yml
:
title
code
status
detail
links
source
meta
sentry_type (default to error)
Common::Exceptions::BackendServiceException
These errors are mapped by the HTTP status code or response body code, rather than the class name, as is the case for BaseError
. BackendServiceException
also contains the original response and status. This is the most commonly rendered error type.
ServiceException
ServiceException
is a subclass of BackendServiceException
that maps to its own section of config/locales/exceptions.en.yml
. It’s unlikely you will need to implement this type of error. ServiceException
should only be used if the base error contents (title, details, etc.) must be overwritten and only the client error approach is feasible.
An example ServiceException
:
en:
decision_review:
exceptions:
unmapped_service_exception:
<<: *defaults
title: Internal Server Error
detail: The upstream server returned an error code that is unmapped
code: unmapped_service_exception
status: 400
# modules/decision_reviews/lib/decision_reviews/v1/service_exception.rb
module DecisionReviews
module V1
# Custom exception that maps Decision Review errors to error details defined in config/locales/exceptions.en.yml
#
class ServiceException < Common::Exceptions::BackendServiceException
include SentryLogging
UNMAPPED_KEY = 'unmapped_service_exception'
def initialize(key: UNMAPPED_KEY, response_values: {}, original_status: nil, original_body: nil)
super(key, response_values, original_status, original_body)
end
private
def code
if @key.present? && I18n.exists?("decision_review.exceptions.#{@key}")
@key
else
UNMAPPED_KEY
end
end
def i18n_key
"decision_review.exceptions.#{code}"
end
end
end
end
Help and feedback
Get help from the Platform Support Team in Slack.
Submit a feature idea to the Platform.