FastAPI: APIs modernas com tipagem e documentação automática

FastAPI: APIs modernas com tipagem e documentação automática

O FastAPI valida, converte e documenta a partir dos tipos, e os erros moram no que isso esconde: async def que trava o servidor com código síncrono, um PATCH com null que corrompe o registro, o lifespan que não roda nos testes e a background task que some no deploy.
Python

• • 21 min de leitura

FastAPI é o framework web que mais cresceu no ecossistema Python nos últimos anos — e por boas razões. Ele combina alta performance, tipagem estática, validação automática de dados, documentação interativa gerada automaticamente e suporte nativo a async/await. Se Flask é o canivete suíço, FastAPI é o bisturi: preciso, moderno e construído sobre os padrões mais atuais da linguagem.

Instalação

pip install "fastapi[standard]"

uvicorn é o servidor ASGI que executa a aplicação FastAPI. ASGI (Asynchronous Server Gateway Interface) é o sucessor do WSGI, com suporte nativo a async.

O extra [standard] não é opcional na prática — e as aspas protegem os colchetes em shells como o zsh. Ele traz o uvicorn, o comando fastapi e dois pacotes que o artigo usa sem avisar. O primeiro é o email-validator: sem ele, a simples definição de um campo EmailStr levanta ImportError: email-validator is not installed. O segundo é o cliente HTTP do TestClient: com o FastAPI puro, from fastapi.testclient import TestClient falha com RuntimeError pedindo um pacote a mais. Instalado o fastapi[standard], os dois funcionam.

Primeiro Endpoint

# main.py
from fastapi import FastAPI

app = FastAPI(
    title="API Escolar",
    description="Sistema de gestão de alunos",
    version="1.0.0"
)

@app.get("/")
def raiz():
    return {"mensagem": "API Escolar online"}

@app.get("/saude")
async def verificar_saude():
    return {"status": "ok"}
fastapi dev main.py
# ou, direto no servidor ASGI:
uvicorn main:app --reload

Acesse:

  • http://localhost:8000 — API
  • http://localhost:8000/docs — Swagger UI interativo gerado automaticamente
  • http://localhost:8000/redoc — ReDoc alternativo

Repare que raiz é def e verificar_saude é async def, e a diferença não é estética. O FastAPI roda as funções def num pool de threads, e as async def direto no event loop — o mesmo loop que atende todas as outras requisições. Uma chamada bloqueante dentro de async def (time.sleep, requests, um driver de banco síncrono) trava o servidor inteiro. Medido no uvicorn com cinco requisições simultâneas a uma rota que dorme 1 segundo: como async def com time.sleep, levaram 5,03 s, uma depois da outra, e até o /saude esperou; como def, 1,02 s; como async def com await asyncio.sleep, 1,01 s. A regra: use async def só quando tudo lá dentro for await; na dúvida, def é o caminho seguro. É o mesmo princípio visto em Concorrência: threads, processos e async/await.

Pydantic: Validação e Serialização

FastAPI usa Pydantic para validar dados automaticamente. Você define modelos com tipos e o FastAPI cuida do resto:

from pydantic import BaseModel, EmailStr, Field, field_validator
from typing import Optional
from datetime import datetime

class AlunoBase(BaseModel):
    nome:  str     = Field(..., min_length=2, max_length=100,
                           description="Nome completo do aluno")
    email: EmailStr = Field(..., description="E-mail válido")
    nota:  float   = Field(0.0, ge=0.0, le=10.0, strict=True,   # recusa true e "9.5"
                           description="Nota de 0 a 10")

class AlunoCriar(AlunoBase):
    senha: str = Field(..., min_length=6)

class AlunoAtualizar(BaseModel):
    nome:  Optional[str]   = Field(None, min_length=2)
    email: Optional[EmailStr] = None
    nota:  Optional[float] = Field(None, ge=0.0, le=10.0)

class AlunoResposta(AlunoBase):
    id:        int
    ativo:     bool
    criado_em: datetime

    model_config = {"from_attributes": True}   # permite criar de ORM


class RespostaLista(BaseModel):
    total:  int
    alunos: list[AlunoResposta]


