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
| Page | What it covers |
|---|---|
| Error catalog | Every status code a channel returns and what causes each |
| Calling REST APIs | Response objects, status checks and non-JSON responses |
| Outgoing connections | The timeouts and retries that decide when errors surface |
| Orchestration | Failures across multiple APIs combined in one service |