Templates HTML com Jinja2 e Deploy com Docker

Templates HTML com Jinja2 e Deploy com Docker

Jinja2 no Flask e no FastAPI, e o deploy com Docker e Compose: os filtros que fazem menos do que o nome promete, a assinatura de TemplateResponse que agora dá 500, e os erros de integração do compose — o Redis com senha que a URL não leva e o nginx sem os estáticos.
Python

• • 19 min de leitura

Até aqui construímos APIs que retornam JSON. Mas muitas aplicações precisam também servir HTML — painéis administrativos, relatórios, páginas de erro customizadas. Jinja2 é o motor de templates padrão do Flask e do FastAPI, e dominar seus recursos abre essa porta. Na segunda parte do artigo veremos como empacotar toda a aplicação em um container Docker — o padrão da indústria para deploy consistente e reproduzível.

Jinja2: Motor de Templates

pip install jinja2

Sintaxe Fundamental

<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="UTF-8">
    <title>{% block titulo %}Sistema Escolar{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/estilo.css') }}">
</head>
<body>
    <nav>
        <a href="{{ url_for('inicio') }}">Início</a>
        <a href="{{ url_for('alunos.listar') }}">Alunos</a>
        {% if usuario %}
            <span>Olá, {{ usuario.nome }}!</span>
            <a href="{{ url_for('auth.logout') }}">Sair</a>
        {% else %}
            <a href="{{ url_for('auth.login') }}">Entrar</a>
        {% endif %}
    </nav>

    <main>
        {% with mensagens = get_flashed_messages(with_categories=true) %}
            {% for categoria, mensagem in mensagens %}
                <div class="alerta alerta-{{ categoria }}">{{ mensagem }}</div>
            {% endfor %}
        {% endwith %}

        {% block conteudo %}{% endblock %}
    </main>

    <footer>
        <p>&copy; {{ ano }} Sistema Escolar</p>
    </footer>
</body>
</html>
<!-- templates/alunos/lista.html -->
{% extends "base.html" %}

{% block titulo %}Lista de Alunos{% endblock %}

{% block conteudo %}
<h1>Alunos</h1>

<p>Total: {{ alunos | length }}</p>

<table>
    <thead>
        <tr>
            <th>#</th>
            <th>Nome</th>
            <th>Nota</th>
            <th>Status</th>
        </tr>
    </thead>
    <tbody>
        {% for aluno in alunos %}
        <tr class="{{ 'aprovado' if aluno.nota >= 6 else 'reprovado' }}">
            <td>{{ loop.index }}</td>
            <td>{{ aluno.nome | title }}</td>
            <td>{{ "%.1f" | format(aluno.nota) }}</td>
            <td>
                {% if aluno.nota >= 9 %}
                    Excelente
                {% elif aluno.nota >= 7 %}
                    Bom
                {% elif aluno.nota >= 6 %}
                    Regular
                {% else %}
                    Reprovado
                {% endif %}
            </td>
        </tr>
        {% else %}
            <tr><td colspan="4">Nenhum aluno cadastrado.</td></tr>
        {% endfor %}
    </tbody>
</table>
{% endblock %}

Filtros e Funções Jinja2

<!-- Filtros embutidos -->
{{ nome | upper }}                  <!-- RICARDO -->
{{ nome | lower }}                  <!-- ricardo -->
{{ nome | title }}                  <!-- Ricardo Matos -->
{{ nome | truncate(20) }}           <!-- só corta acima de 20 caracteres, e termina em "..." -->
{{ lista | length }}                <!-- 5 -->
{{ lista | join(", ") }}            <!-- a, b, c -->
{{ valor | round(2) }}              <!-- 9.5 — round não fixa casas; para isso, "%.2f"|format(valor) -->
{{ texto | replace("a", "b") }}
{{ html | safe }}                   <!-- renderiza HTML sem escapar -->
{{ data | strftime("%d/%m/%Y") }}   <!-- filtro customizado -->

<!-- Testes -->
{% if valor is none %}
{% if lista is iterable %}
{% if numero is divisibleby(2) %}
{% if texto is defined %}

Filtros Customizados

# app.py — registrando filtros customizados no Flask
from datetime import datetime

@app.template_filter("data_br")
def filtro_data_br(valor):
    if isinstance(valor, str):
        valor = datetime.fromisoformat(valor)
    return valor.strftime("%d/%m/%Y")

@app.template_filter("moeda")
def filtro_moeda(valor):
    return f"R$ {valor:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")

