Capítulo 52, Backend
HTTP e consumo de APIs
Quase todo sistema real conversa com outro por HTTP. Aprender a consumir uma API com timeout, tratamento de erro e retentativa é o que separa um script de uma integração confiável.
Código deste capítulo: backend/cap52_http_apis.py
O que acontece em uma requisição
Uma requisição HTTP é um texto com quatro partes: o método (o que fazer), a URL (onde), os cabeçalhos (metadados como formato e credenciais) e, às vezes, um corpo. A resposta tem um código de status, cabeçalhos e um corpo. Quase tudo que você vai depurar está em uma dessas partes.
| Faixa do status | Significado | O que o cliente faz |
|---|---|---|
2xx | Deu certo | Segue em frente |
3xx | Redirecionamento | O cliente HTTP normalmente segue sozinho |
4xx | Erro do cliente (você pediu errado) | Não repita igual: corrija o pedido |
5xx | Erro do servidor | Pode valer uma nova tentativa |
A distinção entre 4xx e 5xx decide a sua estratégia de erro. Repetir um 404 mil vezes não o transforma em 200. Repetir um 503 muitas vezes pode funcionar.
Um servidor local para praticar
Para os exemplos terem saída previsível, sem depender de internet, eu subo um servidor de brincadeira na própria máquina, usando só a biblioteca padrão. Ele tem um endpoint estável, um instável (falha duas vezes e depois funciona), um lento, um paginado e um que recebe JSON. Você não precisa entender o código dele, apenas saber que as rotas existem:
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
import httpx2
contador_instavel = {"n": 0}
class Manipulador(BaseHTTPRequestHandler):
def responder(self, status, corpo):
dados = json.dumps(corpo).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(dados)))
self.end_headers()
self.wfile.write(dados)
def do_GET(self):
url = urlparse(self.path)
if url.path == "/ok":
self.responder(200, {"mensagem": "olá"})
elif url.path == "/instavel":
contador_instavel["n"] += 1
if contador_instavel["n"] < 3:
self.responder(503, {"erro": "indisponível"})
else:
self.responder(200, {"tentativa": contador_instavel["n"]})
elif url.path == "/lento":
time.sleep(1)
self.responder(200, {})
elif url.path == "/itens":
pagina = int(parse_qs(url.query).get("pagina", ["1"])[0])
proxima = pagina + 1 if pagina < 3 else None
self.responder(200, {"itens": list(range((pagina - 1) * 3, pagina * 3)), "proxima": proxima})
else:
self.responder(404, {"erro": "não encontrado"})
def do_POST(self):
tamanho = int(self.headers.get("Content-Length", 0))
corpo = json.loads(self.rfile.read(tamanho) or b"{}")
self.responder(201, {"recebido": corpo})
def log_message(self, *args):
pass
servidor = ThreadingHTTPServer(("127.0.0.1", 0), Manipulador)
threading.Thread(target=servidor.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{servidor.server_address[1]}"
Uma requisição com httpx2
O httpx2 é o cliente HTTP que eu recomendo hoje. Ele tem a mesma interface simples do requests (que continua muito usado), e acrescenta cliente assíncrono, timeouts por padrão e suporte a HTTP/2 (instalado com httpx2[http2]). Use sempre um Client dentro de um with: ele reaproveita a conexão entre as chamadas, o que é muito mais rápido do que abrir uma nova a cada requisição:
with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
resposta = cliente.get("/ok")
print(resposta.status_code, resposta.headers["content-type"], resposta.json())
200 application/json {'mensagem': 'olá'}
Erro HTTP não é exceção
Um 404 ou um 503 não levantam exceção sozinhos: a resposta chega normalmente, e você precisa olhar o status. O método raise_for_status() converte qualquer 4xx ou 5xx em exceção, o que costuma ser o que você quer:
with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
resposta = cliente.get("/nao-existe")
print(resposta.status_code, resposta.is_success)
try:
resposta.raise_for_status()
except httpx2.HTTPStatusError as erro:
print("erro HTTP:", erro.response.status_code)
404 False
erro HTTP: 404
Timeout: sempre
Uma chamada de rede sem timeout pode ficar esperando para sempre e travar o seu serviço inteiro. Toda chamada externa precisa de um limite de tempo explícito:
try:
httpx2.get(f"{BASE}/lento", timeout=0.2)
except httpx2.TimeoutException as erro:
print("estourou o tempo:", type(erro).__name__)
estourou o tempo: ReadTimeout
Retentativas com espera crescente
Falhas 5xx e de rede costumam ser transitórias. A política razoável é tentar de novo poucas vezes, esperando mais a cada tentativa (backoff exponencial), e desistir. Como no capítulo de arquitetura, eu injeto a função de espera para poder testar sem esperar de verdade:
def get_com_retentativas(cliente, caminho, *, tentativas=4, base=0.1, dormir=time.sleep):
ultima = None
for numero in range(1, tentativas + 1):
try:
ultima = cliente.get(caminho)
if ultima.status_code < 500:
return ultima
except httpx2.TransportError:
if numero == tentativas:
raise
if numero < tentativas:
dormir(base * 2 ** (numero - 1))
return ultima
esperas = []
with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
resposta = get_com_retentativas(cliente, "/instavel", dormir=esperas.append)
print(resposta.status_code, resposta.json(), esperas)
200 {'tentativa': 3} [0.1, 0.2]
Só repita o que é seguro repetir
Repetir um
GETé seguro. Repetir umPOSTque cria algo pode criar duas vezes. Só faça retentativa automática em operações idempotentes, ou envie uma chave de idempotência (capítulo seguinte).
Paginação
APIs não devolvem milhões de itens de uma vez. Elas entregam por páginas, e o cliente segue até acabar. Um gerador encapsula isso e quem consome vê um fluxo contínuo:
def todos_os_itens(cliente):
pagina = 1
while pagina is not None:
dados = cliente.get("/itens", params={"pagina": pagina}).json()
yield from dados["itens"]
pagina = dados["proxima"]
with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
print(list(todos_os_itens(cliente)))
[0, 1, 2, 3, 4, 5, 6, 7, 8]
Enviar JSON
O argumento json= serializa o dicionário e já define o cabeçalho Content-Type: application/json:
with httpx2.Client(base_url=BASE, timeout=2.0) as cliente:
resposta = cliente.post("/pedidos", json={"cliente": "Ana", "itens": 2})
print(resposta.status_code, resposta.json())
201 {'recebido': {'cliente': 'Ana', 'itens': 2}}
Várias requisições ao mesmo tempo
Para disparar muitas chamadas independentes, o AsyncClient com asyncio.gather (capítulo 47) trabalha em uma única thread:
import asyncio
async def buscar_varios():
async with httpx2.AsyncClient(base_url=BASE, timeout=2.0) as cliente:
respostas = await asyncio.gather(*(cliente.get("/ok") for _ in range(3)))
return [r.status_code for r in respostas]
print(asyncio.run(buscar_varios()))
[200, 200, 200]
O httpx e o httpx2
O
httpxclássico (versão 0.28) tem a mesma interface, e você o encontra em muito código existente. Ohttpx2se apresenta como a próxima geração do mesmo cliente, e é mantido pela Pydantic. O Starlette, a base do FastAPI, já passou a preferir ohttpx2no seuTestCliente emite um aviso de depreciação se encontrar só ohttpx. Para código novo, eu uso ohttpx2. Para tudo o que este capítulo usa, a migração consiste em trocar o nome do pacote e doimport: eu executei todos os exemplos com ohttpx2, sem nenhuma outra mudança.
O que eu faço em toda integração
Cliente reutilizado, timeout explícito,
raise_for_status(ou tratamento deliberado do status), retentativa só para o que é seguro, credenciais lidas do ambiente (nunca no código) e nenhum segredo nos logs.
Exercício 1
Um cliente que traduz erro HTTP em exceção do domínio
Escreva buscar_json(cliente, caminho) que devolva o JSON em caso de sucesso e levante ErroDeApi (com o atributo status) para qualquer resposta que não seja 2xx.