Testing & Mocking

Testing Error Scenarios: Simulating 4xx and 5xx Responses

How to systematically test error handling — simulating every HTTP error code your client might receive, timeout simulation, and network failure testing.

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 CodeScenarioExpected Client Behavior
400Invalid request bodyShow validation error to user
401Expired/missing tokenRedirect to login or refresh token
403Insufficient permissionsShow 'access denied' message
404Resource deleted or wrong IDShow 'not found' gracefully
409Conflict (duplicate, version mismatch)Show conflict message
422Validation error from serverDisplay field-level errors
429Rate limitedWait and retry with backoff
500Server errorShow generic error, don't expose details
502Gateway errorRetry after delay
503Service unavailableRetry, show maintenance message
504Gateway timeoutRetry, 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.

Related Protocols

Related Glossary Terms

More in Testing & Mocking