@app.template_filter("plural")
def filtro_plural(numero, singular, plural):
    return singular if numero == 1 else plural
<!-- Usando filtros customizados -->
{{ aluno.criado_em | data_br }}         <!-- 15/03/2024 -->
{{ produto.preco   | moeda }}           <!-- R$ 1.299,90 -->
{{ total }} {{ total | plural("aluno", "alunos") }} <!-- 1 aluno / 5 alunos -->

Templates com Flask

from flask import Flask, render_template, request, redirect, url_for, flash
from datetime import datetime

app = Flask(__name__)
app.secret_key = "dev-secret"

alunos_dados = [
    {"id": 1, "nome": "Ana Silva",   "nota": 9.5},
    {"id": 2, "nome": "Bruno Costa", "nota": 7.0},
    {"id": 3, "nome": "Carla Souza", "nota": 5.5},
]


@app.context_processor
def variaveis_globais():
    """Variáveis disponíveis em todos os templates."""
    return {
        "ano":     datetime.now().year,
        "usuario": None   # em produção: sessão do usuário logado
    }


@app.route("/")
def inicio():
    return render_template("inicio.html")


@app.route("/alunos")
def listar_alunos():
    nota_min = request.args.get("nota_min", 0, type=float)
    filtrados = [a for a in alunos_dados if a["nota"] >= nota_min]
    return render_template(
        "alunos/lista.html",
        alunos=filtrados,
        nota_min=nota_min
    )


@app.route("/alunos/novo", methods=["GET", "POST"])
def novo_aluno():
    if request.method == "POST":
        nome = request.form.get("nome", "").strip()
        # type=float devolveria None para "8,5", a vírgula que todo brasileiro digita
        try:
            nota = float(request.form.get("nota", "").replace(",", "."))
        except ValueError:
            nota = None

        if not nome or nota is None:
            flash("Nome e nota são obrigatórios.", "erro")
            return render_template("alunos/form.html")

        aluno = {"id": len(alunos_dados) + 1, "nome": nome, "nota": nota}
        alunos_dados.append(aluno)
        flash(f"Aluno '{nome}' cadastrado com sucesso!", "sucesso")
        return redirect(url_for("listar_alunos"))

    return render_template("alunos/form.html")

Templates com FastAPI

pip install jinja2 python-multipart
from datetime import datetime

from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

app       = FastAPI()
templates = Jinja2Templates(directory="templates")

app.mount("/static", StaticFiles(directory="static"), name="static")

_alunos: dict = {}   # banco em memória, como nos artigos anteriores


@app.get("/", response_class=HTMLResponse)
async def inicio(request: Request):
    # request vem primeiro, fora do dicionário — a assinatura antiga dá 500
    return templates.TemplateResponse(
        request,
        "inicio.html",
        {"titulo": "Sistema Escolar"}
    )


@app.get("/alunos", response_class=HTMLResponse)
async def listar_alunos(request: Request):
    return templates.TemplateResponse(
        request,
        "alunos/lista.html",
        {
            "alunos":  list(_alunos.values()),
            "ano":     datetime.now().year
        }
    )


@app.post("/alunos/novo")
async def criar_aluno_form(
    request: Request,
    nome:  str   = Form(...),
    email: str   = Form(...),
    nota:  float = Form(...)
):
    # Processa o formulário e redireciona
    return RedirectResponse(url="/alunos", status_code=303)

