M-Bus Gateway
← Tilbage til blog
· python· httpx· async· api-client· tenacity· mocking· respx· IoT· SaaS

Python httpx — async API-klient til IoT og SaaS

Byg robuste async HTTP-klienter med httpx: AsyncClient, connection pooling, tenacity retry, Stripe integration, timeout-konfiguration og respx/pytest-httpx mocking.

Af M-Bus Gateway

httpx er den moderne erstatning for requests til Python-applikationer der kræver async HTTP — og det er standard i FastAPI-projekter. Her er mønstrene der virker i produktion.


Basal AsyncClient

# server/src/integrations/http_client.py
import httpx
from contextlib import asynccontextmanager


@asynccontextmanager
async def get_client(base_url: str, timeout: float = 10.0):
    """
    Context manager der sikrer korrekt lukning af forbindelser.
    Brug som: async with get_client("https://api.example.com") as client:
    """
    async with httpx.AsyncClient(
        base_url=base_url,
        timeout=httpx.Timeout(
            connect=5.0,    # Forbindelsestimeout
            read=timeout,   # Læsetimeout
            write=5.0,      # Skrivetimeout
            pool=1.0,       # Ventetid på ledig forbindelse fra pool
        ),
        headers={"User-Agent": "mbus-gateway/1.0"},
        follow_redirects=True,
    ) as client:
        yield client


# Brug i endpoint:
async def fetch_dmi_data(zip_code: str) -> dict:
    async with get_client("https://dmigw.govcloud.dk") as client:
        response = await client.get(
            "/v2/climateData/collections/municipalityValue/items",
            params={"municipalityId": zip_code, "limit": 10},
            headers={"X-Gravitee-Api-Key": "..."},
        )
        response.raise_for_status()
        return response.json()

Persistent client (singleton pattern)

# server/src/integrations/persistent_client.py
# Genanvend én client på tværs af requests — connection pooling

import httpx
from functools import lru_cache


class BrevoEmailClient:
    """Persistent httpx-klient til Brevo email API."""

    def __init__(self, api_key: str):
        self._client = httpx.AsyncClient(
            base_url="https://api.brevo.com/v3",
            headers={
                "api-key": api_key,
                "Content-Type": "application/json",
            },
            timeout=httpx.Timeout(10.0),
            limits=httpx.Limits(
                max_connections=10,
                max_keepalive_connections=5,
                keepalive_expiry=30.0,
            ),
        )

    async def send_transactional(self, payload: dict) -> dict:
        response = await self._client.post("/smtp/email", json=payload)
        response.raise_for_status()
        return response.json()

    async def close(self):
        await self._client.aclose()


# Lifespan-registrering i FastAPI:
# app.state.brevo = BrevoEmailClient(settings.brevo_api_key)
# ved shutdown: await app.state.brevo.close()

Tenacity retry — robust mod netværksfejl

# server/src/integrations/retry.py
import httpx
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
    before_sleep_log,
)
import structlog

log = structlog.get_logger()

RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}


def is_retryable(exc: BaseException) -> bool:
    if isinstance(exc, httpx.HTTPStatusError):
        return exc.response.status_code in RETRYABLE_STATUS_CODES
    return isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, httpx.NetworkError))


@retry(
    stop=stop_after_attempt(4),
    wait=wait_exponential(multiplier=1, min=2, max=16),
    retry=retry_if_exception_type((httpx.ConnectError, httpx.ReadTimeout)),
    before_sleep=before_sleep_log(log, "warning"),
    reraise=True,
)
async def fetch_with_retry(client: httpx.AsyncClient, url: str, **kwargs) -> httpx.Response:
    """
    GET med automatisk retry ved netværksfejl.
    Eksponentiel backoff: 2s → 4s → 8s → 16s
    """
    response = await client.get(url, **kwargs)
    if response.status_code in RETRYABLE_STATUS_CODES:
        response.raise_for_status()    # Trigger retry via HTTPStatusError
    return response

Stripe integration med httpx

# server/src/integrations/stripe_client.py
# Stripe Python SDK bruger requests internt — her er ren httpx alternativ

import httpx
import base64
from decimal import Decimal


class StripeHttpClient:
    BASE_URL = "https://api.stripe.com/v1"

    def __init__(self, secret_key: str):
        # Stripe bruger HTTP Basic Auth: nøgle som username, tomt password
        credentials = base64.b64encode(f"{secret_key}:".encode()).decode()
        self._client = httpx.AsyncClient(
            base_url=self.BASE_URL,
            headers={
                "Authorization": f"Basic {credentials}",
                "Stripe-Version": "2024-04-10",
            },
            timeout=httpx.Timeout(30.0),
        )

    async def create_usage_record(
        self,
        subscription_item_id: str,
        quantity: int,
    ) -> dict:
        """Rapportér antal aktive enheder til Stripe metered billing."""
        response = await self._client.post(
            f"/subscription_items/{subscription_item_id}/usage_records",
            data={
                "quantity": str(quantity),
                "action": "set",
            },
        )
        response.raise_for_status()
        return response.json()

    async def get_upcoming_invoice(self, customer_id: str) -> Decimal:
        """Hent beløb for næste faktura."""
        response = await self._client.get(
            "/invoices/upcoming",
            params={"customer": customer_id},
        )
        response.raise_for_status()
        data = response.json()
        # Stripe returnerer øre — konvert til DKK
        return Decimal(data["amount_due"]) / Decimal("100")

