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

Introdução ao Desenvolvimento Web com Flask
Introdução ao Desenvolvimento Web com Flask

Flask começa com uma rota e uma função, e os erros aparecem no que ele deixa…

Operadores e Expressões
Operadores e Expressões

Os operadores parecem a parte trivial da linguagem, e guardam algumas das…

Funções: definição, parâmetros e escopo
Funções: definição, parâmetros e escopo

Nomear um bloco de código é o primeiro passo para escrever programas que…