O request saiu do dicionário de contexto e passou a ser o primeiro argumento de TemplateResponse. A forma antiga, que ainda aparece em muito tutorial, foi removida no Starlette 1.x e hoje responde 500 com um erro que não aponta a causa: TypeError: cannot use 'tuple' as a dict key. E o base.html do começo do artigo é de Flask: get_flashed_messages não existe no FastAPI, e o url_for de arquivo estático do Starlette recebe path=, não filename=. Um mesmo template serve aos dois frameworks só se evitar essas funções.

Docker: Containerizando a Aplicação

Docker empacota a aplicação com todas as suas dependências em uma unidade isolada — o container — que roda de forma idêntica em qualquer ambiente.

# Instalação — https://docs.docker.com/get-docker/
docker --version
docker compose version

Dockerfile para FastAPI

# Dockerfile
FROM python:3.12-slim

# Variáveis de ambiente
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Diretório de trabalho
WORKDIR /app

# Instala dependências primeiro (aproveitando cache de camadas)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copia o código
COPY . .

# Usuário não-root — boa prática de segurança
RUN adduser --disabled-password --gecos "" appuser && \
    chown -R appuser:appuser /app
USER appuser

# Porta exposta
EXPOSE 8000

# Comando de inicialização
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# .dockerignore
__pycache__/
*.pyc
*.pyo
.env
.git/
.gitignore
venv/
.venv/
tests/
*.md
.pytest_cache/
# Construindo a imagem
docker build -t escola-api:1.0.0 .

# Rodando o container
docker run -d \
  --name escola-api \
  -p 8000:8000 \
  -e SECRET_KEY=minha-chave-secreta \
  -e DATABASE_URL=postgresql://user:senha@host/db \
  escola-api:1.0.0

# Verificando logs
docker logs -f escola-api

# Parando e removendo
docker stop escola-api
docker rm escola-api

Docker Compose: Orquestrando Múltiplos Serviços

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: escola-api
    ports:
      - "8000:8000"
    environment:
      - SECRET_KEY=${SECRET_KEY}
      - DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@db:5432/escola
      - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0   # o redis exige senha
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    volumes:
      - ./logs:/app/logs
    restart: unless-stopped
    networks:
      - escola-net

  db:
    image: postgres:16-alpine
    container_name: escola-db
    environment:
      - POSTGRES_DB=escola
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - escola-net

  redis:
    image: redis:7-alpine
    container_name: escola-redis
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    networks:
      - escola-net

  nginx:
    image: nginx:alpine
    container_name: escola-nginx
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - ./static:/app/static:ro   # o nginx só serve o que existe no container DELE
    depends_on:
      - api
    networks:
      - escola-net

volumes:
  postgres_data:
  redis_data:

networks:
  escola-net:
    driver: bridge
# nginx/nginx.conf
upstream api {
    server api:8000;
}