Streaming response til store filer

# server/src/integrations/storage_client.py
# Download store filer fra Hetzner Object Storage uden at loade alt i RAM

import httpx
from pathlib import Path


async def download_firmware(url: str, destination: Path) -> None:
    """
    Stream download til fil — bruges ved OTA firmware-download til Pi.
    Loader aldrig hele filen i RAM.
    """
    async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
        async with client.stream("GET", url) as response:
            response.raise_for_status()
            with destination.open("wb") as f:
                async for chunk in response.aiter_bytes(chunk_size=65536):
                    f.write(chunk)


async def upload_pdf(url: str, pdf_bytes: bytes, content_type: str = "application/pdf") -> None:
    """Upload PDF til S3-kompatibel storage."""
    async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
        response = await client.put(
            url,
            content=pdf_bytes,
            headers={"Content-Type": content_type},
        )
        response.raise_for_status()

respx mocking i tests

# server/tests/test_dmi_client.py
import pytest
import respx
import httpx
from server.src.integrations.dmi import fetch_dmi_degree_days


@pytest.mark.asyncio
@respx.mock
async def test_fetch_dmi_degree_days_success():
    """Mock DMI API-kald uden netværk."""
    respx.get(
        "https://dmigw.govcloud.dk/v2/climateData/collections/municipalityValue/items"
    ).mock(
        return_value=httpx.Response(
            200,
            json={
                "features": [
                    {"properties": {"value": 12.5, "from": "2026-01-01T00:00:00Z"}}
                ]
            },
        )
    )

    result = await fetch_dmi_degree_days(zip_code="2800")

    assert result == 12.5


@pytest.mark.asyncio
@respx.mock
async def test_fetch_dmi_degree_days_404():
    """Håndter 404 fra DMI gracefully."""
    respx.get(respx.pattern.M("https://dmigw.govcloud.dk/**")).mock(
        return_value=httpx.Response(404)
    )

    result = await fetch_dmi_degree_days(zip_code="9999")

    assert result is None    # Ingen data → None, ikke exception


@pytest.mark.asyncio
async def test_fetch_dmi_retry(respx_mock):
    """pytest-httpx alternativ: Verificer at retry aktiveres ved 503."""
    import pytest_httpx

    # Første kald returnerer 503, andet kald returnerer 200
    # (bruges med pytest-httpx fixture i stedet for respx)
    pass    # Implementation afhænger af valgt mock-bibliotek

pytest-httpx alternativ

# server/tests/test_brevo_client.py
# pytest-httpx integrerer med httpx på transport-niveau

import pytest
from pytest_httpx import HTTPXMock
from server.src.integrations.brevo import BrevoEmailClient


@pytest.mark.asyncio
async def test_send_email_success(httpx_mock: HTTPXMock):
    httpx_mock.add_response(
        method="POST",
        url="https://api.brevo.com/v3/smtp/email",
        json={"messageId": "<test@brevo.com>"},
        status_code=201,
    )

    client = BrevoEmailClient(api_key="test-key")
    result = await client.send_transactional({
        "to": [{"email": "lejer@example.com"}],
        "subject": "Din årsafregning er klar",
        "htmlContent": "<p>Se din afregning her.</p>",
    })
    await client.close()

    assert result["messageId"] == "<test@brevo.com>"


@pytest.mark.asyncio
async def test_send_email_rate_limit(httpx_mock: HTTPXMock):
    httpx_mock.add_response(status_code=429)

    client = BrevoEmailClient(api_key="test-key")
    with pytest.raises(httpx.HTTPStatusError) as exc_info:
        await client.send_transactional({})
    await client.close()

    assert exc_info.value.response.status_code == 429

Timeout-strategi i produktion

# Timeout-konfiguration pr. use case:

TIMEOUTS = {
    # Hurtige interne API-kald (same datacenter)
    "internal": httpx.Timeout(connect=1.0, read=5.0, write=2.0, pool=0.5),

    # DMI Klimagrid (eksternt, men stabilt)
    "dmi": httpx.Timeout(connect=5.0, read=15.0, write=5.0, pool=1.0),

    # Brevo email (accepterer forsinkelse)
    "email": httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=1.0),

    # Stripe (kritisk — kortere timeout, tenacity retry)
    "stripe": httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=2.0),

    # OTA firmware download (stor fil, lang timeout)
    "ota_download": httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=2.0),
}

# Regel: Sæt ALTID explicit timeout — aldrig httpx.AsyncClient() uden timeout.
# Default er ingen timeout → produktionshang ved netværksfejl.

Konklusion

httpx med AsyncClient, persistent connection pooling og tenacity retry er grundopskriften til robuste integrations-klienter. respx og pytest-httpx giver fuld mock-kontrol i tests uden netværksafhængighed. Sæt altid explicit Timeout — ingen timeout er den hyppigste årsag til produktionshang.

Se Python Pydantic Settings guide eller FastAPI pagination guide.