HTTP client (saída)¶
HTTPClient é um wrapper tipado sobre o httpx.AsyncClient para chamar
serviços externos com retry + backoff exponencial, circuit-breaker,
timeouts padrão e propagação do X-Request-ID. É a contraparte de saída do
middleware HTTP (que cuida do tráfego de entrada). O httpx já
vem no pacote base, então não precisa de extra; o [http] continua
existindo como forma explícita (e opcional) de declarar a dependência.
Uso básico¶
O client é seguro pra compartilhar entre requests no mesmo event loop — ele
reusa o connection pool interno. Use como async context manager (ou guarde
um singleton em resources.py e feche no lifespan).
from typing import Any
from tempest_fastapi_sdk import HTTPClient
async def fetch_user(user_id: str) -> dict[str, Any]:
"""GET /users/{id} no serviço externo."""
async with HTTPClient(base_url="https://api.example.com", timeout=10.0) as client:
response = await client.get(f"/users/{user_id}")
response.raise_for_status()
return response.json()
Não reutilize um client já fechado
Sair do bloco async with chama __aexit__, que fecha o connection pool
(aclose()). Para chamadas pontuais, construa o client dentro do async with
como acima. Para reuso entre requests, guarde um singleton e feche uma vez
no lifespan (veja o exemplo do circuit-breaker abaixo) — nunca envolva cada
chamada de um singleton compartilhado em async with.
Métodos: get / post / put / patch / delete (e request genérico),
todos repassando kwargs pro httpx (json=, params=, headers=, ...) e
devolvendo um httpx.Response.
Retry + backoff + circuit-breaker¶
Passe um RetryPolicy e ajuste os limites do breaker na construção:
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from tempest_fastapi_sdk import CircuitOpenError, HTTPClient, RetryPolicy
client = HTTPClient(
base_url="https://api.example.com",
timeout=5.0,
retry_policy=RetryPolicy(
max_attempts=3, # 1 tentativa + 2 retries
backoff_initial_seconds=0.5, # 0.5s, 1s, 2s... (exponencial)
backoff_max_seconds=8.0, # teto por espera
),
failure_threshold=5, # abre o circuito após 5 falhas seguidas
recovery_seconds=30.0, # meio-aberto após 30s
default_headers={"X-Api-Key": "..."},
propagate_request_id=True, # encaminha o X-Request-ID do request atual
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Fecha o connection pool compartilhado no shutdown."""
yield
await client.aclose()
async def call() -> None:
# Singleton compartilhado — chame direto, sem `async with` por chamada
# (isso fecharia o pool). O pool é fechado uma vez no lifespan acima.
try:
await client.post("/charge", json={"amount": 100})
except CircuitOpenError:
# O circuito está aberto — não martele o upstream caído.
...
- Retry: refeito em erros transitórios (timeouts, 5xx, falhas de conexão)
até
max_attempts, com backoff exponencial limitado porbackoff_max_seconds.RetryPolicy(retry_on_read_timeout=False)tira oReadTimeoutdessa lista e mantém o resto — veja o aviso abaixo. - Circuit-breaker: após
failure_thresholdfalhas consecutivas o circuito abre e as chamadas levantamCircuitOpenErrorimediatamente (sem tocar a rede) até passarrecovery_seconds, quando entra em meio-aberto pra testar. - Request-ID: com
propagate_request_id=True, oX-Request-IDdo request em curso (viaRequestIDMiddleware) é repassado ao upstream, costurando os logs ponta-a-ponta.
stream() só refaz antes da primeira linha
Em client.stream(...) o retry e o circuit-breaker cobrem só a
abertura do stream. Depois que a primeira linha foi entregue, uma
falha no meio (inclusive um ReadTimeout entre dois chunks) propaga
pro chamador — refazer o POST reemitiria desde o começo linhas que você
já consumiu. Até esta versão o ReadTimeout no meio do stream era
refeito, e o chamador via ["Hello ", "Hello ", "world"] de dois POSTs.
Timeout de leitura numa chamada cara
Um ReadTimeout quer dizer que o request chegou ao servidor e a
resposta não veio a tempo. Numa chamada rápida isso é conexão travada, e
refazer resolve. Numa chamada cujo custo é o trabalho do servidor — uma
geração de modelo — o servidor aborta o trabalho quando o cliente
desiste, e a nova tentativa recomeça do zero com o mesmo timeout: a
falha chega depois de max_attempts × timeout em vez de um. Para essas,
passe RetryPolicy(retry_on_read_timeout=False); o ConnectError
continua sendo refeito, porque nenhum trabalho começou. Os geradores de
genai já usam esse default no client que criam.
Guarde como singleton em resources.py
Crie o HTTPClient uma vez (em src/api/dependencies/resources.py),
exponha um get_http_client, e feche no lifespan com await client.aclose()
— assim o connection pool é reaproveitado entre requests.
A mesma curva fora do HTTP — async_retry¶
RetryPolicy descreve a espera: quantas tentativas, quanto tempo entre elas,
com teto. Isso não é uma ideia de HTTP — um connect no RabbitMQ durante o
boot, um dispatch de push e uma chamada a terceiro querem a mesma curva. Só o
HTTPClient se importa com retry_statuses.
async_retry aplica a curva a qualquer corrotina:
from tempest_fastapi_sdk import RetryPolicy, async_retry
BOOT: RetryPolicy = RetryPolicy(max_attempts=5, backoff_initial_seconds=0.5)
@async_retry(BOOT, (ConnectionError, TimeoutError))
async def connect_broker(url: str) -> None:
"""Conecta ao broker, tolerando um servidor que ainda está subindo."""
raise ConnectionError(f"{url} indisponível")
Três decisões que um laço escrito à mão costuma errar, e que aqui são explícitas:
- O que conta como transiente é seu. O segundo argumento é a tupla de
exceções que consome tentativa. Tudo fora dela propaga na hora, sem gastar
budget — um
TypeErrorretentado três vezes é o mesmo bug, mais tarde. - A última exceção propaga, não um wrapper. Quem sabia reagir a um
ConnectionErrorpermanente continua vendo umConnectionError. - Cancelamento é honrado. O default
(Exception,)deixaasyncio.CancelledErrorpassar, porque ela deriva deBaseExceptiondesde o 3.8 — retentar uma task cancelada é brigar com o cancelamento.
max_attempts conta a primeira tentativa
max_attempts=3 é uma tentativa e dois retries, com duas esperas. Zero é
recusado na decoração: rodaria a função nenhuma vez e devolveria None de
algo anotado para devolver T.
Para onde vão os registros — logger=¶
async_retry escreve exatamente um WARNING por retry e um ERROR quando o
budget acaba. O parâmetro logger= aceita qualquer coisa que saiba fazer essas
duas chamadas — o logging.Logger da stdlib, ou o LogUtils que este próprio
SDK entrega ao serviço:
from tempest_fastapi_sdk import LogUtils, RetryPolicy, async_retry
logger: LogUtils = LogUtils(__name__)
BROKER: RetryPolicy = RetryPolicy(max_attempts=4, backoff_initial_seconds=0.2)
@async_retry(BROKER, (ConnectionError,), logger=logger)
async def publish(payload: dict[str, str]) -> None:
"""Publica no broker, tolerando uma reconexão em andamento."""
raise ConnectionError(f"broker indisponível para {payload}")
O contrato é RetryLogger, não logging.Logger
Até a 0.280.0 a anotação era logging.Logger | None, e LogUtils não é
um logging.Logger — ele embrulha um. O código acima rodava, porque as
formas batem, e falhava só no type check:
error: Argument "logger" to "async_retry" has incompatible type "LogUtils";
expected "Logger | None" [arg-type]
Quem não roda type-checker nunca descobria; quem roda escrevia
logger=logger.logger, alcançando por baixo da fachada. Hoje o parâmetro é
tipado como RetryLogger — um Protocol com warning e error, que é
tudo o que a função usa — e os dois passam. Os parâmetros obrigatórios do
protocolo são posicionais-only de propósito: logging.Logger chama o
primeiro de msg e LogUtils chama de message, e um protocolo que
nomeasse o parâmetro recusaria um dos dois no basedpyright.
Recap¶
HTTPClient=httpx.AsyncClienttipado + retry/backoff/circuit-breaker + X-Request-ID.- Sem extra obrigatório (
httpxvem no pacote base;[http]é opcional). Métodosget/post/put/patch/delete/request→httpx.Response. RetryPolicy(max_attempts, backoff_initial_seconds, backoff_max_seconds)controla o retry;retry_on_read_timeout=Falsepara chamada cara (geração).async_retry(policy, exceptions, logger=)aplica a curva a qualquer corrotina;logger=aceitalogging.LoggerouLogUtils.failure_threshold/recovery_secondscontrolam o breaker;CircuitOpenErrorquando aberto.- Compartilhe um singleton e feche com
aclose()no shutdown.