Skip to main content
Skip table of contents

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

RUBY
# 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:

RUBY
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.

RUBY
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.

RUBY
# 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.

RUBY
# 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.

RUBY
# 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.)

RUBY
# 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.

RUBY
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:

JSON
{
  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.

RUBY
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.

RUBY
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:

YAML
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
RUBY
# 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

JavaScript errors detected

Please note, these errors can depend on your browser setup.

If this problem persists, please contact our support.