server {
    listen 80;
    server_name meusite.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name meusite.com;

    ssl_certificate     /etc/nginx/ssl/cert.pem;
    ssl_certificate_key /etc/nginx/ssl/key.pem;

    location / {
        proxy_pass         http://api;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }

    location /static {
        alias /app/static;
        expires 7d;
    }
}
# Arquivo .env para docker-compose
SECRET_KEY=sua-chave-super-secreta-aqui
DB_PASSWORD=senha-do-postgres
REDIS_PASSWORD=senha-do-redis
# Comandos docker compose
docker compose up -d          # sobe todos os serviços em background
docker compose down           # para e remove containers
docker compose logs -f api    # logs em tempo real do serviço api
docker compose ps             # status dos serviços
docker compose exec api bash  # acessa o container da api
docker compose build --no-cache  # reconstrói as imagens

Dois erros do compose passam despercebidos até a aplicação precisar do serviço. O Redis sobe com --requirepass, e uma REDIS_URL sem a senha faz toda operação falhar com NOAUTH — daí o :${REDIS_PASSWORD}@ na URL. E o alias /app/static do nginx procura a pasta no container do próprio nginx, onde ela não existe: sem o volume ./static:/app/static:ro nele, todo arquivo estático é 404. O campo version no topo foi retirado porque a especificação atual do Compose o ignora.

Dockerfile Multi-stage: Imagem Otimizada

# Dockerfile.prod — imagem de produção otimizada
FROM python:3.12-slim AS builder

WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt


FROM python:3.12-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# Copia apenas as dependências instaladas
COPY --from=builder /install /usr/local

# Copia o código da aplicação
COPY app/ ./app/
COPY templates/ ./templates/
COPY static/ ./static/

# Usuário não-root
RUN adduser --disabled-password --gecos "" appuser
USER appuser

EXPOSE 8000

CMD ["uvicorn", "app.main:app", \
     "--host", "0.0.0.0", \
     "--port", "8000", \
     "--workers", "4"]

O ganho do multi-stage depende do que o estágio builder faz. Quando alguma dependência precisa ser compilada — e aí o builder instala build-essential ou as bibliotecas de desenvolvimento do sistema —, compiladores e cabeçalhos ficam para trás, e a imagem final leva só o resultado. Com as duas imagens partindo do mesmo python:3.12-slim e todas as dependências distribuídas como wheel pronta, como aqui, a diferença é pequena: o que se ganha é a separação, não os megabytes.

Health Check e Graceful Shutdown

from fastapi import FastAPI
from contextlib import asynccontextmanager


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    print("Conectando ao banco...")
    yield
    # Shutdown — roda DEPOIS que o uvicorn termina as requisições em andamento;
    # aqui se fecham conexões e pools, não se espera
    print("Encerrando conexões...")


app = FastAPI(lifespan=lifespan)


@app.get("/health")
async def health_check():
    """Endpoint para verificação de saúde — usado pelo Docker e load balancer."""
    return {
        "status":  "healthy",
        "versao":  "1.0.0",
    }


@app.get("/ready")
async def readiness_check():
    """Verifica se a aplicação está pronta para receber tráfego."""
    try:
        # Verifica conexão com banco, cache, etc.
        return {"status": "ready"}
    except Exception:
        from fastapi import HTTPException
        raise HTTPException(status_code=503, detail="Serviço indisponível.")

O asyncio.sleep(1) que o exemplo tinha no shutdown não aguardava nada. Medido com uma requisição de 3 segundos em andamento e um SIGTERM no meio dela: o uvicorn terminou a requisição, o cliente recebeu 200, e só então o código após o yield rodou. O lifespan de encerramento serve para fechar pools e conexões. Para o Docker usar o /health, falta ainda uma instrução HEALTHCHECK no Dockerfile ou um healthcheck no compose; a imagem slim não tem curl, então o teste costuma ser um python -c com urllib.request.

O Jinja2 resolve a parte do HTML com três ideias: herança, em que um base.html define os blocos e cada página preenche os seus; controle de fluxo com for e if, inclusive o else do laço para a lista vazia; e filtros, que formatam valores sem levar lógica para dentro do template. Os filtros embutidos fazem menos do que o nome sugere — round não fixa casas decimais, truncate só age acima do limite —, e um filtro customizado devolve exatamente o que a função retorna. O que muda de um framework para outro está em volta do template: no Flask, render_template e as mensagens flash; no FastAPI, Jinja2Templates, com o request como primeiro argumento da resposta.

O Docker empacota a aplicação com suas dependências numa imagem que roda igual em qualquer lugar, desde que o Dockerfile aproveite o cache de camadas copiando o requirements.txt antes do código e rode com um usuário sem privilégios. O Compose junta API, banco, cache e proxy numa rede própria, e é nele que aparecem os erros de integração: a senha que o Redis exige e a URL não leva, o nginx que procura estáticos num container que não os tem. O multi-stage separa a compilação da execução, e os endpoints de saúde dizem ao orquestrador se o container está vivo e pronto — o encerramento limpo, o uvicorn já faz.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um relatório em Jinja2 mostra as médias com {{ media | round(2) }}, e o cliente reclama que a coluna fica desalinhada: aparecem 9.5, 7.25 e 8.0 lado a lado. Na mesma tela, {{ total | plural("aluno", "alunos") }} exibe só "alunos", sem o número. Explique os dois comportamentos e corrija o template.

Ver resposta

✓ Resposta: O filtro round arredonda o valor, não a sua apresentação: devolve um número, e o número 9.50 é impresso como 9.5 — medido, {{ 9.5 | round(2) }} renderiza 9.5. Para fixar as casas é preciso formatar como texto: {{ "%.2f" | format(media) }} ou, com o separador brasileiro, um filtro próprio como o moeda do artigo, que transformou 1299.9 em R$ 1.299,90. Já o plural faz exatamente o que a função diz: recebe o número, compara com 1 e devolve uma das duas palavras — medido, com total = 5, a saída foi só alunos. O número não aparece porque ninguém o imprimiu; o template certo é {{ total }} {{ total | plural("aluno", "alunos") }}. Vale ainda cuidar do zero: em português, "0 alunos" está correto e a função já o trata como plural, mas em outras línguas a regra muda, e para sistemas multilíngues a ferramenta adequada é o ngettext do gettext, que o Jinja2 integra pela extensão i18n.

Exercício 2

Um formulário de cadastro em Flask lê a nota com request.form.get("nota", type=float). Os professores reclamam que o sistema responde "Nome e nota são obrigatórios" mesmo com os dois campos preenchidos — mas só alguns deles, e nem sempre. Os logs não mostram erro. Qual é a causa provável, e como tratar a entrada?

Ver resposta

✓ Resposta: A vírgula decimal. Professores brasileiros digitam 8,5, e float("8,5") levanta ValueError; o type=float do Flask engole a exceção e devolve None, exatamente como se o campo estivesse vazio — medido, o formulário com nota=8,5 produziu None. A mensagem "obrigatórios" é então verdadeira do ponto de vista do código e absurda para quem preencheu. Afeta "só alguns" porque quem digita 8 ou 8.5 passa. A correção tem duas partes. Na leitura, aceitar os dois separadores — float(texto.replace(",", ".")) dentro de um try, como no exemplo corrigido. Na resposta, distinguir campo vazio de campo inválido: "Informe a nota" é uma mensagem, "Nota inválida: use um número de 0 a 10" é outra, e o valor digitado deve voltar para o formulário para ser corrigido. No HTML, um <input type="number" step="0.1" min="0" max="10"> ajuda, porque o navegador normaliza o valor enviado para ponto — mas a validação no servidor continua obrigatória, já que o formulário pode ser enviado por qualquer cliente.

Exercício 3

Uma equipe atualiza as dependências de um painel em FastAPI e todas as páginas HTML passam a responder 500 com TypeError: cannot use 'tuple' as a dict key, enquanto as rotas JSON seguem normais. Ninguém mexeu nos templates, e o traceback termina dentro do Starlette. O que aconteceu, e como encontrar esse tipo de quebra antes do deploy?

Ver resposta

✓ Resposta: O código usa a assinatura antiga, templates.TemplateResponse("pagina.html", {"request": request, ...}). Por várias versões o Starlette aceitou as duas formas, emitindo um aviso de obsolescência na antiga; na série 1.x ela foi removida, e o nome do template passou a ser lido na posição do request, o que produz um erro sem nenhuma relação aparente com a causa. Medido no Starlette 1.7: a forma antiga respondeu 500, e TemplateResponse(request, "pagina.html", {...}), 200. As rotas JSON não são afetadas porque não passam por templates. Encontrar isso antes do deploy depende de duas práticas. A primeira é tratar avisos de obsolescência como erro nos testes — python -W error::DeprecationWarning -m pytest, ou a opção filterwarnings = error do pytest —, porque a remoção foi anunciada por avisos durante versões. A segunda é ter ao menos um teste que renderize cada página HTML com o TestClient e confira o status 200; sem isso, a suíte cobre só a API JSON e fica verde enquanto o painel está fora do ar. E atualizar dependências com versões travadas num arquivo de lock, uma de cada vez, torna óbvio qual delas quebrou.

Exercício 4

O docker compose up sobe os quatro serviços sem erro, o /health responde, mas o cache nunca funciona — toda operação no Redis falha com NOAUTH Authentication required —, e o site carrega sem CSS nem imagens, com 404 em todo /static/.... Os dois arquivos, compose e nginx, foram copiados de um tutorial. Encontre as duas falhas de integração.

Ver resposta

✓ Resposta: A primeira está na combinação de dois trechos do compose que, isolados, parecem certos. O serviço redis sobe com redis-server --requirepass ${REDIS_PASSWORD}, mas a API recebe REDIS_URL=redis://redis:6379/0, sem credencial: a conexão abre, e cada comando é recusado com NOAUTH. A URL precisa levar a senha, redis://:${REDIS_PASSWORD}@redis:6379/0 — os dois-pontos antes dela indicam usuário vazio. A segunda está no nginx: location /static { alias /app/static; } manda o nginx ler os arquivos do seu próprio sistema de arquivos, e /app/static existe só no container da API. Cada container tem o seu disco; o nginx não enxerga o da API. A correção é montar a pasta também no nginx, ./static:/app/static:ro, ou tirar o location /static e deixar a API servir os estáticos pelo StaticFiles, ao custo de desempenho. O /health respondendo mostra por que esses erros escapam: ele só confirma que o processo está de pé. Um endpoint de prontidão que teste de fato o Redis — um PING — e uma checagem de um arquivo estático no teste de fumaça do deploy teriam acusado os dois.

Exercício 5

Para garantir que nenhuma requisição seja cortada nos deploys, um desenvolvedor coloca await asyncio.sleep(5) depois do yield do lifespan, "para dar tempo das requisições terminarem". Os deploys passam a demorar mais, e o problema de requisições cortadas continua aparecendo de vez em quando. Explique por que o sleep não ajuda e onde está o encerramento limpo de verdade.

Ver resposta

✓ Resposta: O código após o yield roda depois, e não durante, a espera pelas requisições. Ao receber SIGTERM, o uvicorn para de aceitar conexões novas, aguarda as requisições em andamento terminarem e só então executa o shutdown do lifespan. Medido com uma requisição de 3 segundos em curso e um SIGTERM no meio: a requisição terminou, o cliente recebeu 200, e a mensagem do shutdown apareceu em seguida. O sleep(5) só atrasa a saída do processo, com nada mais para esperar. Os cortes que continuam têm outras causas. A mais comum é o prazo do orquestrador: o Docker manda SIGTERM e, se o processo não sair em 10 segundos (o padrão de docker stop), manda SIGKILL — e o sleep extra consome esse prazo, piorando a situação. Outra é o sinal não chegar ao uvicorn, quando o CMD está na forma de texto e o processo 1 do container é um shell; a forma de lista, CMD ["uvicorn", ...], evita isso. A terceira é o balanceador continuar mandando tráfego para a instância que está saindo; o endpoint /ready deve passar a responder 503 assim que o encerramento começa, e o --timeout-graceful-shutdown do uvicorn limita quanto tempo esperar por requisições longas.

Comentários

Mais em Python

Banco de Dados com SQLite e SQLAlchemy
Banco de Dados com SQLite e SQLAlchemy

SQLite com sqlite3 e SQLAlchemy 2.0 em Python, do CRUD ao repositório e às…

Decoradores e Metaprogramação
Decoradores e Metaprogramação

Decoradores, closures e metaprogramação em Python, do açúcar sintático ao que…

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…