The Error Scenario Catalog
Most test suites exercise the happy path thoroughly and test error handling as an afterthought. This is exactly backwards: error handling is where bugs live, where security vulnerabilities hide, and where user experience breaks down.
For every HTTP client your code contains, you should have tests for each of these scenarios:
| Status Code | Scenario | Expected Client Behavior |
|---|---|---|
| 400 | Invalid request body | Show validation error to user |
| 401 | Expired/missing token | Redirect to login or refresh token |
| 403 | Insufficient permissions | Show 'access denied' message |
| 404 | Resource deleted or wrong ID | Show 'not found' gracefully |
| 409 | Conflict (duplicate, version mismatch) | Show conflict message |
| 422 | Validation error from server | Display field-level errors |
| 429 | Rate limited | Wait and retry with backoff |
| 500 | Server error | Show generic error, don't expose details |
| 502 | Gateway error | Retry after delay |
| 503 | Service unavailable | Retry, show maintenance message |
| 504 | Gateway timeout | Retry, surface latency alert |
Simulating Server Errors with Mock Responses
# pytest + responses library
# pip install responses
import responses
import requests
import pytest
from myapp.clients import PaymentClient
@responses.activate
def test_payment_client_handles_500():
responses.add(
responses.POST,
'https://payments.example.com/charge',
json={'error': 'Internal server error'},
status=500
)
client = PaymentClient(base_url='https://payments.example.com')
with pytest.raises(PaymentServerError) as exc_info:
client.charge(amount=1000, currency='usd')
assert exc_info.value.retryable is True
@responses.activate
def test_payment_client_handles_429_with_retry_after():
responses.add(
responses.POST,
'https://payments.example.com/charge',
headers={'Retry-After': '30'},
status=429
)
client = PaymentClient(base_url='https://payments.example.com')
with pytest.raises(RateLimitError) as exc_info:
client.charge(amount=1000, currency='usd')
assert exc_info.value.retry_after == 30
@responses.activate
def test_payment_client_handles_401_by_refreshing_token():
# First call returns 401, second call (after token refresh) returns 200
responses.add(responses.POST, 'https://payments.example.com/charge', status=401)
responses.add(
responses.POST,
'https://payments.example.com/charge',
json={'charge_id': 'ch_123', 'status': 'succeeded'},
status=200
)
client = PaymentClient(base_url='https://payments.example.com')
result = client.charge(amount=1000, currency='usd')
assert result['charge_id'] == 'ch_123'
assert len(responses.calls) == 2 # original + retry after refresh
Middleware Injection for Server-Side Error Testing
For testing your server's own error responses, inject errors via middleware:
# Django test middleware
class ForceErrorMiddleware:
"""Force a specific status code for testing."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
forced_status = request.META.get('HTTP_X_FORCE_STATUS')
if forced_status and settings.DEBUG:
return HttpResponse(status=int(forced_status))
return self.get_response(request)
# Test usage:
def test_client_retries_on_503():
response = client.get('/api/data', headers={'X-Force-Status': '503'})
assert response.status_code == 503
Timeout Simulation
Timeouts are among the most common error scenarios and the most commonly untested.
Read Timeout vs Connection Timeout
import responses
from responses.matchers import json_params_matcher
@responses.activate
def test_client_raises_on_read_timeout():
responses.add(
responses.GET,
'https://api.example.com/slow',
body=requests.exceptions.ReadTimeout()
)
client = MyApiClient(timeout=5)
with pytest.raises(ApiTimeoutError):
client.get('/slow')
@responses.activate
def test_client_raises_on_connection_timeout():
responses.add(
responses.GET,
'https://api.example.com/down',
body=requests.exceptions.ConnectTimeout()
)
with pytest.raises(ApiConnectionError):
client.get('/down')
Slow Response with Toxiproxy
import time
import requests
import pytest
TOXIPROXY_API = 'http://localhost:8474'
@pytest.fixture
def add_latency():
requests.post(f'{TOXIPROXY_API}/proxies/api-proxy/toxics', json={
'name': 'slow', 'type': 'latency',
'attributes': {'latency': 10000} # 10 seconds
})
yield
requests.delete(f'{TOXIPROXY_API}/proxies/api-proxy/toxics/slow')
def test_request_times_out(add_latency):
start = time.monotonic()
with pytest.raises(requests.exceptions.Timeout):
requests.get('http://localhost:18000/api/data', timeout=2)
elapsed = time.monotonic() - start
assert elapsed < 3 # should timeout, not hang
Network Failure Simulation
@responses.activate
def test_handles_connection_reset():
responses.add(
responses.GET,
'https://api.example.com/data',
body=requests.exceptions.ConnectionError('Connection reset by peer')
)
with pytest.raises(ApiConnectionError) as exc_info:
client.get_data()
assert exc_info.value.retryable is True
@responses.activate
def test_handles_dns_failure():
responses.add(
responses.GET,
'https://api.example.com/data',
body=requests.exceptions.ConnectionError(
'Failed to establish a new connection: [Errno -2] Name or service not known'
)
)
with pytest.raises(ApiConnectionError):
client.get_data()
Assertion Patterns for Error Responses
Validate Error Response Shape
@responses.activate
def test_error_response_has_correct_structure():
responses.add(
responses.POST,
'https://api.example.com/orders',
json={
'error': 'validation_failed',
'message': 'Request validation failed',
'details': [{'field': 'quantity', 'message': 'must be positive'}]
},
status=422
)
with pytest.raises(ValidationError) as exc_info:
client.create_order(item_id=1, quantity=-1)
error = exc_info.value
assert error.code == 'validation_failed'
assert len(error.details) > 0
assert error.details[0]['field'] == 'quantity'
Verify Retry Behavior
@responses.activate
def test_client_retries_502_three_times():
# First 3 calls fail with 502, 4th succeeds
for _ in range(3):
responses.add(responses.GET, 'https://api.example.com/data', status=502)
responses.add(
responses.GET,
'https://api.example.com/data',
json={'data': 'result'},
status=200
)
result = client.get_data() # Should retry and eventually succeed
assert result['data'] == 'result'
assert len(responses.calls) == 4 # 3 retries + 1 success
def test_client_does_not_retry_400():
"""400 errors are client errors — retrying won't help."""
with responses.RequestsMock() as rsps:
rsps.add(responses.POST, 'https://api.example.com/orders', status=400)
with pytest.raises(BadRequestError):
client.create_order(item_id='invalid')
assert len(rsps.calls) == 1 # no retry
Building a comprehensive error scenario test suite is one of the highest-value investments you can make in API reliability. Every uncaught error scenario is a bug waiting to surface in production at the worst possible moment.