Error handling

Return the right errors to your callers and handle the errors external APIs return to you.

A REST service handles errors in two directions - it returns status codes and error messages to its own callers, and it reacts to failures of the external APIs it calls. This page shows both, with the error catalog as the reference for every status a channel can produce.

Return errors to callers

When a service cannot fulfill a request, raise one of the exception classes from zato.common.exception - the channel turns the exception into its HTTP status code and the response body carries the message along with the request's CID:

# -*- coding: utf-8 -*-

# Zato
from zato.common.exception import NotFound
from zato.server.service import Service

class GetCustomer(Service):
    """ Returns a customer by ID, with 404 for IDs that match nothing.
    """
    name = 'demo.rest.get-customer-by-id'

    input = 'customer_id'

    def handle(self) -> 'None':

        customer_id = self.request.input.customer_id
        customer = self.invoke('adapter.crm.customer.get', customer_id=customer_id)

        # No such customer means 404 for our caller
        if not customer:
            raise NotFound(self.cid, f'No such customer: {customer_id}')

        self.response.payload = customer

Each exception class maps to one status code - BadRequest to 400, NotFound to 404, Conflict to 409, ServiceUnavailable to 503 - the full table is in the error catalog. Any other exception, Python built-ins included, surfaces as 500.

A caller sees the difference directly:

# An existing customer - 200 with data
curl http://localhost:11223/api/customer/CUST-123

# A non-existent customer - 404 with the error message and CID
curl http://localhost:11223/api/customer/DOES-NOT-EXIST

When the response needs a status that no exception expresses - 201 Created for a new resource - or a structured error body of your own, set self.response.status_code and the payload yourself:

# -*- coding: utf-8 -*-

# stdlib
from http import HTTPStatus

# Zato
from zato.server.service import Service

class CreateOrder(Service):
    name = 'demo.rest.create-order-endpoint'

    def handle(self) -> 'None':

        order = self.invoke('adapter.orders.create', data=self.request.payload)

        # 201 tells the caller a new resource exists and where it is
        self.response.status_code = HTTPStatus.CREATED
        self.response.headers['Location'] = f'/api/orders/{order["id"]}'
        self.response.payload = order

Validation errors

A service with a data model receives only requests that match the model - a request with a missing field or a wrong type is answered 400 before the service runs, with no validation code anywhere.

Business rules the model cannot express are checked in the service, with BadRequest carrying the reason:

# -*- coding: utf-8 -*-

# stdlib
from dataclasses import dataclass

# Zato
from zato.common.exception import BadRequest
from zato.server.service import Model, Service

@dataclass(init=False)
class CreateOrderRequest(Model):
    customer_id: str
    sku: str
    quantity: int

class ValidateOrder(Service):
    name = 'demo.rest.validate-order'

    input = CreateOrderRequest

    def handle(self) -> 'None':

        request = self.request.input

        # The model has already guaranteed that quantity is an int -
        # the business rule that it must be positive is checked here
        if request.quantity < 1:
            raise BadRequest(self.cid, 'quantity must be positive')

        order = self.invoke('adapter.orders.create',
            customer_id=request.customer_id,
            sku=request.sku,
            quantity=request.quantity
        )

        self.response.payload = order

Handle errors from external APIs

An external API's status code arrives in the response object - decide per code what your own caller receives:

# -*- coding: utf-8 -*-

# stdlib
from http import HTTPStatus

# Zato
from zato.common.exception import NotFound
from zato.server.service import Service

class CallExternalAPI(Service):
    name = 'demo.rest.call-external-api'

    def handle(self) -> 'None':

        conn = self.rest['Customer API']
        response = conn.get(self.cid)

        # Success passes the data through ..
        if response.status_code == HTTPStatus.OK:
            self.response.payload = response.data

        # .. the external 404 becomes our own 404 ..
        elif response.status_code == HTTPStatus.NOT_FOUND:
            raise NotFound(self.cid, 'Resource not found in the external system')

        # .. and everything else is a gateway problem the caller cannot fix,
        # so the details go to the log and the caller receives 502.
        else:
            self.logger.error('External API error: %s - %s', response.status_code, response.data)
            self.response.status_code = HTTPStatus.BAD_GATEWAY
            self.response.payload = {'error': 'External service error'}

