Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 050 / 100

Calling HTTP APIs with httpx: what developers should know

A modern client for sync and async code.

Expona Admin7 min read

Calling HTTP APIs with httpx: what developers should know

When building backend systems, making HTTP requests to external APIs is one of those tasks that feels simple until it isn't. The requests library has served the Python community well for years, but if you're working on modern async codebases or need finer control over connection management, httpx is worth your attention. Unlike requests, httpx is built from the ground up to support both synchronous and asynchronous code, giving teams flexibility without switching libraries.

Over the next few sections, we'll walk through the patterns that separate solid HTTP clients from ones that fail under load. We're talking about connection pooling, explicit timeouts, intelligent retries, and testing strategies that actually catch bugs. If you've ever debugged a production incident caused by connection exhaustion or a missing timeout, you'll recognize why these details matter.

Reuse one client for connection pooling

One of the most common mistakes we see in production code is creating a new HTTP client for every request. Each instantiation opens fresh TCP connections, exhausts file descriptors, and defeats the purpose of HTTP keep-alive. With httpx, the pattern is straightforward: create a single client instance and reuse it throughout your application lifecycle.

Connection pooling automatically manages a pool of persistent connections, reducing latency and resource consumption. When you make multiple requests to the same host, httpx reuses connections from the pool instead of establishing new ones each time.

python
import httpx

# Wrong: Creates a new client for every request
def fetch_user_old(user_id):
    with httpx.Client() as client:
        response = client.get(f"https://api.example.com/users/{user_id}")
    return response.json()

# Right: Reuse a single client
client = httpx.Client(timeout=10.0)

def fetch_user(user_id):
    response = client.get(f"https://api.example.com/users/{user_id}")
    return response.json()

def fetch_multiple_users(user_ids):
    results = []
    for user_id in user_ids:
        results.append(fetch_user(user_id))
    return results

For async code, the principle is identical but you'll use httpx.AsyncClient. Create it once and reuse it across your coroutines:

python
import httpx
import asyncio

async_client = httpx.AsyncClient(timeout=10.0)

async def fetch_user_async(user_id):
    response = await async_client.get(f"https://api.example.com/users/{user_id}")
    return response.json()

async def fetch_batch(user_ids):
    tasks = [fetch_user_async(uid) for uid in user_ids]
    return await asyncio.gather(*tasks)

In a FastAPI application, you'd typically store the client as an application dependency or context variable, ensuring it lives for the lifetime of your server process. The connection pool limits (defaults are sensible but tunable) prevent resource exhaustion even under heavy concurrent load.

Set timeouts explicitly

Timeouts are your shield against hanging requests, unresponsive APIs, and cascading failures. Without them, a single slow external service can deadlock your entire backend. httpx requires you to think about timeouts upfront, which is a feature, not a limitation.

Timeouts in httpx come in four flavors: connect, read, write, and pool. Setting a single float value applies it uniformly; for more control, use a Timeout object:

python
import httpx

# Global timeout for all operations
client = httpx.Client(timeout=10.0)

# Fine-grained control
timeout = httpx.Timeout(
    timeout=10.0,  # Default for all phases
    connect=5.0,   # Time to establish connection
    read=8.0,      # Time waiting for response data
    write=5.0,     # Time sending request data
    pool=1.0       # Time waiting for a connection from the pool
)
client = httpx.Client(timeout=timeout)

# Override per-request
response = client.get("https://api.example.com/users", timeout=30.0)

In real-world scenarios, different endpoints may need different timeouts. A user lookup might complete in 2 seconds, but a bulk report generation could take 30. Rather than hardcoding timeouts everywhere, consider a configuration layer:

python
TIMEOUT_CONFIG = {
    "users": httpx.Timeout(5.0, connect=2.0),
    "reports": httpx.Timeout(60.0, connect=3.0),
    "health_check": httpx.Timeout(2.0),
}

def get_timeout(endpoint):
    return TIMEOUT_CONFIG.get(endpoint, httpx.Timeout(10.0))

# Usage
response = client.get("https://api.example.com/users", timeout=get_timeout("users"))

Always set a timeout, even if you think the endpoint is "usually fast." That thinking has started many production incidents.

Retry with tenacity

Networks are unreliable. Temporary glitches—DNS hiccups, brief connection drops, transient 5xx errors—happen more often than you'd think. Blindly retrying every failure is wrong, but intelligent retries with exponential backoff can save you from cascading failures.

The tenacity library pairs beautifully with httpx for declarative retry logic:

python
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception

