Flask é um microframework web para Python — leve, flexível e direto ao ponto. Diferente de frameworks como Django, Flask não impõe estrutura, ORM ou sistema de templates específico. Você começa com o mínimo e adiciona apenas o que precisa. Isso o torna ideal para APIs, microserviços e projetos onde o controle total da arquitetura é importante.
Instalação e Primeiro Servidor
pip install flask
# app.py
from flask import Flask
app = Flask(__name__)
@app.route("/")
def inicio():
return "Olá, Flask!"
@app.route("/sobre")
def sobre():
return "Sistema de gestão escolar v1.0"
if __name__ == "__main__":
app.run(debug=True)
python app.py
# * Running on http://127.0.0.1:5000
debug=True ativa o reloader automático — o servidor reinicia ao salvar o arquivo — e troca a página de erro pelo depurador do Werkzeug. É por causa dele que o modo nunca pode ir para produção: a página de erro traz um console Python interativo, que executa código no servidor a partir do navegador, protegido apenas por um PIN. O mesmo vale para o app.run() em geral, que é um servidor de desenvolvimento; em produção a aplicação roda sob um servidor WSGI, como o Gunicorn ou o Waitress. No dia a dia, o comando de linha de comando dispensa o bloco if __name__: flask --app app run --debug faz o mesmo que python app.py com debug=True, sem que a opção fique gravada no código.
Rotas e Métodos HTTP
from flask import Flask, request, jsonify
app = Flask(__name__)
# Rota com parâmetro de URL
@app.route("/usuarios/<int:usuario_id>")
def buscar_usuario(usuario_id):
return jsonify({"id": usuario_id, "nome": f"Usuário {usuario_id}"})
# Parâmetro string
@app.route("/produtos/<string:slug>")
def buscar_produto(slug):
return jsonify({"slug": slug})
# Múltiplos métodos
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
dados = request.get_json(silent=True) or {} # None se o corpo não for JSON
email = dados.get("email")
senha = dados.get("senha")
if not email or not senha:
return jsonify({"erro": "email e senha são obrigatórios."}), 400
return jsonify({"mensagem": f"Login de {email} processado."})
return jsonify({"mensagem": "Envie POST com email e senha."})
# Rota com valor padrão
@app.route("/pagina/")
@app.route("/pagina/<int:numero>")
def pagina(numero=1):
return jsonify({"pagina": numero})
Três comportamentos das rotas que o exemplo não mostra. /pagina, sem a barra, responde 308 redirecionando para /pagina/, porque a rota foi declarada com a barra final; o contrário, declarar sem barra e acessar com ela, dá 404. O conversor int não aceita negativos — /pagina/-1 é 404, e <int(signed=True):numero> resolve —, e o string não aceita barra, de modo que /produtos/a/b também é 404 (para isso existe path). E o login usa get_json(silent=True) de propósito: desde o Flask 2.3, get_json() sem esse argumento responde 415 Unsupported Media Type quando o corpo não chega como application/json — um POST vindo de formulário HTML nunca alcançaria o código da função.
Request: Acessando Dados da Requisição
from flask import request
@app.route("/dados", methods=["POST"])
def receber_dados():
# JSON — sem silent=True, um POST de formulário pararia aqui com 415
dados_json = request.get_json(silent=True)
# Query parameters — ?nome=Ana&idade=25
nome = request.args.get("nome", "Anônimo")
idade = request.args.get("idade", type=int) # None se ausente OU inválido
# Form data
campo = request.form.get("campo")
# Headers
token = request.headers.get("Authorization")
# Arquivos
arquivo = request.files.get("documento")
# Informações da requisição
ip = request.remote_addr # atrás de proxy, é o IP do proxy
metodo = request.method
url = request.url
return jsonify({
"ip": ip,
"metodo": metodo,
"nome": nome,
})
O mesmo 415 explica o comentário no topo da função: com get_json() na primeira linha, qualquer envio de formulário ou de arquivo para /dados parava ali, e as linhas que leem request.form e request.files nunca eram executadas. Duas armadilhas silenciosas completam o quadro. O type=int de request.args.get devolve None tanto para parâmetro ausente quanto para inválido: ?idade=abc não gera erro nenhum. E remote_addr, atrás de um proxy reverso ou balanceador, é o endereço do proxy, não o do cliente; o IP real vem no cabeçalho X-Forwarded-For, e o jeito seguro de lê-lo é o werkzeug.middleware.proxy_fix.ProxyFix, configurado com o número de proxies que existem de fato — confiar no cabeçalho sem isso deixa qualquer cliente escolher o próprio IP.
Responses: Controlando a Resposta
from flask import jsonify, make_response, redirect, url_for, abort
# JSON com status code
@app.route("/criado", methods=["POST"])
def criar():
return jsonify({"id": 1, "mensagem": "Criado"}), 201
# Response customizada
@app.route("/customizado")
def customizado():
resposta = make_response(
jsonify({"dados": "aqui"}),
200
)
resposta.headers["X-Custom-Header"] = "valor"
resposta.headers["Cache-Control"] = "no-cache"
return resposta
# Redirecionamento
@app.route("/antigo")
def antigo():
return redirect(url_for("inicio"), 301)
# Abortando com erro
@app.route("/restrito")
def restrito():
abort(403) # lança Forbidden automaticamente
Tratamento de Erros
from flask import jsonify
from werkzeug.exceptions import HTTPException
@app.errorhandler(404)
def nao_encontrado(erro):
return jsonify({
"erro": "Recurso não encontrado.",
"codigo": 404
}), 404
@app.errorhandler(403)
def proibido(erro):
return jsonify({
"erro": "Acesso negado.",
"codigo": 403
}), 403
# Os demais erros HTTP (405, 415, 400...) mantêm o próprio código
@app.errorhandler(HTTPException)
def erro_http(erro):
return jsonify({
"erro": erro.description,
"codigo": erro.code
}), erro.code
# Qualquer outra exceção: o detalhe vai para o log, não para o cliente
@app.errorhandler(Exception)
def erro_generico(erro):
app.logger.exception("Erro não tratado")
return jsonify({
"erro": "Erro interno do servidor.",
"codigo": 500
}), 500
O errorhandler(Exception) é o ponto que mais engana. Ele não pega só os erros do código: pega também as exceções HTTP que não têm handler próprio. Registrado sem o de HTTPException — só ele e os de 403, 404 e 500 —, um POST numa rota que só aceita GET responde 500 com o texto 405 Method Not Allowed — o cliente recebe o código errado —, e o handler de 500 nunca é chamado, porque o de Exception chega antes. Pior: devolver str(erro) manda ao cliente a mensagem interna da exceção, e um arquivo que não abre expõe o caminho completo dele no servidor. Por isso o handler de HTTPException vem antes, preservando o código de cada erro, e o genérico registra o detalhe no log e responde uma mensagem neutra.
Blueprints: Organizando a Aplicação
Blueprints permitem dividir a aplicação em módulos independentes:
projeto/
├── app.py
├── config.py
└── routes/
├── __init__.py
├── alunos.py
└── disciplinas.py
# routes/alunos.py
from flask import Blueprint, jsonify, request
alunos_bp = Blueprint("alunos", __name__, url_prefix="/alunos")
# Banco em memória para exemplo
_alunos = {}
_proximo_id = 1
@alunos_bp.route("/", methods=["GET"])
def listar():
nota_min = request.args.get("nota_min", 0, type=float)
resultado = [
a for a in _alunos.values()
if a["nota"] >= nota_min
]
return jsonify(resultado)
@alunos_bp.route("/<int:aluno_id>", methods=["GET"])
def buscar(aluno_id):
aluno = _alunos.get(aluno_id)
if not aluno:
return jsonify({"erro": "Aluno não encontrado."}), 404
return jsonify(aluno)
@alunos_bp.route("/", methods=["POST"])
def criar():
global _proximo_id
dados = request.get_json(silent=True)
if not isinstance(dados, dict) or not dados.get("nome") or not dados.get("email"):
return jsonify({"erro": "nome e email são obrigatórios."}), 400
try:
nota = float(dados.get("nota", 0.0))
except (TypeError, ValueError):
return jsonify({"erro": "nota deve ser um número."}), 400
aluno = {
"id": _proximo_id,
"nome": dados["nome"],
"email": dados["email"],
"nota": nota,
}
_alunos[_proximo_id] = aluno
_proximo_id += 1
return jsonify(aluno), 201
@alunos_bp.route("/<int:aluno_id>", methods=["PUT"])
def atualizar(aluno_id):
aluno = _alunos.get(aluno_id)
if not aluno:
return jsonify({"erro": "Aluno não encontrado."}), 404
dados = request.get_json(silent=True)
if not isinstance(dados, dict):
return jsonify({"erro": "Body JSON obrigatório."}), 400
try:
nota = float(dados.get("nota", aluno["nota"]))
except (TypeError, ValueError):
return jsonify({"erro": "nota deve ser um número."}), 400
aluno.update({
"nome": dados.get("nome", aluno["nome"]),
"email": dados.get("email", aluno["email"]),
"nota": nota,
})
return jsonify(aluno)
@alunos_bp.route("/<int:aluno_id>", methods=["DELETE"])
def deletar(aluno_id):
if aluno_id not in _alunos:
return jsonify({"erro": "Aluno não encontrado."}), 404
del _alunos[aluno_id]
return "", 204
# app.py
from flask import Flask
from routes.alunos import alunos_bp
def criar_app():
app = Flask(__name__)
# Registrando blueprints
app.register_blueprint(alunos_bp)
return app
app = criar_app()
if __name__ == "__main__":
app.run(debug=True)
A validação da nota não é detalhe. Sem a conversão com float, um único aluno criado com "nota": "9" — texto, não número — era aceito com 201, e a partir dali toda listagem respondia 500 com TypeError: '>=' not supported between instances of 'str' and 'int': um dado ruim gravado derruba a rota para todo mundo. Repare também que o blueprint registra a rota como / dentro de url_prefix="/alunos", então o endereço canônico é /alunos/; /alunos responde 308 e o cliente precisa seguir o redirecionamento (o 308, ao contrário do 301, preserva o método POST).
Middleware e Before/After Request
from flask import request, g
import time
@app.before_request
def antes_da_requisicao():
g.inicio = time.perf_counter() # g é o contexto global da requisição
print(f"→ {request.method} {request.path}")
@app.after_request
def depois_da_requisicao(resposta):
duracao = time.perf_counter() - g.get("inicio", time.perf_counter())
resposta.headers["X-Tempo-Ms"] = f"{duracao * 1000:.2f}"
print(f"← {resposta.status_code} em {duracao*1000:.1f}ms")
return resposta
@app.teardown_request
def encerrar_requisicao(excecao=None):
# Executado ao final — com ou sem erro
# Ideal para fechar conexões de banco
pass
Configuração
# config.py
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.getenv("SECRET_KEY", "dev-inseguro")
DEBUG = False
TESTING = False
class ConfigDesenvolvimento(Config):
DEBUG = True
DATABASE_URL = "sqlite:///dev.db"
class ConfigProducao(Config):
DATABASE_URL = os.getenv("DATABASE_URL")
SECRET_KEY = os.getenv("SECRET_KEY") # obrigatório em produção
class ConfigTestes(Config):
TESTING = True
DATABASE_URL = "sqlite:///:memory:"
configs = {
"desenvolvimento": ConfigDesenvolvimento,
"producao": ConfigProducao,
"testes": ConfigTestes,
}
# app.py
def criar_app(ambiente="desenvolvimento"):
app = Flask(__name__)
app.config.from_object(configs[ambiente])
if not app.config["SECRET_KEY"]:
# sem isto, a aplicação sobe e só quebra no primeiro uso de sessão
raise RuntimeError("SECRET_KEY não definida.")
return app
A checagem em criar_app existe porque a falta da chave não impede a aplicação de subir. Com SECRET_KEY ausente do ambiente, ConfigProducao fica com None, tudo funciona, e só a primeira rota que usar session quebra, com 500 e RuntimeError: The session is unavailable because no secret key was set. Falhar na inicialização troca um erro intermitente em produção por um deploy que não completa. Note ainda que o padrão "dev-inseguro" da classe base continua valendo em desenvolvimento e em testes, que é exatamente onde ele deve valer.
Exemplo Completo: API REST de Escola
import re
from datetime import datetime, UTC
from flask import Flask, jsonify, request
app = Flask(__name__)
app.json.ensure_ascii = False # "José", e não "Jos\u00e9", no JSON de saída
# Simulando banco em memória
banco = {
"alunos": {
1: {"id": 1, "nome": "Ana Silva", "email": "ana@email.com", "nota": 9.5},
2: {"id": 2, "nome": "Bruno Costa", "email": "bruno@email.com", "nota": 7.0},
3: {"id": 3, "nome": "Carla Souza", "email": "carla@email.com", "nota": 5.5},
},
"proximo_id": 4
}
def validar_aluno(dados, obrigatorio=True):
erros = {}
if obrigatorio or "nome" in dados:
nome = dados.get("nome")
if not isinstance(nome, str) or len(nome.strip()) < 2:
erros["nome"] = "Nome deve ter ao menos 2 caracteres."
if obrigatorio or "email" in dados:
email = dados.get("email")
if not isinstance(email, str) or not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email):
erros["email"] = "E-mail inválido."
if "nota" in dados:
nota = dados["nota"]
# bool é subclasse de int: sem essa checagem, true viraria nota 1.0
if isinstance(nota, bool) or not isinstance(nota, (int, float)):
erros["nota"] = "Nota deve ser um número."
elif not 0 <= nota <= 10:
erros["nota"] = "Nota deve ser entre 0 e 10."
return erros
@app.route("/alunos", methods=["GET"])
def listar_alunos():
alunos = list(banco["alunos"].values())
nota_min = request.args.get("nota_min", type=float) # None se inválido
# type=bool não serve: bool("false") é True
aprovados = request.args.get("aprovados", "").lower() in ("1", "true", "sim")
if nota_min is not None:
alunos = [a for a in alunos if a["nota"] >= nota_min]
if aprovados:
alunos = [a for a in alunos if a["nota"] >= 6.0]
return jsonify({
"total": len(alunos),
"alunos": alunos
})
@app.route("/alunos/<int:aluno_id>", methods=["GET"])
def buscar_aluno(aluno_id):
aluno = banco["alunos"].get(aluno_id)
if not aluno:
return jsonify({"erro": "Aluno não encontrado."}), 404
return jsonify(aluno)
@app.route("/alunos", methods=["POST"])
def criar_aluno():
dados = request.get_json(silent=True)
if not isinstance(dados, dict):
return jsonify({"erro": "Body JSON obrigatório."}), 400
erros = validar_aluno(dados)
if erros:
return jsonify({"erros": erros}), 422
aluno_id = banco["proximo_id"]
aluno = {
"id": aluno_id,
"nome": dados["nome"],
"email": dados["email"],
"nota": float(dados.get("nota", 0.0)),
"criado_em": datetime.now(UTC).isoformat()
}
banco["alunos"][aluno_id] = aluno
banco["proximo_id"] += 1
return jsonify(aluno), 201
@app.route("/alunos/<int:aluno_id>", methods=["PATCH"])
def atualizar_aluno(aluno_id):
aluno = banco["alunos"].get(aluno_id)
if not aluno:
return jsonify({"erro": "Aluno não encontrado."}), 404
dados = request.get_json(silent=True)
if not isinstance(dados, dict):
return jsonify({"erro": "Body JSON obrigatório."}), 400
erros = validar_aluno(dados, obrigatorio=False)
if erros:
return jsonify({"erros": erros}), 422
if "nome" in dados: aluno["nome"] = dados["nome"]
if "email" in dados: aluno["email"] = dados["email"]
if "nota" in dados: aluno["nota"] = float(dados["nota"])
return jsonify(aluno)
@app.route("/alunos/<int:aluno_id>", methods=["DELETE"])
def deletar_aluno(aluno_id):
if aluno_id not in banco["alunos"]:
return jsonify({"erro": "Aluno não encontrado."}), 404
del banco["alunos"][aluno_id]
return "", 204
@app.route("/alunos/estatisticas", methods=["GET"])
def estatisticas():
alunos = list(banco["alunos"].values())
if not alunos:
return jsonify({"erro": "Nenhum aluno cadastrado."}), 404
notas = [a["nota"] for a in alunos]
aprovados = [a for a in alunos if a["nota"] >= 6.0]
return jsonify({
"total": len(alunos),
"media": round(sum(notas) / len(notas), 2),
"maior_nota": max(notas),
"menor_nota": min(notas),
"aprovados": len(aprovados),
"reprovados": len(alunos) - len(aprovados),
})
@app.errorhandler(404)
def nao_encontrado(e):
return jsonify({"erro": "Rota não encontrada."}), 404
@app.errorhandler(405)
def metodo_nao_permitido(e):
return jsonify({"erro": "Método não permitido."}), 405
if __name__ == "__main__":
app.run(debug=True, port=5000)
Testando com curl:
# Listar alunos
curl http://localhost:5000/alunos
# Criar aluno
curl -X POST http://localhost:5000/alunos \
-H "Content-Type: application/json" \
-d '{"nome": "Diego Lima", "email": "diego@email.com", "nota": 8.0}'
# Buscar por ID
curl http://localhost:5000/alunos/1
# Atualizar nota
curl -X PATCH http://localhost:5000/alunos/1 \
-H "Content-Type: application/json" \
-d '{"nota": 10.0}'
# Estatísticas
curl http://localhost:5000/alunos/estatisticas
# Deletar
curl -X DELETE http://localhost:5000/alunos/3
Cada checagem do exemplo corresponde a uma entrada que, sem ela, passa ou derruba a rota. type=bool em request.args.get não interpreta o texto: bool("false") é True, e ?aprovados=false filtraria os aprovados do mesmo jeito que ?aprovados=true. Um corpo JSON que é lista, ou um nome numérico, derrubam com 500 uma validação que só usa len() e .get(); float(True) é 1.0, porque bool é subclasse de int, e sem a checagem de tipo true viraria nota; e procurar só o "@" aceita "@" como e-mail. O datetime.utcnow() está obsoleto desde a 3.12 e gera data sem fuso; datetime.now(UTC) sai com +00:00. E o JSON de saída do Flask vem com as chaves em ordem alfabética e, por padrão, com acentos escapados ("Jos\u00e9"); o app.json.ensure_ascii = False devolve o texto legível.
O Flask entrega pouco de propósito: uma rota vira função com @app.route, o request dá acesso ao que chegou, e a função devolve o que vai sair — um texto, um jsonify com código de status, um redirecionamento. O que ele não entrega, fica por conta de quem escreve a API, e é aí que moram os erros deste artigo. Ninguém valida o corpo da requisição: JSON que é lista, número onde se esperava texto, true onde se esperava nota. Os conversores de query string falham calados, e type=bool considera verdadeiro qualquer texto não vazio. E o get_json(), desde o Flask 2.3, recusa com 415 tudo o que não vier como JSON, o que faz de silent=True a escolha certa sempre que a rota aceita mais de um formato.
Os mecanismos de organização seguem a mesma lógica. Blueprints dividem as rotas por assunto, e o url_prefix define se o endereço canônico termina em barra. Os handlers de erro escolhem pelo tipo da exceção, então um handler de Exception sem outro de HTTPException transforma 405 em 500 e ainda devolve ao cliente a mensagem interna. A configuração por classe separa os ambientes, desde que a aplicação se recuse a subir sem SECRET_KEY em vez de descobrir a falta no primeiro login. E o debug=True fica no computador de quem desenvolve, porque a página de erro dele é um console Python aberto para o navegador.
Fontes e leituras recomendadas
- Flask — documentação oficial — https://flask.palletsprojects.com/
- Quickstart do Flask — https://flask.palletsprojects.com/en/3.0.x/quickstart/
- Blueprints e aplicações modulares — https://flask.palletsprojects.com/en/3.0.x/blueprints/
- Flask-SQLAlchemy — https://flask-sqlalchemy.palletsprojects.com/
- Flask-JWT-Extended — autenticação JWT — https://flask-jwt-extended.readthedocs.io/
- GRINBERG, Miguel. Flask Web Development. 2. ed. O'Reilly Media, 2018. — o livro de referência do Flask, escrito pelo autor de extensões centrais do framework.
- PERCIVAL, Harry; GREGORY, Bob. Architecture Patterns with Python. O'Reilly Media, 2020. — uso de Flask em arquiteturas orientadas a domínio.
Exercícios
Exercício 1
Uma API de matrículas em Flask funcionou bem por meses, recebendo JSON de um aplicativo móvel. A secretaria da escola ganha um formulário HTML simples que envia para a mesma rota, e todo envio falha com 415 Unsupported Media Type — antes de qualquer linha da função ser executada, segundo o log. O desenvolvedor jura que a rota trata formulário, porque há um request.form.get(...) logo depois do request.get_json(). Explique o que acontece e como a rota deveria ler o corpo.
Ver resposta
✓ Resposta: A função é executada, mas para na primeira linha. Desde o Flask 2.3, request.get_json() sem argumentos exige o cabeçalho Content-Type: application/json; um formulário HTML chega como application/x-www-form-urlencoded (ou multipart/form-data, se tiver arquivo), e o get_json() levanta ali mesmo um erro 415. As linhas de request.form e request.files nunca são alcançadas — medido com o exemplo do artigo, tanto o formulário quanto o envio de arquivo respondiam 415. Até o Flask 2.0 o mesmo código devolvia None sem reclamar, e nas versões 2.1 e 2.2 levantava 400; o 415 atual é o mais explícito dos três. A correção é decidir pelo tipo do corpo: request.get_json(silent=True) devolve None em vez de levantar, e aí a rota cai no request.form; ou, de forma explícita, if request.is_json: ... else: .... Nos dois casos, depois de ler, é preciso validar: silent=True também devolve None para JSON malformado, e um corpo JSON pode ser uma lista em vez de um objeto — isinstance(dados, dict) antes de qualquer .get() evita um 500 com AttributeError.
Exercício 2
Um painel administrativo chama GET /alunos?aprovados=false para listar os alunos que ainda não passaram, e a tela mostra justamente os aprovados. Na mesma semana, alguém digita ?nota_min=oito e recebe a lista completa, sem erro. Os dois parâmetros são lidos com request.args.get(..., type=bool) e type=float. Explique cada comportamento e como ler parâmetros de query string com segurança.
Ver resposta
✓ Resposta: O argumento type não é um parser inteligente: o Flask chama a função passada com o texto recebido. bool("false") é True, como bool("0") e bool("não") — qualquer texto não vazio é verdadeiro —, então ?aprovados=false aplicava o filtro de aprovados; medido no exemplo, false, 0 e true devolviam os mesmos 2 alunos, e só ?aprovados= (texto vazio) devolvia os 3. Já no type=float, quando a conversão levanta ValueError, o Flask engole a exceção e devolve o default, que é None: para a rota, nota_min=oito é igual a não ter filtro. Para booleanos, a leitura certa compara o texto com uma lista de valores aceitos — request.args.get("aprovados", "").lower() in ("1", "true", "sim"). Para números, quando um valor inválido deve ser recusado e não ignorado, leia o texto e converta você mesmo, respondendo 400 no ValueError; o cliente que errou o parâmetro fica sabendo, em vez de receber uma resposta plausível e errada.
Exercício 3
Uma API tem handlers para 403 e 404 e, "para garantir", um @app.errorhandler(Exception) que devolve {"erro": str(erro), "codigo": 500}. A equipe do aplicativo reclama de dois problemas: um POST enviado por engano a uma rota só de leitura volta como erro 500, e não como erro do cliente, e num dia de falha a resposta trazia o caminho completo de um arquivo de configuração do servidor. Explique os dois e reescreva a estratégia de handlers.
Ver resposta
✓ Resposta: O Flask escolhe o handler pela classe da exceção, subindo pela hierarquia até achar um registrado. O 405 é levantado como MethodNotAllowed, uma HTTPException; não há handler para 405 nem para HTTPException, então o primeiro que casa é o de Exception — que responde com código 500 fixo. Medido: o POST numa rota GET voltou 500 com o texto 405 Method Not Allowed: ... no corpo. O mesmo acontece com 400, 415 e qualquer outro código sem handler próprio, e o handler de 500 nunca é chamado, porque o de Exception chega antes. O segundo problema é o str(erro): a mensagem de uma exceção é escrita para quem depura, não para quem usa. Um open() que falha devolveu ao cliente [Errno 2] No such file or directory: '/etc/app/senha-do-banco.txt'. A estratégia correta tem duas camadas: um @app.errorhandler(HTTPException) que responde com erro.code e erro.description, preservando o código de cada erro HTTP, e um @app.errorhandler(Exception) que chama app.logger.exception(...) — o traceback completo vai para o log — e devolve ao cliente só uma mensagem neutra com 500. Handlers específicos, como o 404 personalizado, continuam valendo porque são mais próximos da classe da exceção.
Exercício 4
Uma API em Flask roda atrás de um Nginx e registra o IP de quem faz login, para bloquear tentativas repetidas. Todos os registros mostram o mesmo IP, 10.0.0.5, e o bloqueio acaba barrando todo mundo ao mesmo tempo. Um colega sugere ler direto o cabeçalho X-Forwarded-For; outro sugere o ProxyFix com x_for=2, "para garantir". Explique o sintoma e avalie as duas sugestões.
Ver resposta
✓ Resposta: request.remote_addr é o endereço de quem abriu a conexão TCP com o Flask, e atrás de um proxy reverso esse é sempre o proxy: 10.0.0.5 é o Nginx. O IP do cliente chega no X-Forwarded-For, que o proxy acrescenta — e aí está o problema da primeira sugestão: o cliente também pode mandar esse cabeçalho, e o proxy acrescenta o IP real ao fim do que recebeu. Um atacante que envia X-Forwarded-For: 1.2.3.4 chega à aplicação com 1.2.3.4, 200.9.9.9; quem lê o primeiro valor usa o IP que o atacante escolheu, e o bloqueio por IP vira brincadeira. O ProxyFix resolve lendo da direita para a esquerda, confiando só em tantos saltos quantos forem declarados. Medido com essa mesma lista: com x_for=1, o correto para um Nginx só, remote_addr virou 200.9.9.9, o IP real; com x_for=2, virou 1.2.3.4, o forjado. Declarar proxies a mais não é "garantir", é voltar a confiar no cliente. O número tem de ser exatamente o de proxies da sua infraestrutura, e a aplicação não pode ser alcançável sem passar por eles.
Exercício 5
Uma API muda o endereço de cadastro de /cadastro para /alunos/ e, para não quebrar os clientes antigos, a rota antiga passa a fazer redirect("/alunos/", 301). Os clientes antigos param de dar erro, mas nenhum aluno novo aparece no banco, e o log mostra só requisições GET /alunos/ vindas deles. Explique o que aconteceu com os POSTs e qual código de redirecionamento deveria ter sido usado.
Ver resposta
✓ Resposta: O 301 foi definido numa época em que os clientes, na prática, trocavam o método para GET ao seguir o redirecionamento, e as bibliotecas mantêm esse comportamento: medido com o requests, um POST com corpo JSON para uma rota que responde 301 chegou ao destino como GET, com corpo vazio. O cliente recebe 200 da listagem, acha que deu certo, e o cadastro nunca acontece — daí nenhum erro e nenhum aluno novo. O código certo para mudança permanente de endereço que preserva método e corpo é o 308 (e o 307 para a temporária); com ele, o mesmo teste chegou como POST com o corpo {"nome": "Ana"} intacto. O próprio Flask usa 308 quando redireciona /alunos para /alunos/ por causa da barra final. Mesmo com o código certo, vale anunciar a mudança e acompanhar o volume da rota antiga até ele zerar: um cliente que não segue redirecionamentos, como o httpx no padrão, recebe o 308 e para ali.