# Validators customizados
class AlunoComValidacao(AlunoBase):
    cpf: str

    @field_validator("cpf")
    @classmethod
    def validar_cpf(cls, v):
        apenas_numeros = "".join(c for c in v if c.isdigit())
        if len(apenas_numeros) != 11:
            raise ValueError("CPF deve ter 11 dígitos.")
        # só confere o tamanho: os dígitos verificadores ficam por conta de outro teste
        return apenas_numeros   # guarda normalizado, sem pontos nem traço

CRUD Completo com FastAPI

from fastapi import FastAPI, HTTPException, status, Query, Path
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime, UTC

app = FastAPI(title="API Escolar", version="1.0.0")

# Banco em memória
_alunos: dict = {}
_proximo_id: int = 1


class AlunoBase(BaseModel):
    nome:  str      = Field(..., min_length=2, max_length=100)
    email: EmailStr
    nota:  float    = Field(0.0, ge=0.0, le=10.0, strict=True)

class AlunoCriar(AlunoBase):
    pass

class AlunoAtualizar(BaseModel):
    nome:  Optional[str]      = Field(None, min_length=2)
    email: Optional[EmailStr] = None
    nota:  Optional[float]    = Field(None, ge=0.0, le=10.0)

class AlunoResposta(AlunoBase):
    id:        int
    aprovado:  bool
    criado_em: str

class RespostaLista(BaseModel):
    total:   int
    pagina:  int
    tamanho: int
    alunos:  list[AlunoResposta]


# GET — listar com filtros e paginação
@app.get(
    "/alunos",
    response_model=RespostaLista,   # com dict, o /docs não mostra o formato
    summary="Listar alunos",
    tags=["Alunos"]
)
async def listar_alunos(
    nota_min: float = Query(0.0,  ge=0.0, le=10.0, description="Nota mínima"),
    nota_max: float = Query(10.0, ge=0.0, le=10.0, description="Nota máxima"),
    pagina:   int   = Query(1,    ge=1,             description="Número da página"),
    tamanho:  int   = Query(10,   ge=1, le=100,     description="Itens por página"),
):
    alunos = [
        a for a in _alunos.values()
        if nota_min <= a["nota"] <= nota_max
    ]

    inicio = (pagina - 1) * tamanho
    fim    = inicio + tamanho

    return {
        "total":   len(alunos),
        "pagina":  pagina,
        "tamanho": tamanho,
        "alunos":  alunos[inicio:fim]
    }


# GET — buscar por ID
@app.get(
    "/alunos/{aluno_id}",
    response_model=AlunoResposta,
    summary="Buscar aluno por ID",
    tags=["Alunos"]
)
async def buscar_aluno(
    aluno_id: int = Path(..., ge=1, description="ID do aluno")
):
    aluno = _alunos.get(aluno_id)
    if not aluno:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Aluno {aluno_id} não encontrado."
        )
    return aluno


# POST — criar
@app.post(
    "/alunos",
    response_model=AlunoResposta,
    status_code=status.HTTP_201_CREATED,
    summary="Criar aluno",
    tags=["Alunos"]
)
async def criar_aluno(dados: AlunoCriar):
    global _proximo_id

    # Verifica e-mail duplicado
    for aluno in _alunos.values():
        # EmailStr só normaliza o domínio: Ana@x.com e ana@x.com passariam
        if aluno["email"].casefold() == dados.email.casefold():
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail="E-mail já cadastrado."
            )

    aluno = {
        "id":        _proximo_id,
        "nome":      dados.nome,
        "email":     dados.email,
        "nota":      dados.nota,
        "aprovado":  dados.nota >= 6.0,
        "criado_em": datetime.now(UTC).isoformat()
    }
    _alunos[_proximo_id]  = aluno
    _proximo_id          += 1
    return aluno


