Skip to main content
Skip table of contents

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.

  1. Build the configuration.

RUBY
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
  1. Build the service.

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

YAML
 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
  1. Build the receiver.

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

  1. Build the configuration.

RUBY
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
  1. Build the service.

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

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

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

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

  1. Build the receiver.

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

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

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

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


JavaScript errors detected

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

If this problem persists, please contact our support.