A connection error or a timeout raises an exception inside the service. Catch it when the caller should receive 503 rather than 500:

# -*- coding: utf-8 -*-

# stdlib
from traceback import format_exc

# Zato
from zato.common.exception import ServiceUnavailable
from zato.server.service import Service

class SafeAPICall(Service):
    name = 'demo.rest.safe-api-call'

    def handle(self) -> 'None':

        try:
            conn = self.rest['Unreliable API']
            response = conn.get(self.cid)
        except Exception:
            self.logger.error('API call failed, e:`%s`', format_exc())
            raise ServiceUnavailable(self.cid, 'The upstream API is unavailable')

        self.response.payload = response.data

Partial failures in orchestration

A service that calls several APIs decides per source whether a failure ends the request or only removes one part of the response:

# -*- coding: utf-8 -*-

# stdlib
from traceback import format_exc

# Zato
from zato.common.exception import ServiceUnavailable
from zato.server.service import Service

class GetDashboard(Service):
    """ Combines profile, notifications and activity into one response.
    """
    name = 'demo.rest.get-dashboard'

    input = 'user_id'

    def handle(self) -> 'None':

        user_id = self.request.input.user_id
        result = {'user_id': user_id, 'errors': []}

        # The profile is required - without it there is nothing to return
        try:
            result['profile'] = self.invoke('adapter.users.get', user_id=user_id)
        except Exception:
            self.logger.error('User profile failed, e:`%s`', format_exc())
            raise ServiceUnavailable(self.cid, 'User profile is unavailable')

        # Notifications are optional - the response names what is missing
        try:
            result['notifications'] = self.invoke('adapter.notifications.list', user_id=user_id)
        except Exception:
            self.logger.warning('Notifications failed, e:`%s`', format_exc())
            result['notifications'] = []
            result['errors'].append('notifications_unavailable')

        # Activity is optional too
        try:
            result['activity'] = self.invoke('adapter.activity.recent', user_id=user_id)
        except Exception:
            self.logger.warning('Activity failed, e:`%s`', format_exc())
            result['activity'] = []
            result['errors'].append('activity_unavailable')

        self.response.payload = result

To learn more about combining APIs, see orchestration.

Timeouts and retries

Timeouts and retries live on the outgoing connection, not in service code. A connection retries timeouts, connection errors and HTTP 429 responses with backoff, honours the Retry-After header and gives up after its configured budget - see retries and timeouts.

Log errors

Log expected errors as warnings and unexpected ones as errors, so the log separates input problems from failures that need attention:

# -*- coding: utf-8 -*-

# stdlib
from traceback import format_exc

# Zato
from zato.common.exception import BadRequest
from zato.server.service import Service

class LoggingExample(Service):
    name = 'demo.rest.logging-example'

    def handle(self) -> 'None':

        try:
            result = self.process_request()
            self.response.payload = result

        except ValueError as e:

            # An input problem - the caller receives the reason as 400
            self.logger.warning('Validation error, e:`%s`', e)
            raise BadRequest(self.cid, str(e))

        except Exception:

            # An unexpected failure - the traceback goes to the log
            # and the exception surfaces to the caller as 500
            self.logger.error('Unexpected error, e:`%s`', format_exc())
            raise

    def process_request(self) -> 'dict':
        return {'status': 'ok'}

Every error body a channel produces includes the request's CID, so a caller reporting an error gives you the exact log entries to read.

See also

PageWhat it covers
Error catalogEvery status code a channel returns and what causes each
Calling REST APIsResponse objects, status checks and non-JSON responses
Outgoing connectionsThe timeouts and retries that decide when errors surface
OrchestrationFailures across multiple APIs combined in one service

Learn more