# PATCH — atualizar parcialmente
@app.patch(
    "/alunos/{aluno_id}",
    response_model=AlunoResposta,
    summary="Atualizar aluno",
    tags=["Alunos"]
)
async def atualizar_aluno(
    aluno_id: int,
    dados: AlunoAtualizar
):
    aluno = _alunos.get(aluno_id)
    if not aluno:
        raise HTTPException(status_code=404, detail="Aluno não encontrado.")

    # Atualiza apenas campos enviados; "nome": null não pode apagar campo obrigatório
    atualizados = dados.model_dump(exclude_unset=True, exclude_none=True)
    aluno.update(atualizados)

    if "nota" in atualizados:
        aluno["aprovado"] = aluno["nota"] >= 6.0

    return aluno


# DELETE
@app.delete(
    "/alunos/{aluno_id}",
    status_code=status.HTTP_204_NO_CONTENT,
    summary="Deletar aluno",
    tags=["Alunos"]
)
async def deletar_aluno(aluno_id: int):
    if aluno_id not in _alunos:
        raise HTTPException(status_code=404, detail="Aluno não encontrado.")
    del _alunos[aluno_id]

Quatro detalhes deste CRUD saíram de testes que a versão ingênua não passa. O mais grave é o PATCH: o modelo de atualização aceita None, e exclude_unset=True sozinho mantém um "nome": null enviado de propósito. Sem o exclude_none=True, esse null é gravado, a resposta falha na validação de saída com 500, e o aluno fica corrompido: dali em diante, todo GET e PATCH dele respondem 500. Depois, o EmailStr normaliza só o domínio — Ana@Email.com vira Ana@email.com —, e a checagem de duplicidade com == deixava a mesma pessoa se cadastrar duas vezes; daí o casefold(). O Pydantic, no modo padrão, converte o que consegue: "nota": true virava nota 1.0 e "9.5", texto, virava número; strict=True recusa os dois com 422 e continua aceitando o inteiro 9. Por fim, response_model=dict funciona, mas o /docs mostra só "objeto qualquer"; com RespostaLista, o formato da listagem aparece documentado.

Dependências: Dependency Injection

O sistema de injeção de dependências do FastAPI é um de seus recursos mais poderosos:

import os
import secrets
from fastapi import Depends, Header, HTTPException, Query
from typing import Annotated

TOKEN_API = os.environ["TOKEN_API"]   # nunca no código-fonte