def is_retryable(exception):
    """Only retry on specific conditions"""
    if isinstance(exception, httpx.ConnectError):
        return True
    if isinstance(exception, httpx.ReadTimeout):
        return True
    if isinstance(exception, httpx.HTTPStatusError):
        return exception.response.status_code >= 500
    return False

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10),
    retry=retry_if_exception(is_retryable)
)
def fetch_user_with_retry(user_id):
    response = client.get(
        f"https://api.example.com/users/{user_id}",
        timeout=10.0
    )
    response.raise_for_status()
    return response.json()

This pattern retries up to 3 times if a connect error, read timeout, or server error occurs. The wait time grows exponentially: 1–2 seconds on first retry, up to 10 seconds on later attempts. Notice that we don't retry on client errors (4xx) or connection timeouts—there's no point retrying invalid input or a genuinely unresponsive host.

For async functions, tenacity supports them directly:

python
@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10),
    retry=retry_if_exception(is_retryable)
)
async def fetch_user_async_with_retry(user_id):
    response = await async_client.get(
        f"https://api.example.com/users/{user_id}",
        timeout=10.0
    )
    response.raise_for_status()
    return response.json()

In high-throughput systems, consider circuit breakers or bulkheads to prevent retry storms from overwhelming a struggling upstream service. But for most scenarios, tenacity's retry-with-backoff approach is the right starting point.

Mock with respx in tests

Testing code that makes HTTP calls is tricky if you're not careful. Making real requests to external APIs is slow, unreliable, and couples your test suite to external systems. The solution is proper mocking with respx, a library designed specifically for mocking httpx calls.

respx works as a context manager or decorator and intercepts httpx requests before they leave your process:

python
import httpx
import respx
import pytest

@respx.mock
def test_fetch_user_success():
    """Test successful user fetch"""
    respx.get("https://api.example.com/users/123").mock(
        return_value=httpx.Response(
            200,
            json={"id": 123, "name": "Alice", "email": "alice@example.com"}
        )
    )
    
    result = fetch_user(123)
    
    assert result["name"] == "Alice"
    assert respx.calls.call_count == 1

@respx.mock
def test_fetch_user_server_error():
    """Test handling of server errors"""
    respx.get("https://api.example.com/users/123").mock(
        return_value=httpx.Response(500, text="Internal Server Error")
    )
    
    with pytest.raises(httpx.HTTPStatusError):
        fetch_user(123)

For more complex scenarios, you can inspect request properties and return different responses:

python
@respx.mock
def test_fetch_multiple_users():
    """Test batching with dynamic responses"""
    def user_response(request):
        user_id = request.url.path.split("/")[-1]
        return httpx.Response(
            200,
            json={"id": int(user_id), "name": f"User{user_id}"}
        )
    
    respx.get("https://api.example.com/users/").mock(side_effect=user_response)
    
    results = fetch_multiple_users([1, 2, 3])
    
    assert len(results) == 3
    assert results[0]["name"] == "User1"

respx also captures all requests for assertions, helping you verify that your code is calling the API correctly and not making redundant requests. This is invaluable for catching performance regressions in test.

Common mistakes

  • Creating a client inside a request handler: If you're using FastAPI or Flask and instantiate a new httpx.Client for each request, you're thrashing connections. Create the client once at startup and inject it as a dependency.
  • No timeout on client creation: Relying on the default timeout is risky. Be explicit. If an endpoint doesn't declare a timeout, assume the developer forgot and the default will catch issues later.
  • Retrying everything, including bad input: Retrying a 400 Bad Request makes no sense. Only retry on transient failures: connection errors, timeouts, and 5xx server errors. Retrying client errors wastes time and masks bugs.
  • Testing with real external API calls: Tests that call the real API are brittle, slow, and fail when the internet is down. Always mock external dependencies in unit tests. Reserve integration tests for dedicated environments.
  • Mixing sync and async clients without thinking: You can't await a sync client's result, and you can't use an async client in synchronous code without event loops. Pick one pattern and stick with it at the module level.

Key takeaways

  • Create one httpx.Client (or AsyncClient) per application and reuse it. Connection pooling is automatic and essential for performance and resource efficiency.
  • Always set an explicit timeout. Use a configuration layer if different endpoints need different limits. Network calls without timeouts invite deadlocks.
  • Combine tenacity with httpx for intelligent retries. Retry only on transient failures—connection errors, timeouts, and server errors. Never retry client errors.
  • Mock httpx calls with respx in tests. Never make real HTTP requests in your test suite unless you're running explicit integration tests in a controlled environment.
  • Inspect httpx's response object carefully. Use response.raise_for_status() to convert HTTP errors to exceptions, or handle status codes explicitly depending on your use case.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles