Erros do app: quando o cliente quebra na mão do usuário¶
Seu app mobile estourou um TypeError na tela de pagamento. O usuário fecha
o app e vai embora. Você fica sabendo por um print no WhatsApp, três dias
depois, sem versão, sem aparelho, sem stack trace.
Esta receita monta o lugar onde esse relato cai sozinho.
O que você vai construir¶
Um endpoint público que aceita o relato, uma tabela onde ele fica consultável, e uma listagem filtrável para investigar.
from tempest_fastapi_sdk.app_errors import (
AppErrorService,
make_app_error_model,
make_app_error_router,
)
A tabela¶
O SDK traz a linha abstrata; o seu projeto cria a tabela concreta, porque o nome da tabela de usuários é do seu projeto:
# src/db/models/app_error.py
from tempest_fastapi_sdk.app_errors import make_app_error_model
AppErrorModel = make_app_error_model(
user_table="users",
tablename="app_errors",
)
Três decisões vêm prontas aí dentro, e cada uma existe porque a alternativa falha em silêncio:
user_id é nullable, e a FK é SET NULL
Erro no fluxo de login acontece antes de existir usuário autenticado — e é justo o mais difícil de depurar pelo app. Exigir usuário derrubaria o caso mais valioso.
E a FK usa ON DELETE SET NULL, não o CASCADE do resto do schema: o
relato descreve defeito da aplicação, não do usuário. Apagar a conta
não pode apagar a evidência do bug.
created_at tem índice próprio
A leitura padrão é "mais recente primeiro", paginada, e esta é a tabela que cresce sem limite natural. Sem o índice, cada página custa um sort da tabela inteira.
Recebendo o relato¶
# src/api/app.py
from collections.abc import AsyncIterator
from uuid import UUID
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import BaseRepository
from tempest_fastapi_sdk.app_errors import AppErrorService, make_app_error_router
from src.db.models.app_error import AppErrorModel
from src.db.session import get_session
def service_factory(session: AsyncSession) -> AppErrorService:
"""Build the service for one request.
Args:
session (AsyncSession): The request-scoped session.
Returns:
AppErrorService: The service.
"""
return AppErrorService(BaseRepository(session, model=AppErrorModel))
async def session_factory() -> AsyncIterator[AsyncSession]:
"""Yield a request-scoped session.
Yields:
AsyncSession: The session.
"""
async for session in get_session():
yield session
app: FastAPI = FastAPI()
app.include_router(
make_app_error_router(
service_factory=service_factory,
session_factory=session_factory,
)
)
O app manda:
{
"code": "PLAN_ACTIVATION_FAILED",
"message": "TypeError: null is not an object (evaluating 'user.id')",
"platform": "ios",
"os_version": "18.2",
"app_version": "1.4.2+310",
"device_model": "iPhone 15 Pro"
}
Só code e message são obrigatórios. Todo o resto é o que o app
conseguiu coletar no momento da falha — exigir qualquer um deles
transformaria uma coleta incompleta num relato perdido.
As duas regras que fazem isso funcionar¶
Um relato truncado vale mais que um relato perdido¶
Payload acima do limite da coluna é cortado, nunca recusado:
from tempest_fastapi_sdk.app_errors import (
APP_ERROR_TRUNCATION_SUFFIX,
AppErrorService,
)
def exemplo() -> str | None:
"""Show what a value over the limit becomes.
Returns:
str | None: The cut value, marked.
"""
return AppErrorService.truncate("x" * 5000, 4000)
O valor volta com …[truncado] no fim, para quem lê a listagem saber que
falta conteúdo.
Por que não 422
Quem está mandando é um app que acabou de quebrar. Ele não tem caminho de tratamento para uma recusa: o 422 vira exceção dentro do handler de exceção, e o relato simplesmente evapora. Recusar por tamanho é perder exatamente o relato mais interessante — o que veio com stack trace grande.
user_id vem do token, nunca do corpo¶
AppErrorReportSchema — o que o cliente pode mandar — não tem o campo.
Quem o preenche é o serviço, a partir de quem a requisição autenticou:
from uuid import UUID
from tempest_fastapi_sdk.app_errors import AppErrorReportSchema, AppErrorService
async def registrar(
service: AppErrorService, data: AppErrorReportSchema, caller: UUID | None
) -> None:
"""Store a report attributed to the authenticated caller.
Args:
service (AppErrorService): The service.
data (AppErrorReportSchema): The body the client sent.
caller (UUID | None): Who the token says is calling.
"""
await service.report_error(data, user_id=caller)
É por isso que são dois schemas
Se o user_id fosse campo do corpo, qualquer cliente poderia atribuir o
próprio erro à conta de outra pessoa. A separação entre
AppErrorReportSchema e AppErrorCreateSchema não é organização: é o
que torna isso impossível, em vez de apenas desencorajado.
Investigando¶
A listagem é opt-in. Ela só existe se você passar a dependência de admin:
from fastapi import APIRouter
from tempest_fastapi_sdk.app_errors import make_app_error_router
from src.api.dependencies.auth import require_admin
from src.api.factories import service_factory, session_factory
router: APIRouter = make_app_error_router(
service_factory=service_factory,
session_factory=session_factory,
admin_dependency=require_admin,
)
Sem admin_dependency, a rota GET não é montada — e isso é
deliberado: a listagem devolve stack trace e identificador de aparelho, que
não podem ficar abertos por esquecimento de proteger.
Ou nem monte o endpoint
Se o seu serviço já usa o AdminSite, registre a tabela lá: listagem,
filtro e paginação saem de graça, e você não mantém rota nenhuma. O
GET do router é para quem tem painel próprio.
O corte que resolve a maioria das investigações é code + app_version —
isola um defeito específico numa build específica:
O intervalo de datas é semiaberto, de propósito
O filtro monta created_at >= start e created_at < end + 1 dia, em
vez de func.date(created_at). Aplicar função na coluna descarta o
índice — e esta é a tabela que mais cresce. Para você, start_date e
end_date continuam inclusivos nos dois lados.
start_date e end_date são dias UTC
created_at é gravado por utcnow, e o filtro compara a data que você
manda com meia-noite — então o corte é um limite UTC, não o do fuso onde
o processo roda.
Medido: um relato gravado em 2026-03-10T02:30Z — que no relógio de
Brasília ainda é 2026-03-09 23:30 — aparece filtrando 2026-03-10
(total=1) e não aparece filtrando 2026-03-09 (total=0). A
resposta é a mesma com o servidor em America/Sao_Paulo ou em
Asia/Tokyo, que é o ponto: a janela não muda de sentido conforme a
máquina.
Consequência prática: monte o intervalo a partir de
datetime.now(UTC).date(), não de datetime.now().date(). Em BRT, as
duas divergem por três horas todo dia, e é nelas que "hoje" devolve
vazio.
O teto de requisições¶
O POST é público. Ele precisa de teto, e o teto não mora neste módulo:
from fastapi import FastAPI
from tempest_fastapi_sdk.api.middlewares.rate_limit import (
FailOpenRateLimitStore,
MemoryRateLimitStore,
RateLimitMiddleware,
)
app: FastAPI = FastAPI()
app.add_middleware(
RateLimitMiddleware,
store=FailOpenRateLimitStore(MemoryRateLimitStore()),
max_requests=120,
window_seconds=3600.0,
)
O FailOpenRateLimitStore não é enfeite
Medido: com uma store cujo hit levanta, a exceção propaga pelo
RateLimitMiddleware e o chamador recebe erro. Para a maioria dos
endpoints isso se defende — limitador que não consegue contar não
consegue proteger.
Aqui é o contrário. O momento em que a store de contadores está mal é exatamente o momento em que os erros disparam, e recusar os relatos então destrói a evidência do incidente que está sendo reportado. Perder o relato é pior que servir acima do teto.
O wrapper torna essa troca explícita, e ela fica visível: cada falha vai
para o log em WARNING, então "o teto não está sendo aplicado" não
acontece em silêncio.
Recapitulando¶
make_app_error_modelcria a tabela;user_idnullable eSET NULLpreservam o relato de erro de login e a evidência do bug.- Só
codeemessagesão obrigatórios; o resto é o que o app conseguiu coletar. - Valor grande é truncado com marcador, nunca recusado.
user_idvem do token — o schema do cliente não tem o campo.- A listagem só existe com
admin_dependency, ou peloAdminSite. - O teto do
POSTéRateLimitMiddleware, e precisa falhar aberto.