# Dependência simples — autenticação por token
async def verificar_token(
    authorization: Annotated[str | None, Header()] = None
):
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(
            status_code=401,
            detail="Token de autenticação obrigatório.",
            headers={"WWW-Authenticate": "Bearer"},
        )
    token = authorization.removeprefix("Bearer ")
    # compare_digest leva o mesmo tempo acerte ou erre o começo do token
    if not secrets.compare_digest(token, TOKEN_API):
        raise HTTPException(
            status_code=401,
            detail="Token inválido.",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return token


# Dependência de paginação — reutilizável
class Paginacao:
    def __init__(
        self,
        pagina:  int = Query(1,  ge=1),
        tamanho: int = Query(10, ge=1, le=100)
    ):
        self.pagina   = pagina
        self.tamanho  = tamanho
        self.offset   = (pagina - 1) * tamanho


# Usando dependências
@app.get("/admin/alunos", dependencies=[Depends(verificar_token)])
async def listar_admin(paginacao: Annotated[Paginacao, Depends()]):
    alunos = list(_alunos.values())
    inicio = paginacao.offset
    fim    = inicio + paginacao.tamanho
    return {"alunos": alunos[inicio:fim], "pagina": paginacao.pagina}


# Dependência que fornece "banco de dados"
def get_db():
    """Em produção, retornaria uma sessão SQLAlchemy."""
    db = _alunos   # simulando
    try:
        yield db
    finally:
        pass   # fechar conexão aqui


@app.get("/v2/alunos")
async def listar_v2(db: Annotated[dict, Depends(get_db)]):
    return list(db.values())

A dependência de autenticação segue três regras que valem para qualquer API. O token vem do ambiente, nunca do código-fonte, que acaba em repositório, em log de CI e em cópia de desenvolvedor. A comparação usa secrets.compare_digest, que leva o mesmo tempo acerte ou erre o começo do valor, enquanto != para no primeiro caractere diferente. E token ausente ou errado é 401, com o cabeçalho WWW-Authenticate; o 403 é para quem se identificou corretamente e não tem permissão para aquele recurso. O bloco também precisa importar Query, usado na classe Paginacao — sem isso, o módulo nem carrega (NameError).

Middleware e CORS

from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
import time


# CORS — essencial para APIs consumidas por frontend
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000", "https://meusite.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Compressão automática de respostas grandes
app.add_middleware(GZipMiddleware, minimum_size=1000)


# Middleware customizado
class LoggingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        inicio   = time.perf_counter()
        resposta = await call_next(request)
        duracao  = time.perf_counter() - inicio
        print(f"{request.method} {request.url.path} "
              f"→ {resposta.status_code} "
              f"({duracao*1000:.1f}ms)")
        resposta.headers["X-Tempo-Ms"] = f"{duracao*1000:.2f}"
        return resposta


app.add_middleware(LoggingMiddleware)

Lifespan: Startup e Shutdown

from contextlib import asynccontextmanager
from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup — executado ao iniciar
    print("Iniciando aplicação...")
    # Aqui: conectar ao banco, carregar modelos ML, etc.
    _alunos.update({
        1: {"id": 1, "nome": "Ana",   "email": "ana@email.com",   "nota": 9.5, "aprovado": True,  "criado_em": "2024-01-01"},
        2: {"id": 2, "nome": "Bruno", "email": "bruno@email.com", "nota": 7.0, "aprovado": True,  "criado_em": "2024-01-02"},
        3: {"id": 3, "nome": "Carla", "email": "carla@email.com", "nota": 5.5, "aprovado": False, "criado_em": "2024-01-03"},
    })
    print(f"Banco carregado com {len(_alunos)} alunos.")

    yield   # aplicação em execução

    # Shutdown — executado ao encerrar
    print("Encerrando aplicação...")
    _alunos.clear()


# O lifespan entra na criação do app: declare as rotas neste mesmo objeto
app = FastAPI(title="API Escolar", lifespan=lifespan)

Em testes, o lifespan só executa se o TestClient for usado como gerenciador de contexto. Medido com um lifespan que carrega um item: TestClient(app).get(...) encontrou 0 itens, e dentro de with TestClient(app) as client:, 1. Um teste que depende do que o startup prepara — dados iniciais, conexão com banco, modelo carregado — falha ou, pior, passa testando outra coisa. Por isso o exemplo de testes no fim do artigo cria o cliente numa fixture com with.

Background Tasks

import time
from datetime import datetime, UTC

from fastapi import BackgroundTasks


def enviar_email_boas_vindas(email: str, nome: str):
    """Executado depois que a resposta sai — o cliente não espera por ela."""
    time.sleep(2)   # simula envio de e-mail
    print(f"[Email] Boas-vindas enviadas para {nome} <{email}>")


def registrar_log(acao: str, dados: dict):
    timestamp = datetime.now(UTC).isoformat()
    with open("auditoria.log", "a", encoding="utf-8") as f:
        f.write(f"{timestamp} | {acao} | {dados}\n")


@app.post("/alunos/registro", status_code=201)
async def registrar_aluno(
    dados: AlunoCriar,
    background_tasks: BackgroundTasks
):
    global _proximo_id
    aluno = {
        "id":       _proximo_id,
        "nome":     dados.nome,
        "email":    dados.email,
        "nota":     dados.nota,
        "aprovado": dados.nota >= 6.0,
        "criado_em": datetime.now(UTC).isoformat()
    }
    _alunos[_proximo_id]  = aluno
    _proximo_id          += 1

    # Agendando tarefas em background
    background_tasks.add_task(enviar_email_boas_vindas, dados.email, dados.nome)
    background_tasks.add_task(registrar_log, "CRIAR_ALUNO", {"id": aluno["id"]})

    return aluno   # responde imediatamente

A resposta realmente não espera a tarefa: medido no uvicorn, o POST voltou em menos de 10 milissegundos, e o "envio" de 2 segundos aconteceu depois. Mas a tarefa roda no mesmo processo do servidor e não fica registrada em lugar nenhum. Se o processo reiniciar — um deploy, um crash, o --reload do desenvolvimento —, o e-mail que estava na fila simplesmente não sai, e ninguém fica sabendo. BackgroundTasks serve para trabalho curto cuja perda é tolerável, como um log; envio de e-mail, cobrança e processamento pesado pedem uma fila de verdade, com persistência e nova tentativa, como o Celery.

Exemplo Completo: Estrutura de Projeto FastAPI

escola-api/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── config.py
│   ├── modelos/
│   │   ├── __init__.py
│   │   └── aluno.py        ← modelos Pydantic
│   ├── routers/
│   │   ├── __init__.py
│   │   └── alunos.py       ← endpoints
│   ├── servicos/
│   │   ├── __init__.py
│   │   └── aluno_servico.py ← lógica de negócio
│   └── dependencias.py     ← injeção de dependências
├── tests/
│   └── test_alunos.py
├── .env
├── requirements.txt
└── pyproject.toml
# app/main.py
from fastapi import FastAPI
from app.routers import alunos
from app.config import configuracoes
from contextlib import asynccontextmanager


@asynccontextmanager
async def lifespan(app: FastAPI):
    print(f"Iniciando {configuracoes.nome_app} v{configuracoes.versao}")
    yield
    print("Encerrando aplicação.")


def criar_app() -> FastAPI:
    app = FastAPI(
        title=configuracoes.nome_app,
        version=configuracoes.versao,
        lifespan=lifespan
    )
    app.include_router(alunos.router)
    return app


app = criar_app()
# Testando com pytest
import pytest
from fastapi.testclient import TestClient
from app.main import app


@pytest.fixture
def client():
    # o with executa o lifespan; sem ele, startup e shutdown não rodam
    with TestClient(app) as c:
        yield c

def test_listar_alunos(client):
    resposta = client.get("/alunos")
    assert resposta.status_code == 200
    assert "alunos" in resposta.json()

def test_criar_aluno(client):
    resposta = client.post("/alunos", json={
        "nome":  "Eduardo",
        "email": "eduardo@email.com",
        "nota":  8.5
    })
    assert resposta.status_code == 201
    assert resposta.json()["nome"] == "Eduardo"

def test_email_duplicado(client):
    dados = {"nome": "Teste", "email": "duplicado@email.com", "nota": 7.0}
    client.post("/alunos", json=dados)
    resposta = client.post("/alunos", json=dados)
    assert resposta.status_code == 409

Flask vs FastAPI

Característica Flask FastAPI
Curva de aprendizado Baixa Média
Performance Boa Excelente (ASGI)
Tipagem Opcional Nativa
Validação Manual Automática (Pydantic)
Documentação Manual Automática (OpenAPI)
Async nativo Desde a 2.0, com o extra async, sobre WSGI Sim (ASGI)
Maturidade Alta Alta (desde 2018), ainda na série 0.x
Ecossistema Imenso Crescendo rápido

O FastAPI troca código por declaração. Um modelo Pydantic diz o que a rota aceita e o que devolve, e a partir dele o framework valida a entrada, responde 422 com o campo exato que falhou, converte a saída e escreve a documentação OpenAPI que aparece em /docs. Query, Path e Header fazem o mesmo com os parâmetros, e Depends transforma autenticação, paginação e sessão de banco em peças reaproveitáveis. O preço dessa conveniência é saber o que cada declaração faz por baixo: o modo padrão do Pydantic converte true em nota, o EmailStr só normaliza o domínio, e um exclude_unset sem exclude_none deixa um null apagar um campo obrigatório.

A outra metade do framework é o modelo de execução. async def roda no event loop e só compensa quando tudo lá dentro é await; qualquer chamada bloqueante ali para o servidor inteiro, e def é a escolha segura para código síncrono. O lifespan prepara e libera recursos, mas nos testes só executa dentro de with TestClient(app). E BackgroundTasks tira trabalho curto do caminho da resposta sem garantir que ele aconteça, o que o deixa para logs e avisos, nunca para o que não pode se perder.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Uma equipe migra uma API de Flask para FastAPI e, para "aproveitar o async", troca todos os def por async def, sem mexer no resto: as rotas continuam usando requests para chamar um parceiro e o SQLAlchemy síncrono para o banco. Em desenvolvimento tudo parece mais rápido. Em produção, com carga, a latência média dispara, e o balanceador passa a derrubar instâncias porque o health check não responde. Explique o mecanismo e a correção.

Ver resposta

✓ Resposta: O FastAPI decide onde executar cada rota pela forma como ela foi declarada. Uma função def vai para um pool de threads, e várias rodam ao mesmo tempo; uma async def roda direto no event loop, que é um só para o processo inteiro. Dentro de async def, uma chamada síncrona — requests.get, uma consulta do SQLAlchemy síncrono, um time.sleep — não cede o controle, então o loop fica parado até ela terminar, e todas as outras requisições esperam, inclusive o health check. Medido no uvicorn com cinco requisições simultâneas a uma rota que bloqueia 1 segundo: como async def, 5,03 s, em fila; como def, 1,02 s; e uma rota /saude chamada durante o bloqueio levou 0,90 s para responder algo que deveria ser instantâneo. Em desenvolvimento, com uma requisição por vez, a fila não aparece. A correção mais rápida é desfazer a troca: rotas com código síncrono voltam a ser def. Migrar para async def só compensa junto com bibliotecas assíncronas — httpx.AsyncClient, o SQLAlchemy com AsyncSession —, e o que não tiver versão assíncrona pode ser chamado com await asyncio.to_thread(...).

Exercício 2

Um aplicativo envia PATCH /alunos/1 com o corpo {"nome": null}, porque o campo ficou vazio num formulário. A API responde 500. Minutos depois, a secretaria reclama que não consegue mais abrir o cadastro daquele aluno, nem corrigir o nome: toda chamada para o aluno 1 responde 500, enquanto os outros alunos funcionam normalmente. A rota usa dados.model_dump(exclude_unset=True) e um modelo com nome: Optional[str] = Field(None, min_length=2). Explique como uma única requisição estragou o registro e como evitar.

Ver resposta

✓ Resposta: exclude_unset=True separa o que o cliente enviou do que ficou no padrão, mas null enviado de propósito conta como enviado: o dicionário de atualização vira {"nome": None}, e o Optional[str] do modelo de entrada aceita isso. O aluno.update(...) grava o None no registro antes da resposta ser montada. Só na saída o response_model, que exige nome: str, recusa o valor — e erro de validação de resposta é erro do servidor, 500. O dado ruim já está gravado, então toda leitura posterior do aluno 1 falha na mesma validação de saída, e um novo PATCH também, porque devolve o mesmo registro. Medido com o exemplo do artigo: depois do PATCH com null, o GET e um PATCH legítimo de nota responderam 500. Há dois níveis de correção. O mínimo é model_dump(exclude_unset=True, exclude_none=True), que ignora os null — com ele, o mesmo PATCH respondeu 200 sem tocar no nome. O mais robusto é validar o registro inteiro antes de gravar, montando o resultado da mesclagem com o modelo completo e respondendo 422 se não passar. A lição geral: validar só a entrada e confiar na validação da saída deixa uma janela em que o dado inválido já foi persistido.

Exercício 3

A suíte de testes de uma API FastAPI passa inteira na máquina de todos. Um desenvolvedor acrescenta ao lifespan a carga de uma tabela de preços e escreve um teste que confere o preço de um produto: o teste falha com "produto não encontrado", embora a API rodando no uvicorn responda certo. Os testes usam client = TestClient(app) no topo do arquivo. O que está acontecendo, e como estruturar o cliente de teste?

Ver resposta

✓ Resposta: O TestClient só executa o lifespan quando é usado como gerenciador de contexto. Criado solto, ele manda as requisições direto para a aplicação sem disparar o startup, então a tabela de preços nunca é carregada; no uvicorn, o servidor executa o lifespan ao subir, e por isso a API "funciona". Medido com um lifespan que coloca um item num dicionário: TestClient(app).get(...) viu 0 itens, e dentro de with TestClient(app) as client: viu 1. Os testes anteriores passavam porque nenhum dependia do startup — o que é pior do que parece, já que o código de inicialização nunca era exercitado pela suíte. A estrutura certa é uma fixture do pytest que abre o cliente com with e o entrega com yield, como no exemplo do artigo; assim o startup roda antes de cada teste e o shutdown depois. Se o startup for caro, a fixture pode ter scope="module" ou "session", ao custo de compartilhar estado entre testes. E vale ao menos um teste que dependa explicitamente do que o lifespan prepara, para que a omissão do with falhe na hora.

Exercício 4

No cadastro de alunos, o e-mail de boas-vindas é enviado com BackgroundTasks, e a rota responde em milissegundos. Depois de um deploy às 14h, alguns alunos cadastrados entre 13h58 e 14h reclamam que nunca receberam o e-mail. Não há nenhum erro no log, e o cadastro deles está no banco. Explique a perda e proponha uma arquitetura que não perca mensagens.

Ver resposta

✓ Resposta: BackgroundTasks executa a tarefa depois de enviar a resposta, dentro do mesmo processo do servidor e sem registrá-la em lugar nenhum. Medido no uvicorn, o POST respondeu em menos de 10 milissegundos, e o envio simulado de 2 segundos começou só depois; nesse intervalo, a tarefa existe apenas na memória do processo. O deploy encerra os processos antigos: o que estava na fila, ou no meio do envio, desaparece. Não há erro no log porque nada falhou — o código simplesmente nunca rodou —, e o cadastro está no banco porque foi gravado antes da resposta. A arquitetura que não perde mensagens separa o registro da execução. A rota grava a intenção num lugar durável — uma tabela de "e-mails pendentes", na mesma transação do cadastro, ou uma fila como a do Celery com um broker persistente — e responde; um processo à parte consome essa fila, envia, marca como enviado e tenta de novo em caso de falha. Assim, um deploy ou um crash só atrasa o envio. BackgroundTasks continua útil para o que pode se perder sem prejuízo, como uma métrica ou um log auxiliar.

Exercício 5

Um modelo tem nota: float = Field(ge=0, le=10). Uma auditoria no banco encontra notas 1.0 para alunos que, pelo sistema do professor, não tinham nota lançada, e um desenvolvedor mostra que o front-end, num bug, enviava "nota": true quando a caixa "nota lançada" era marcada. Ele pergunta por que o Pydantic, que "valida os tipos", não recusou. Explique e diga como endurecer a validação sem quebrar clientes que mandam 9 em vez de 9.0.

Ver resposta

✓ Resposta: O Pydantic valida no modo lax por padrão: em vez de só conferir o tipo, ele tenta converter o valor recebido quando a conversão é considerada segura. Para um campo float, true é aceito e vira 1.0 — bool é subclasse de int no Python —, e o texto "9.5" vira 9.5. Medido no exemplo do artigo: um POST com "nota": true respondeu 201, com nota 1.0 e aluno reprovado. O ge=0, le=10 não ajuda, porque 1.0 está dentro do intervalo. A correção é o modo estrito no campo, Field(0.0, ge=0.0, le=10.0, strict=True): com ele, true e "9.5" são recusados com 422 e o tipo float_type, enquanto o inteiro 9 continua aceito e vira 9.0 — clientes que mandam número inteiro não quebram. O modo estrito pode valer para o modelo inteiro, com model_config = ConfigDict(strict=True), mas campo a campo é mais seguro numa API que já tem clientes: aplica-se onde uma conversão silenciosa vira dado errado. E o bug do front-end merece o próprio teste: um contrato que recusa o formato errado é o que fez o problema aparecer como erro, e não como nota falsa no banco.

Comentários

Mais em Python

Concorrência: threads, processos e async/await
Concorrência: threads, processos e async/await

Threads, processos e asyncio resolvem problemas diferentes, e escolher errado…

Leitura e Escrita de Arquivos
Leitura e Escrita de Arquivos

Arquivos de texto, CSV, JSON e pickle em Python, com pathlib e o with. E os…

Trabalhando com Datas e Horas
Trabalhando com Datas e Horas

Datas e horas em Python com datetime, timedelta, zoneinfo e dateutil. Com as…