Ir para o conteúdo

Guia de migração

Passo a passo das mudanças que quebram compatibilidade, agrupadas por release minor. Siga a versão que casa com aquela de onde você está atualizando. As seções estão listadas da mais nova para a mais antiga, então num salto de várias versões leia e aplique-as de baixo para cima.

0.316.0 — set-password pede o fator em conta com MFA

tempest user set-password numa conta com MFA ativo (e AUTH_MFA_ENABLED=True) passa a pedir o código — TOTP ou recovery code — e troca a senha por UserAuthService.change_password_with_mfa. Sem código válido em três tentativas, o comando sai com código 1 e a senha não muda. Até a 0.315.x o comando trocava a senha sem pedir o fator.

O que fazer

  1. Script ou CI que roda set-password sem terminal numa conta com MFA deixa de funcionar: não há quem digite o código.
  2. Para recuperar quem perdeu o celular e os recovery codes, rode tempest user mfa-reset --email <e> (--yes fora de terminal) e depois set-password. Veja a receita de MFA.
  3. Para aceitar recovery code, o comando precisa da tabela de recovery codes (--recovery-code-model, default src.db.models:UserRecoveryCodeModel).

0.316.0 — o segundo item do UserAuthService.signup pode ser ActivationWithheld

A tupla devolvida por signup passa a ter três formas no segundo item: None (a conta nasceu ativa), ActivationToken (o link foi emitido) e o novo ActivationWithheld(user_id) (o link foi retido). O terceiro só sai com AUTH_AUTO_ACTIVATE=false e UserAuthService(refuses_email_link=...) recusando a conta nova; sem o predicado, o comportamento em runtime é o de antes. O router do SDK já trata o tipo novo.

O que fazer

  1. Se você chama signup direto e lê .url ou .token do segundo item, teste isinstance(activation, ActivationToken) antes: com ActivationWithheld a conta existe, inativa e sem link. O mypy acusa union-attr onde isso falta.
  2. Só None autoriza emitir o par de JWT logo após o cadastro.
  3. Se você sobrescreve signup e repassa ao super(), ajuste a anotação de retorno.

0.314.0 — Python 3.12 passa a ser o mínimo

O SDK deixa de suportar Python 3.11. O requires-python sobe para >=3.12, e as versões suportadas passam a ser 3.12, 3.13 e 3.14.

Quem está em 3.11 fica na 0.313.x: o instalador descarta as releases cujo requires-python exclui o interpretador. Pedir a versão nova num ambiente 3.11 falha antes de instalar qualquer coisa. Medido com o uv:

error: The requested interpreter resolved to Python 3.11.12, which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`)

O que fazer

  1. Suba o interpretador do serviço para 3.12 ou mais novo (uv python pin 3.13, a imagem base do Dockerfile, o runtime da CI).
  2. Ajuste o requires-python do seu pyproject.toml para >=3.12. O tempest new já gera assim, com target-version = "py312" no ruff e python_version = "3.12" no mypy.
  3. Rode uv lock e uv sync. Com o piso novo o lock deixa de carregar o split do 3.11: no lock do próprio SDK saíram tomli, async-timeout e audioread, e numpy, scipy e librosa passaram a ter uma versão só.

Para continuar em 3.11 por enquanto, fixe a faixa: uv add "tempest-fastapi-sdk>=0.313.0,<0.314.0".

0.314.0 — login social de conta com TOTP pede o segundo fator

GET /auth/oauth/{provider}/callback e POST /auth/oauth/{provider}/token passam a espelhar o /auth/login. Quando a conta resolvida tem TOTP inscrito (e AUTH_MFA_ENABLED=True), a resposta não traz o par nem cookie de sessão: traz mfa_required=true + mfa_token, e o par só sai do /auth/mfa/verify, com amr == ["ext", "otp"]. Até a 0.313.x o provedor bastava, e a conta pulava o fator que tinha inscrito.

O que fazer

  1. No cliente do login social, trate o desafio como no login por senha: se mfa_required for true, peça o código e faça POST /auth/mfa/verify com o mfa_token. Conta sem MFA não muda — o par continua na própria resposta, com amr == ["ext"].
  2. Se você sobrescreve UserAuthService.login_with_oauth, mova a lógica para o novo authenticate_oauth (resolve a conta sem emitir nada). As rotas não chamam mais login_with_oauth, e o router loga um aviso ao ser montado quando acha o override. Chamar login_with_oauth direto continua emitindo o par sem perguntar pela MFA.
  3. Se você sobrescreve issue_mfa_token, aceite o novo amr: Sequence[str] | None = PASSWORD_AMR e repasse ao super(). Sem ele o token sai sem o primeiro passo, e o /auth/mfa/verify volta ao ["pwd", "otp"] de antes — o router também avisa no log.

O par que POST /auth/activate/{token} e POST /auth/password-reset/confirm emitem para conta sem MFA passa a sair com amr == ["email"] (AMR_EMAIL_LINK), não mais ["pwd"]: nenhum dos dois confere senha. Com TOTP inscrito, o /auth/mfa/verify desse desafio emite ["email", "otp"]. O refresh repete o amr da origem, então a sessão continua ["email"] depois de rotacionar.

O que fazer

  • Código que lê "pwd" in amr como "esta sessão começou num login" passa a ver "email" nesses dois caminhos. Se a sua regra é "qualquer sessão de um fator", compare com AMR_EMAIL_LINK também; se é "a senha foi conferida", a mudança é a correção que você queria.
  • make_mfa_required_dependency não muda: email não está em MFA_AMR_METHODS, e o gate recusa o par como recusava o ["pwd"].
  • Override de issue_token_pair sem amr= continua sendo chamado sem a keyword, e o par sai com o default do override (["pwd"], se delega ao super()), como antes.

0.313.0 — classe terminada em Enum não dobra mais o sufixo do tipo

enum_type_name tira um Enum do fim do nome da classe antes de acrescentar _enum. Só muda quem tem enum com esse sufixo:

Classe Antes Agora
CandidateSlugEnum candidate_slug_enum_enum candidate_slug_enum
OrderStatus order_status_enum order_status_enum (igual)

O banco continua com o nome antigo até uma migration renomear o tipo, e no PostgreSQL o model passa a escrever o nome novo em todo INSERT, UPDATE e WHERE com a coluna. Medido no PostgreSQL 16 com asyncpg, antes da migration: type "candidate_slug_enum" does not exist nos três.

O que fazer

  1. Atualize o SDK e rode tempest db revision --autogenerate -m "renomeia tipos de enum". O sync_enum_types detecta o tipo renomeado (no PostgreSQL pelo information_schema, no SQLite pelo nome do CHECK) e escreve um op.rename_enum_type por tipo, com o reverso no downgrade:

    from alembic import op
    
    from tempest_fastapi_sdk.db.enum_migrations import EnumColumnRef
    
    
    def upgrade() -> None:
        """Renomeia o tipo do enum para o nome sem o sufixo dobrado."""
        op.rename_enum_type(
            "candidate_slug_enum_enum",
            "candidate_slug_enum",
            columns=[EnumColumnRef(table="candidates", column="slug")],
        )
    
  2. Aplique a migration antes de o código novo atender tráfego (o alembic upgrade head do deploy já faz isso). tempest db check falha enquanto a migration não existir, então a CI pega o esquecimento.

Para manter o nome antigo numa coluna, passe-o explícito: mapped_column(TempestEnum(CandidateSlugEnum, name="candidate_slug_enum_enum")).

0.313.0 — o 429 do auth tem código próprio

Os três limites do make_auth_router deixam de responder o code genérico TOO_MANY_REQUESTS, o mesmo de qualquer outro 429 do app:

Refuso Antes Agora
login acima do limite, por e-mail ou por cliente TOO_MANY_REQUESTS LOGIN_THROTTLED (LoginThrottledException)
signup acima do limite TOO_MANY_REQUESTS SIGNUP_THROTTLED (SignupThrottledException)
código MFA errado acima do limite TOO_MANY_REQUESTS MFA_THROTTLED (MfaThrottledException)

O status continua 429, com Retry-After e details.retry_after_seconds. As três classes herdam de TooManyRequestsException, então except TooManyRequestsException continua pegando. Muda para quem compara o code: troque code == "TOO_MANY_REQUESTS" pelo código do fluxo. Com default_message_catalog(), o detail passa a ser uma mensagem específica (Muitas tentativas de login. Aguarde um pouco e tente de novo) em vez de Requisições em excesso.

0.313.0 — refresh token guarda o amr, e /mfa/confirm responde 200

O access token passou a carregar o claim amr (como a sessão foi autenticada), e o BaseUserRefreshTokenModel ganhou a coluna amr (texto, nullable) para o refresh repetir esse valor. Como a coluna é mapeada no modelo base, migre o banco antes de atualizar o pacote se você usa refresh_token_model: sem ela, toda emissão de par falha — medido com SQLite, POST /auth/signup com ativação automática respondeu 500 (OperationalError). Quem não passa refresh_token_model não muda nada no banco.

O que fazer

  1. Gere a migration (uv run tempest db revision -m "refresh token amr", ou alembic revision --autogenerate) ou escreva-a à mão, com o nome da sua tabela:

    import sqlalchemy as sa
    from alembic import op
    
    revision: str = "0313_refresh_token_amr"
    down_revision: str | None = "<a revisão anterior>"
    
    
    def upgrade() -> None:
        """Adiciona o amr da família de refresh token."""
        op.add_column(
            "user_refresh_tokens",
            sa.Column("amr", sa.String(length=64), nullable=True),
        )
    
    
    def downgrade() -> None:
        """Remove o amr da família de refresh token."""
        op.drop_column("user_refresh_tokens", "amr")
    
  2. Rode uv run tempest db upgrade (ou alembic upgrade head) e só então faça o deploy da versão nova.

Linhas antigas ficam com NULL: o refresh delas emite access token sem amr, que make_mfa_required_dependency trata como "sem segundo fator".

Duas mudanças de comportamento vêm junto:

  • POST /auth/mfa/confirm responde 200 com um LoginResponseSchema, não mais 204. Confirmar revoga todos os refresh tokens do usuário, e o par novo no corpo (ou nos cookies, no modo cookie) é o que mantém o dispositivo logado. Cliente que confere status === 204 passa a checar 200 e guarda o par novo.
  • mfa_confirm revoga as sessões por padrão. Quem chama o serviço direto e precisa manter as sessões passa revoke_sessions=False.

POST /auth/password-reset/confirm e POST /auth/activate/{token} passam a espelhar o /auth/login: quando a conta tem TOTP inscrito, não devolvem o par — devolvem mfa_required=true + mfa_token, e o par só sai do /auth/mfa/verify, com amr de MFA. A senha é trocada de todo jeito.

Cliente que chamava o confirm (ou o activate de uma conta reativada) e guardava access_token direto precisa tratar o desafio: se mfa_required for true, peça o código e faça o /auth/mfa/verify. Conta sem MFA não muda — continua recebendo o par na própria resposta. Ativar uma conta recém-cadastrada também não muda: ela nunca tem fator.

ActivationResponseSchema ganhou mfa_required / mfa_token, e access_token / refresh_token passaram a ser opcionais (str | None), como no LoginResponseSchema. Quem lê o par de uma ativação de conta nova não muda nada.

O reset de senha agora revoga as sessões já abertas na mesma transação que grava o hash, como o /auth/mfa/confirm e o tempest user set-password já faziam: um refresh token DB-backed emitido antes do reset deixa de trocar por par novo (responde 401). Access tokens já emitidos ainda valem até o TTL. Quem chamava UserAuthService.confirm_password_reset e precisa manter as sessões passa revoke_sessions=False — assinatura antiga de override continua sendo chamada pelo router, e delega para o default que revoga. Refresh token stateless (sem refresh_token_model) não tem linha a revogar e segue valendo até expirar.

0.312.0 — Mercado Pago: Pix vencido e busca de chargebacks

Duas mudanças de comportamento, as duas medidas no sandbox em 2026-10-10.

Pix vencido agora é EXPIRED. Uma order que o Mercado Pago devolve canceled / expired passou a ser PaymentStatus.EXPIRED, e o webhook a entrega como PixEventType.CHARGE_EXPIRED. Até a 0.311.0 ela saía CANCELLED / CHARGE_CANCELLED. Se o seu serviço tratava o vencimento no ramo de cancelamento, trate os dois:

from tempest_fastapi_sdk.integrations.payment import PaymentStatus, PixCharge


def is_closed_unpaid(charge: PixCharge) -> bool:
    """Tell whether a Pix charge ended without being paid."""
    return charge.status in {PaymentStatus.CANCELLED, PaymentStatus.EXPIRED}

search_chargebacks exige payment_id. A busca respondia 400 sem esse filtro, inclusive na única forma que o método aceitava (limit / offset), então ela nunca funcionava. Agora:

Antes Agora
await client.search_chargebacks(limit=10) → sempre 400 await client.search_chargebacks(payment_id="123") → ChargebackSearchResponse
dict[str, Any] found.paging.total, found.results (itens ainda dict[str, Any])

0.311.0 — Mercado Pago: a API de Payments saiu do SDK

O painel do Mercado Pago marca a API de Payments (/v1/payments) como "Esta API será descontinuada em breve", e o SDK passou a cobrar pela API de Orders. Quem usava o caminho de Payments troca assim:

Antes Agora
client.create_payment(body=PaymentRequest(...)) com Pix MercadoPagoPixProvider(http).create_pix_charge(PixChargeRequest(...))
client.create_payment(...) com token de cartão MercadoPagoCardProvider(http).create_card_charge(CardChargeRequest(...))
create_pix_payment / get_pix_payment / parse_pix_payment create_pix_charge / get_pix_charge — o QR vem em PixCharge.br_code e qr_code_base64
client.get_payment(id) get_pix_charge(order_id) / get_card_charge(order_id), ou client.get_order(order_id)
client.update_payment(id, body={"capture": True}) capture_card_charge(order_id)
client.update_payment(id, body={"status": "cancelled"}) cancel_pix_charge(order_id) / cancel_card_charge(order_id)
client.create_refund(id, ...) refund_card_charge(order_id, amount_cents=...)
PixPayment, Payment PixCharge, CardCharge, ou o Order gerado
as 7 operações de QR presencial V1/V2 e QR dinâmico antigo sem substituto neste SDK: a própria spec as marca deprecated, e a API de Orders da spec só declara type: online

Removidos sem substituto direto (o equivalente, quando existe, é o Order gerado ou os contratos PixCharge / CardCharge):

  • Métodos de MercadoPagoClient: create_payment, search_payments, get_payment, update_payment, cancel_payment, create_refund, list_refunds, get_refund, e os 7 de QR presencial — create_instore_order_v1, delete_instore_order_v1, create_instore_order_v2, get_instore_order_v2, delete_instore_order_v2, create_dynamic_qr_order, create_qr_tramma_dynamic.
  • Funções e constantes: create_pix_payment, get_pix_payment, parse_pix_payment, PAYMENTS_PATH.
  • Modelos e enums: PixPayment, PixPointOfInteraction, PixTransactionData, Payment, PaymentRequest, PaymentPayer, PaymentPayer2, PaymentCard, PaymentCardCardholder, PaymentItem, PaymentAdditionalInfo, PaymentAdditionalInfoPayer, PaymentAdditionalInfoShipments, PaymentTransactionDetails, PaymentOperationType, PaymentPaymentTypeId, PaymentProcessingMode, PaymentSearchResult, SearchPaymentsRange, PaymentUpdateRequest, PaymentUpdateRequestStatus, CancelPaymentBody, RefundRequest, CreateRefundResponse, CreateRefundResponseSource, GetRefundResponse, GetRefundResponseSource, ListRefundsResponse, ListRefundsResponseSource e PaymentStatus de mercado_pago. O PaymentStatus canônico, de integrations.payment, continua — é o que os contratos usam.

Tipos que mudaram:

  • Order.status, Order.status_detail, OrderTransactionPayment.status e OrderTransactionPayment.status_detail passam de enum para Enum | str: o sandbox devolveu valores fora da lista. Um match exaustivo sobre o enum precisa de um ramo para a string.
  • get_authenticated_user() devolve AuthenticatedUser em vez de dict[str, Any]: troque user["id"] por user.id (o que não foi declarado está em user.model_extra).
  • Dois enums ganharam nome próprio: ListPaymentMethodsResponseItemProcessingModesItem (processing mode de list_payment_methods) e UpdateAdvancedPaymentBodyStatus (status do corpo de update_advanced_payment).

Três diferenças que pedem ajuste além do nome:

  1. O id muda de forma. Uma order tem id de texto (ORD…), não número. Se você guardava o id do pagamento como inteiro, a coluna vira texto.
  2. O webhook muda de recurso. A notificação deve passar a apontar para a order — é o que o documento do provedor descreve (ações order.created / order.updated); uma entrega real de Orders ainda não foi observada aqui. Troque a leitura manual de data.id por make_mercado_pago_webhook_delivery_dependency, e marque o evento de Order na configuração de webhooks da aplicação.
  3. A credencial precisa ser de uma aplicação "API de Orders". Medido: a aplicação Checkout Pro de uma conta vendedora de teste responde 401 Unauthorized use of live credentials a cobrança direta. A receita contas e credenciais de teste mostra o caminho.

0.309.0 — outras mudanças de comportamento

Além da recusa de chave em filters (seção abaixo), a 0.309.0 traz estas mudanças que podem pedir ajuste:

  1. JobStore.reclaim_stale() devolve ReclaimedJobs, não int. if await store.reclaim_stale(): continua funcionando (__len__); quem compara com número ou soma o retorno passa a ler .total.
  2. make_auth_router limita o login por padrão: 5 senhas erradas para o mesmo e-mail em 900 s viram 429. Passe login_throttle=False para desligar, ou um AttemptThrottle próprio para outro orçamento.
  3. make_prometheus_router(dependencies=[require_x_token]) é recusado na construção com TypeError. Use dependencies=[make_token_dependency(segredo)], que lê o header X-Token.
  4. Readiness com timeout: cada check de make_health_router tem timeout=3.0 s por padrão e estourar conta como falha. Check mais lento pede timeout= maior (ou None).
  5. MinIOSettings está deprecado em favor de StorageSettings (STORAGE_*). Os nomes MINIO_* continuam sendo lidos; definir os dois nomes de um par com valores diferentes derruba o boot. Ao regenerar o docker-compose.yaml (que agora escreve STORAGE_*), remova os MINIO_* antigos do .env ou deixe os dois com o mesmo valor.
  6. test_session / test_database estão deprecados: troque por make_test_session / make_test_database (mesma assinatura).

0.309.0 — chave desconhecida em filters levanta erro

Chave de filters que não é coluna do model ({"usr_id": 1}) ou sufixo que não é operador ({"user_id__bogus": 1}) era ignorada em silêncio — e com só a chave errada no dict, delete_many e bulk_update alteravam a tabela inteira. Agora todo método que recebe filters, o Q, o BaseService, o BaseController e o TenantScopedRepository levantam UnknownFilterKeyException (422, code="UNKNOWN_FILTER_KEY", details={"filter": <chave>}) antes de qualquer statement.

Quem é afetado: quem passava, de propósito ou sem saber, uma chave que o model não resolve. O caso mais comum é um schema de filtro com campo que não é coluna (search, include_archived) repassado inteiro por get_conditions(): o request que preenche esse campo passa a responder 422.

O que fazer

  1. Campo de schema que não é coluna: tire-o do dict antes de repassar e trate-o no service.

    from typing import Any
    
    from tempest_fastapi_sdk import BasePaginationFilterSchema
    
    
    class TicketFilterSchema(BasePaginationFilterSchema):
        status: str | None = None
        search: str | None = None
    
    
    def split_conditions(f: TicketFilterSchema) -> tuple[dict[str, Any], str | None]:
        """Separate the column filters from the free-text term."""
        conditions = f.get_conditions()
        search = conditions.pop("search", None)
        return conditions, search
    
  2. Admin: list_filter, search_fields e os filters de cada Lens passam a ser conferidos na construção do AdminModel. Um nome errado vira ValueError no boot (AdminModel lens 'Open' filters on keys Ticket cannot resolve: stauts); corrija o nome.

  3. Código que capturava o resultado "tudo" de propósito com uma chave inexistente: passe {} (ou nenhuma chave), que continua sem condição. bulk_update segue recusando mapping vazio.

0.308.0 — o CLI tempest virou o extra [cli]

O tempest-cli saiu das dependências do pacote base, junto com o typer e o click, e foi para o extra novo [cli] (que também entra no [all]). O tempest-cli exige ruff>=0.8.0 em runtime: no base, todo serviço que adota o SDK carregava um formatador como dependência de produção, e um projeto que fixa ruff<0.8 no grupo dev nem resolvia o lock (uv lock recusava com tempest-cli>=0.4.0 depends on ruff>=0.8.0). A biblioteca — import tempest_fastapi_sdk e tudo o que o serviço importa em runtime — não muda.

Quem é afetado: só quem roda o comando tempest sem [cli] nem [all]. Sem o extra, o script continua instalado, mas imprime a instrução e sai com código 2:

$ tempest --help
error: missing tempest_cli, typer, click. The tempest CLI needs the optional [cli] extra. Install it with:
  uv add --dev "tempest-fastapi-sdk[cli]"   (in a project)
  uv tool install "tempest-fastapi-sdk[cli]"   (as a global command)

O que fazer

  1. CLI global (uv tool install tempest-fastapi-sdk): reinstale com o extra.

    uv tool install --force "tempest-fastapi-sdk[cli]"
    
  2. CLI no projeto (uv run tempest check, tempest db upgrade no terminal, na CI): adicione o extra ao grupo dev. Projetos gerados pelo tempest new a partir da 0.308.0 já vêm assim.

    uv add --dev "tempest-fastapi-sdk[cli]"
    
  3. tempest dentro da imagem de produção (por exemplo, um entrypoint que roda tempest db upgrade antes de subir o app): o grupo dev não entra numa imagem construída com uv sync --no-dev, então ali o extra vai nas dependências de runtime — "tempest-fastapi-sdk[cli,...]" — ou o passo troca para alembic upgrade head, que não precisa da CLI.

  4. Import direto de tempest_fastapi_sdk.cli.main, .config, .lint ou .pr_prompt, ou de tempest_fastapi_sdk.cli.app / TempestConfig: sem o extra, levanta ImportError com a mesma instrução. Instale [cli] onde esse código roda.

Quem já instala [all] não faz nada: o [all] traz o [cli].

0.306.0 — código TOTP de uso único pede a coluna totp_last_step

O MFAMixin ganhou a coluna totp_last_step (inteiro, nullable), onde o SDK grava o passo de 30 segundos do último código TOTP aceito para recusar o mesmo código uma segunda vez. Como ela é mapeada no mixin, migre o banco antes de atualizar o pacote: sem a coluna, toda consulta ao user model falha — signup e login respondem 500 (no such column: <tabela>.totp_last_step, medido com SQLite). Projeto que não usa MFAMixin não muda nada.

O que fazer

  1. Gere a migration (uv run tempest db revision -m "totp last step", ou alembic revision --autogenerate) ou escreva-a à mão:

    import sqlalchemy as sa
    from alembic import op
    
    revision: str = "0306_totp_last_step"
    down_revision: str | None = "<a revisão anterior>"
    
    
    def upgrade() -> None:
        """Adiciona a coluna do último passo TOTP aceito."""
        op.add_column("users", sa.Column("totp_last_step", sa.Integer(), nullable=True))
    
    
    def downgrade() -> None:
        """Remove a coluna do último passo TOTP aceito."""
        op.drop_column("users", "totp_last_step")
    
  2. Rode uv run tempest db upgrade (ou alembic upgrade head) e só então faça o deploy da versão nova.

Linhas antigas ficam com NULL, que significa "nenhum código gasto ainda": o próximo código válido é aceito e grava o passo.

Duas mudanças de comportamento vêm junto, sem passo obrigatório:

  • Um código TOTP vale uma aceitação. O código usado no confirm não serve mais para o primeiro login, nem o do login para o disable logo em seguida: o usuário espera o próximo código do app (até 30 segundos). Teste automatizado que reusa pyotp.TOTP(secret).now() depois do confirm passa a receber 401; use o código do passo seguinte, pyotp.TOTP(secret).at(int(time.time()) + 30).
  • POST /auth/mfa/verify responde 429 depois de cinco códigos errados por conta em 15 minutos. O contador padrão vive no processo; com mais de um worker, passe make_auth_router(mfa_throttle=AttemptThrottle(redis, ...)) — veja Limite de tentativas.

0.306.0 — make_logs_router recusa segredo vazio

make_logs_router(token_secret="") levanta ValueError na construção. Até a 0.305.0 o segredo vazio desligava a checagem do X-Token, e GET /logs e DELETE /logs respondiam a qualquer um. O serviço gerado pelo tempest new monta /logs só quando TOKEN_SECRET não está vazio.

O que fazer

  • Serviço em produção: preencha TOKEN_SECRET (por exemplo com uv run tempest secrets init) em todo ambiente. Sem isso, o app passa a não subir.
  • Serviço que deve subir sem segredo: monte o router só quando houver segredo, como o template faz:

    from fastapi import FastAPI
    
    from tempest_fastapi_sdk import make_logs_router
    
    from src.core.settings import settings
    
    app = FastAPI()
    
    if settings.TOKEN_SECRET.strip():
        app.include_router(
            make_logs_router(
                log_dir=settings.LOG_DIR,
                token_secret=settings.TOKEN_SECRET,
            ),
        )
    
  • Execução local que precisa de /logs aberto: passe make_logs_router(..., allow_unauthenticated=True).

  • TOKEN_SECRET com espaço em volta: o espaço passa a ser removido antes da comparação, então o cliente envia o valor sem ele.

0.306.0 — download só vai inline para tipo seguro

as_attachment=False virou pedido: DownloadUtils, FileStoreUtils e AsyncMinIOClient.download_response/serve_object só respondem Content-Disposition: inline quando o tipo da resposta está em INLINE_SAFE_MEDIA_TYPES. Qualquer outro — text/html, image/svg+xml, application/octet-stream — sai como attachment. Toda resposta de download também passa a levar X-Content-Type-Options, Content-Security-Policy e Cross-Origin-Resource-Policy.

accel_redirect_response muda o default para as_attachment=True, e com as_attachment=False só serve inline quando media_type= é passado.

build_content_disposition(name, as_attachment=False) sem media_type= devolve attachment.

O que fazer

  • Rota que mostra imagem, PDF, áudio ou vídeo no navegador: nada, se o tipo está na lista. No modo X-Accel-Redirect, passe media_type= (por exemplo guess_media_type(key)), senão o arquivo vira download.
  • Chamada a accel_redirect_response que contava com inline por padrão: passe as_attachment=False e media_type=.
  • Deploy com STORAGE_ACCEL_REDIRECT=true: acrescente ao location interno do nginx o proxy_hide_header X-Content-Type-Options; e os três add_header ... always; da receita de storage. O nginx não repassa os headers que o app põe na resposta vazia.
  • Página que embute o download em <iframe> ou de outro site: a CSP sandbox e o CORP same-site valem para ela. Passe o valor que precisa em headers= — ele substitui o default de mesmo nome.
  • build_content_disposition(..., as_attachment=False) chamado à mão: passe media_type= com o tipo da resposta.

0.305.0 — token de conta desativada não autentica mais

current_user_dependency() e as rotas autenticadas do make_auth_router passam a recusar conta com is_active=False com 403 ACCOUNT_INACTIVE (ou AuthExceptions.account_inactive), mesmo com o token ainda válido. Até a 0.304.0 a conta desativada seguia entrando com o token emitido antes, pelo access TTL inteiro.

O que fazer

  • Rota que precisa atender conta inativa (reativação, exportação de dados antes do encerramento): monte a dependência com current_user_dependency(require_active=False).
  • Cliente que trata a conta desativada por outro code: passe inactive_exception=SuaExcecao.
  • Override de get_user só para levantar 403 em conta inativa: pode ser removido.

confirm_password_reset com um token cujo usuário não existe mais levantava NotFoundException (404). Agora levanta InvalidTokenException (401 INVALID_TOKEN, ou a classe de AuthExceptions.invalid_token), como activate e confirm_email_change já faziam: para quem chama, é um link que não vale.

O que fazer

  • Cliente que tratava 404 em POST /auth/password-reset/confirm: trate o mesmo erro do link expirado.
  • Código que constrói PasswordPolicyViolation(...) à mão: passe code=PasswordViolationCode.<...>, agora obrigatório. Quem só lê o retorno de check_password_policy não muda nada.

0.305.0 — o signup grava os campos do schema que são coluna

make_auth_router(signup_schema=...) passa a gravar na linha, antes do insert, todo campo que o seu signup_schema acrescenta à SignupSchema e que também é coluna do user model (fora de SIGNUP_PROTECTED_FIELDS: id, email, hashed_password, is_active, is_admin). Até a 0.304.0 esses campos só chegavam à linha se o on_signup os copiasse.

O que fazer

  • on_signup que só copiava os campos (user.phone = payload.phone): pode ser removido.
  • Campo de schema com nome de coluna que você transforma antes de gravar (normaliza telefone, por exemplo): o on_signup continua rodando depois, na mesma transação, e sobrescreve o valor. Mas a unicidade é conferida no insert, com o valor cru — se isso importa, normalize num validator do próprio schema.
  • Campo de schema com nome de coluna que não devia ir para a linha: renomeie o campo.

0.304.0 — o leitor CSV do Google Sheets tem limite por padrão

read_google_sheet e read_google_sheet_as ganharam max_bytes e max_rows, ligados por padrão: um export acima de 10 MiB (DEFAULT_GOOGLE_CSV_MAX_DOWNLOAD_BYTES) ou com mais de 100 000 linhas de dado (DEFAULT_XLSX_MAX_ROWS) passa a levantar SpreadsheetTooLargeError (413, SPREADSHEET_TOO_LARGE). Até a 0.303.2 a mesma aba era lida inteira, sem teto. Detalhes em Aba grande demais.

O que muda

  • Aba com 100 001 linhas de dado: antes devolvia as 100 001; agora levanta SpreadsheetTooLargeError com details == {"limit": "rows", "max": 100000, "actual": 100001, "sheet": None, "row": 100002}.
  • Export acima de 10 MiB: a transferência é fechada e sai o mesmo erro com details["limit"] == "download_bytes".
  • Linha em branco não conta para max_rows.

O que fazer

  • Aba que você sabe que é grande e confia na origem: suba o limite (max_rows=500_000, max_bytes=50 * 1024 * 1024) ou desligue com max_rows=None / max_bytes=None.
  • Endpoint que repassa a exceção: o erro já é um AppException com 413; nada a fazer se você usa o register_exception_handlers do SDK.

0.304.0 — o SQLite confere chave estrangeira

Todo engine SQLite que o AsyncDatabaseManager e o create_test_engine montam passa a rodar PRAGMA foreign_keys=ON em cada conexão. Antes nenhum rodava, e o SQLite aceitava o que o PostgreSQL recusa: filho apontando para pai inexistente, ON DELETE CASCADE que não apagava nada, e add_all([filho, pai]) sem relationship() mandando o INSERT do filho primeiro. Detalhes e medição em Banco de dados.

O que muda

  • Teste que grava filho sem pai falha com IntegrityError: FOREIGN KEY constraint failed. Era um teste que passava no SQLite e falharia no PostgreSQL.
  • ON DELETE CASCADE apaga os filhos também no SQLite.
  • create_test_engine aplica o fix de savepoint do manager: um begin_nested() que sai limpo não comita mais a transação de fora.
  • Engine de migration continua sem FK, e o env.py gerado recusa uma conexão entregue com a FK ligada — veja abaixo.

O que fazer

  1. Semeie o pai nos testes. Crie a linha referenciada antes da que referencia, e dê flush() depois do pai quando os models não têm relationship(). Não desligue a FK no teste para fazê-lo passar: o defeito é o teste, não a FK.
  2. Banco SQLite existente: procure órfão antes de subir a versão.

    import sqlite3
    
    with sqlite3.connect("app.db") as conn:
        for row in conn.execute("PRAGMA foreign_key_check"):
            print(row)
    

    Cada linha é (tabela, rowid, tabela pai, índice da FK): o membro de id 2 apontando para uma organização que não existe sai ('member', 2, 'org', 0). Medido no SQLite 3.47.1: com a FK ligada, o banco abre e lê normalmente, UPDATE de outra coluna do órfão e DELETE dele passam; falha gravar uma chave sem pai (INSERT, ou UPDATE da coluna FK para um pai inexistente). 3. Precisa adiar? Desligue pelo caminho que você usa — ignorado em qualquer backend que não seja SQLite:

    from sqlalchemy.ext.asyncio import AsyncEngine
    
    from tempest_fastapi_sdk import AsyncDatabaseManager
    from tempest_fastapi_sdk.testing import create_test_engine
    
    db: AsyncDatabaseManager = AsyncDatabaseManager(
        "sqlite+aiosqlite:///./app.db",
        sqlite_foreign_keys=False,
    )
    engine: AsyncEngine = create_test_engine(foreign_keys=False)
    

    Pelo ambiente: DATABASE_SQLITE_FOREIGN_KEYS=false, lido pelo DatabaseSettings.database_kwargs(). 4. Conexão compartilhada com o Alembic: se você entrega a conexão de um AsyncDatabaseManager ao env.py (config.attributes["connection"]), ela agora chega com FK ligada — e o batch mode que recria a tabela pai apaga os filhos com ON DELETE CASCADE sem erro (medido com Alembic 1.19.1: 3 linhas → 0). O env.py que já está no seu repositório não recusa. Migre numa conexão de AsyncDatabaseManager(url, sqlite_foreign_keys=False) e regenere o env.py — passo a passo em Migrações. AlembicHelper e tempest db ... abrem engine próprio, sem FK, e não mudam.

0.304.0 — erro cru de ferramenta não chega mais ao modelo

Quando uma ferramenta do agente levanta uma exceção que não é AgentToolError, o modelo passa a ler só tool failed: <Tipo> na observação. Antes lia o texto inteiro da exceção — e o HTTPStatusError de um raise_for_status() traz a URL completa: medido, a observação levou ?apikey=s3cr3t ao modelo, que pode repetir o que lê. O traço já guardava só o tipo desde a 0.300.0; agora a conversa também. Detalhes em Por que traduzir a exceção.

O que muda

  • Observação de exceção crua era HTTPStatusError: Client error '401 Unauthorized' for url '...?apikey=...' e passa a ser tool failed: HTTPStatusError.
  • O traço não muda: continua HTTPStatusError: the tool failed (details withheld). O log (tempest_fastapi_sdk.agents.agent) continua com a exceção inteira.
  • AgentToolError não muda: a mensagem chega ao modelo e ao traço como foi escrita.
  • expose_tool_errors=True agora vale para os dois lados: entrega o texto ao modelo e ao traço, com as formas óbvias de credencial mascaradas (?apikey=***, Authorization: Bearer ***, postgresql://admin:***@db).

O que fazer

  • Ferramenta que contava com o modelo lendo o texto cru para se corrigir (um 404 que dizia "não existe", um erro de validação de outra lib) traduz a falha para AgentToolError com a frase que o modelo precisa ler.
  • Teste que afirmava o texto cru na observação passa a afirmar tool failed: <Tipo> — ou liga expose_tool_errors=True naquele agente.
  • Para voltar ao texto no modelo em desenvolvimento, Agent(..., expose_tool_errors=True). Ele também grava o texto no traço, que o router HTTP serve: não ligue num endpoint aberto.

0.302.0 — constraint composta leva todas as colunas no nome

NAMING_CONVENTION nomeava unique, índice e foreign key só pela primeira coluna, então UniqueConstraint("title") e UniqueConstraint("title", "release_year") viravam ambas uq_books_title. O PostgreSQL recusa o CREATE TABLE (relation "uq_books_title" already exists); o SQLite aceita as duas, e é por isso que o defeito passava no banco de teste. A convenção agora usa todas as colunas.

O que muda

  • Constraint composta muda de nome. uq, ix e fk com mais de uma coluna levam todas no nome: uq_books_title → uq_books_title_release_year, ix_books_author → ix_books_author_books_title, fk_books_tenant_id_authors → fk_books_tenant_id_author_id_authors.
  • Nome de uma coluna não muda, nem pk e ck.
  • O próprio SDK tem duas. O model de make_user_oauth_account_model renomeia uq_<tabela>_provider → uq_<tabela>_provider_subject e uq_<tabela>_user_id → uq_<tabela>_user_id_provider.
  • O constraint do parse_integrity_error acompanha o banco. Depois do RENAME, o PostgreSQL devolve o nome novo; mapa seu de nome de constraint para código de erro precisa da chave nova.

O que fazer

O alembic revision --autogenerate sobre um banco antigo propõe drop + create para as uniques e os índices compostos e não vê a FK composta renomeada (medido contra PostgreSQL 16 e Alembic 1.19.1). Esse drop falha quando uma FK depende da unique. O caminho é uma revisão com RENAME:

  1. Com todos os models importados, gere os RENAME com legacy_constraint_renames(BaseModel.metadata). Cada item é um ConstraintRename(kind, table, schema, columns, old_name, new_name); statement(dialect) escreve o SQL e inverse() dá o do downgrade(). Constraint com name= explícito fica de fora.

    from sqlalchemy import Index, UniqueConstraint
    from sqlalchemy.dialects import postgresql, sqlite
    from sqlalchemy.orm import Mapped, mapped_column
    
    from tempest_fastapi_sdk import (
        BaseModel,
        ConstraintRename,
        legacy_constraint_renames,
    )
    
    
    class BookModel(BaseModel):
        """Livros do catálogo."""
    
        __tablename__ = "books"
        __table_args__ = (
            UniqueConstraint("isbn"),
            UniqueConstraint("title", "release_year"),
            Index(None, "author", "title"),
        )
    
        isbn: Mapped[str] = mapped_column()
        title: Mapped[str] = mapped_column()
        release_year: Mapped[int] = mapped_column()
        author: Mapped[str] = mapped_column()
    
    
    renames: list[ConstraintRename] = legacy_constraint_renames(BaseModel.metadata)
    for rename in renames:
        print(rename.kind, rename.old_name, "->", rename.new_name)
        print(rename.statement(postgresql.dialect()))
    
    try:
        renames[0].statement(sqlite.dialect())
    except ValueError as exc:
        print(f"ValueError: {exc}")
    

    Saída:

    index ix_books_author -> ix_books_author_books_title
    ALTER INDEX ix_books_author RENAME TO ix_books_author_books_title
    unique uq_books_title -> uq_books_title_release_year
    ALTER TABLE books RENAME CONSTRAINT uq_books_title TO uq_books_title_release_year
    ValueError: ConstraintRename.statement supports postgresql, not 'sqlite'; SQLite cannot rename a constraint, so use the batch migration alembic --autogenerate renders
    

    O uq_books_isbn, de uma coluna, não aparece: o nome dele não mudou.

  2. Cole os comandos numa revisão vazia e rode alembic upgrade head antes do deploy da versão nova. O passo a passo, com a revisão completa e o downgrade(), está em Banco de dados → Migrar constraints compostas da convenção antiga.

  3. No SQLite o statement() levanta ValueError, como na saída acima: o SQLite não renomeia constraint, e lá a migração em batch que o autogenerate gera funciona.

Para adiar

Dê à constraint composta o nome que ela já tem no banco (UniqueConstraint("title", "release_year", name="uq_books_title")): nome explícito vence a convenção, e o legacy_constraint_renames deixa a constraint de fora. A convenção antiga continua disponível como LEGACY_NAMING_CONVENTION.

0.301.0 — CSS, DataTable, formulários, SSR e sessões

A release que trouxe para o SDK as pontes do painel do tempest-bucket muda comportamento em cinco áreas que um serviço percebe. Nenhuma exige migration de banco.

CSS

  • make_css_router e css_response mudam o Cache-Control default de public, max-age=3600 para no-cache no path sem versão: o browser revalida pelo ETag (304, sem corpo) antes de usar a cópia. Para o cache longo, linke sheet.url(path), que acrescenta ?v=<versão> e recebe public, max-age=31536000, immutable. Quem já passava cache_control= explícito não muda nada.
  • O reset do StyleSheet ganhou font-family no body. Regra própria que define a família continua vencendo quando vem depois do reset (é o caso do extra= do app_stylesheet).

Detalhe em CSS tipado.

DataTable

O <table> agora sai dentro de <div class="tui-table-scroll">, para a tabela larga rolar no contêiner em vez de alargar a página no celular. Seletor próprio que mirava .tui-card__body > table precisa incluir o wrapper.

Formulários

  • help_text presente sempre vence. {"ui": {"help_text": ""}}, None ou False suprimem a dica; antes caía na description do campo.
  • Campo UploadFile deixou de sair como <input type="text">: gera <input type="file">, e o formulário ganha enctype="multipart/form-data".

Detalhe em Formulários.

SSR

html_response(page) sem title= usa page.document_title() em vez de levantar ValueError. O erro continua para widget que não é Page.

Sessões

  • make_session_dependency(...) devolve dependency async. Em Depends(...) nada muda; teste que chamava o resolver direto precisa de await.
  • SessionAuth.user_model é type[BaseUserModel] | None. Código tipado que lê o atributo precisa estreitar.
  • SESSION_COOKIE_SAMESITE é Literal["lax", "strict", "none"]. Nenhum valor aceito antes é recusado agora; Settings que sobrescreve o campo como str passa a ser override incompatível no mypy — sobrescreva com o Literal ou remova o override.
  • O logout do make_session_router apaga o cookie com os atributos do settings, em vez de sempre Secure e HttpOnly.

Detalhe em Sessões.

0.300.0 — o anexo do chat registra quem fez o upload

BaseMessageAttachmentModel ganhou a coluna uploader_id (nullable, indexada). Como toda tabela de anexo concreta a herda, migre o banco antes de atualizar o pacote: sem a coluna, toda rota do chat que monta mensagem responde 500 (no such column: message_attachments.uploader_id, medido com SQLite).

O que fazer

  1. Gere a migration (alembic revision --autogenerate) ou escreva-a à mão — um op.add_column(..., sa.Column("uploader_id", sa.Uuid(), nullable=True)) e um op.create_index(...). O arquivo completo, com downgrade(), está na receita de chat.
  2. Rode alembic upgrade head e só então faça o deploy da versão nova.
  3. Grave o upload por ChatService.add_attachment(uploader_id, ...), ou passe uploader_id= onde você já grava a linha à mão.

Linhas antigas ficam com uploader_id NULL e seguem reivindicáveis por qualquer remetente — a janela de transição. Linha nova com autor só é reivindicada por uma mensagem do próprio autor; outra pessoa recebe o mesmo 404 de um id inexistente.

0.300.0 — o resto das quebras: IA, chat, métricas e migrations

A mesma release fecha defeitos de segurança e de recurso nos módulos de IA, e alguns consertos mudam o contrato. Percorra a lista e aplique o que você usa; o porquê de cada item, medido, está no CHANGELOG.md.

make_ai_chat_router

  • O campo user_id saiu do corpo — cliente que ainda o manda não quebra, o campo é ignorado. O dono da memória agora vem de uma dependência: make_ai_chat_router(pipeline, current_user_id=...). Pipeline com memory= montado sem current_user_id= levanta ValueError na montagem. Exemplo completo em Memória de longo prazo.
  • history com role fora de user/assistant responde 422.

ContentExtractor

  • Host privado (intranet, localhost, metadata de nuvem) é recusado com failed=True. Para buscar na intranet, passe allow_private_networks=True.
  • Página acima de 5 MiB precisa de max_response_bytes= maior.
  • HTTPS por proxy HTTP passa a falhar fechado; atrás de proxy, use allow_private_networks=True e deixe o proxy como controle de saída.

Limites novos que respondem 422

  • make_genai_router confere GenAIRequestLimits antes de rodar o modelo; quem precisa de mais passa limits=GenAIRequestLimits(...) (Limites por request). POST /image recusa num_images > 1.
  • make_vision_router lê no máximo 20 MiB e 50 MP por imagem (max_upload_bytes= / max_image_pixels=).
  • make_chat_router exige page >= 1 e 1 <= page_size <= 100 (max_page_size=), e os schemas do chat têm teto de tamanho.
  • POST /predict do modelops recusa lote acima de 10 000 linhas (max_rows=; None tira o limite).

Router de agentes

  • A rota de artefato mudou de /runs/{index}/artifacts/{name} para /runs/{run_id}/artifacts/{name}; POST /run devolve o run_id.
  • Sem owner=, toda execução continua visível para todo chamador — passe make_agent_router(agent, owner=...) quando houver mais de um usuário (Servir por HTTP).
  • Erro inesperado de ferramenta aparece no traço só como o tipo; Agent(expose_tool_errors=True) volta ao texto inteiro.
  • Backend próprio que implementa ChatBackend / ToolCallingBackend recebe as mensagens por posição e não precisa mais aceitar **kwargs.

Chat

  • Quem não enxerga a mensagem recebe 404 onde antes recebia 403. O remetente que ainda é participante continua recebendo 403 para mensagem alheia.

RAG

  • add e index dos stores do SDK e do HybridRetriever substituem por fonte: passe todos os chunks de uma source na mesma chamada, ou os lotes anteriores dela somem.

Métricas

  • genai_requests_total e genai_request_seconds ganharam o rótulo status (ok / error). Seletor {model, op} continua casando, mas cada série vira duas: some por status ou filtre status="ok" em query e alerta que comparam a série inteira.

modelops

  • /model devolve só o nome do arquivo em path; make_prediction_router(expose_model_path=True) restaura o caminho absoluto.
  • [modelops] passa a instalar numpy.

AlembicHelper

  • O método síncrono que roda o env.py, chamado com um event loop rodando (o lifespan do FastAPI), levanta RuntimeError — use o par _async (await helper.upgrade_async()). O env.py que você já tem continua funcionando (receita de migrations).

Outros

  • RedisFactStore: fato gravado antes sob subject="" ou subject="_" continua na chave antiga {prefix}:_, e a versão nova grava esses subjects em outra chave.
  • O helper privado genai/_lifecycle.py mudou para utils/_lifecycle.py, sem shim.

Nenhuma assinatura muda e nenhum default de campo existente muda. O que muda é quantos links de uma vez ficam válidos: emitir um token de conta agora gasta os outros tokens não usados do mesmo usuário e do mesmo propósito.

O que muda

Antes, cada POST /auth/password-reset/request só empilhava linha em user_tokens, e todos os tokens não usados e não expirados valiam ao mesmo tempo. Agora vale um por propósito — o mais recente.

Aplica-se a ACTIVATION, PASSWORD_RESET, EMAIL_CHANGE e EMAIL_VERIFICATION, porque a correção vive dentro do _issue_token, por onde os quatro fluxos passam.

O escopo é estreito nas duas direções:

  • Só o mesmo propósito. Pedir um reset de senha não derruba uma troca de e-mail pendente.
  • Só o mesmo usuário. O token de outra pessoa não é tocado.

A linha antiga é marcada used_at em vez de apagada, então a trilha de auditoria continua completa.

Por que o default mudou

Porque o comportamento antigo anulava a reação correta do usuário. Um atacante dispara o reset para a vítima; a vítima recebe um e-mail de recuperação que não pediu, desconfia, e por conta própria pede um reset e conclui. Sem superseder, o link do atacante continuava valendo até AUTH_PASSWORD_RESET_TTL_SECONDS. Medido:

AUTH_SINGLE_ACTIVE_TOKEN=True  -> link do atacante: recusado (InvalidTokenException)
AUTH_SINGLE_ACTIVE_TOKEN=False -> link do atacante: AINDA FUNCIONA

O que fazer

Na maioria dos serviços, nada. É a propriedade que quase todo provedor já aplica, e é o que o usuário espera.

Quebra num caso: um fluxo que de propósito mantém vários links vivos ao mesmo tempo — por exemplo, um convite reenviado para vários endereços em que qualquer um dos links deve funcionar. Se for o seu caso:

AUTH_SINGLE_ACTIVE_TOKEN=false

Um sintoma a reconhecer, se você não ligar o flag: um usuário que clica num link antigo agora recebe InvalidTokenException (400, INVALID_TOKEN) em vez de entrar. A instrução certa na sua UI é "peça um novo link", não "tente de novo".

0.272.0 — os checks passaram a ver o que já estava errado

Nenhuma assinatura muda, nenhum default muda. O que muda é o que o tempest check-config reporta — e o CI de quem roda --fail-level warning pode passar a falhar.

O que muda

Três correções, todas na direção de reportar mais:

Check Antes Agora
deployment.I001 lia DEBUG, que nenhum mixin declara — nunca disparava resolve SERVER_DEBUG e depois DEBUG
database.W001 a isenção de debug nunca valia; avisava com o debug ligado fica calado com o debug ligado, avisa com ele desligado
security.W004 não existia avisa quando o segredo ainda é o default declarado no campo

O security.W004 é o que muda o resultado do gate na prática. O default de JWTSettings.JWT_SECRET tem exatos 32 caracteres, então nunca acionava security.W002 — um serviço que nunca definiu JWT_SECRET passava limpo. Agora não passa.

O que fazer

Se o seu CI roda o gate com aviso bloqueante:

tempest check-config --fail-level warning

...e ele começar a falhar com security.W004, a falha está certa: o serviço estava assinando token com um valor publicado no código-fonte do SDK. Gere um segredo de verdade e ponha no ambiente:

tempest secrets rotate

Se o serviço declarou um campo DEBUG na mão em vez de compor ServerSettings, nada muda — a resolução tenta SERVER_DEBUG primeiro e cai em DEBUG depois.

O texto de duas mensagens mudou

deployment.I001 passou a nomear o campo que carregou o flag (SERVER_DEBUG is enabled.), e o database.W001 diz while debug is off em vez de while DEBUG is off. Se alguma automação casa esse texto por substring, ajuste; o id de cada mensagem não mudou.

0.270.0 — PixCharge.raw usa a grafia do fio

Não muda nenhuma assinatura. Muda as chaves de um dicionário que o contrato canônico entrega.

O que muda

PixCharge.raw sempre carregou o payload do provedor, mas com grafia diferente conforme o caminho. Medido na v0.269.0, mesmo corpo, mesmos dois caminhos:

API      raw['paymentLinkUrl']   -> None
API      raw['payment_link_url'] -> https://openpix.com.br/pay/pl_1
webhook  raw['paymentLinkUrl']   -> https://openpix.com.br/pay/pl_1

O caminho de API dumpava sem by_alias, então campo declarado saía em snake_case enquanto campo não declarado — que extra="allow" preserva desde a v0.259/v0.260 — mantinha o nome do fio. O raw do caminho de API era, literalmente, uma mistura das duas grafias.

A partir da v0.270.0 os dois caminhos usam by_alias=True: a grafia é a do provedor, sempre.

O que fazer

Se o seu serviço lê raw em campo declarado pela especificação, troque para o nome do fio:

from tempest_fastapi_sdk.integrations.payment import PixCharge


def payment_link(charge: PixCharge) -> object | None:
    """Read OpenPix's payment link out of the raw payload.

    Args:
        charge (PixCharge): The charge, in canonical shape.

    Returns:
        object | None: The link, when the provider sent one.
    """
    return charge.raw.get("paymentLinkUrl")

Quem já lia campo não declarado (paidAt, por exemplo) não muda nada: esses já vinham na grafia do fio.

Como achar as ocorrências

grep -rn 'raw\[\|raw\.get(' <seu-serviço>/

Toda leitura de raw com chave em snake_case é candidata. A contrapartida é o nome que a documentação do provedor usa.

Por que a grafia do fio, e não a do Python

raw existe para o que o contrato não modela. O consumidor descobre esse campo lendo a documentação do provedor, e lá ele se chama paymentLinkUrl. Uma grafia que só existe depois de o SDK renomear é uma que ninguém consegue prever.

0.270.0 — PaymentStatus ganhou UNKNOWN

Membro novo num enum. Aditivo: nada que existia mudou de valor.

O que muda

Um estado que o SDK não classifica agora chega como PaymentStatus.UNKNOWN, com a string do provedor preservada em provider_status. Antes, os dois caminhos da OpenPix respondiam coisas opostas e nenhuma era verdade:

API      status 'CANCELLED' -> ValidationError -> 500 no seu serviço
webhook  status 'CANCELLED' -> status=pending

O que fazer

Se você ramifica exaustivamente em PaymentStatus — um match sem case _, um dicionário indexado por membro — acrescente o caso:

from tempest_fastapi_sdk.integrations.payment import PaymentStatus, PixCharge


def describe(charge: PixCharge) -> str:
    """Describe a charge's state for a support screen.

    Args:
        charge (PixCharge): The charge, in canonical shape.

    Returns:
        str: A line a human can act on.
    """
    if charge.status is PaymentStatus.UNKNOWN:
        return f"estado não classificado: {charge.provider_status}"
    return charge.status.value

Quem ramifica só em PAID/PENDING com um ramo default não precisa fazer nada — o comportamento novo é estritamente melhor: uma cobrança que antes aparecia como PENDING sem ser, ou que derrubava a requisição, agora se identifica.

0.269.0 — Charge.expires_in virou int

Não muda nenhuma assinatura. Muda o tipo de um campo de resposta da OpenPix.

O que muda

Charge.expires_in era str | None e passa a ser int | None. O documento da Woovi declara expiresIn como string, a API devolve 3600, e a validação estourava antes de qualquer consumidor ver a cobrança:

charge.expiresIn
  Input should be a valid string [type=string_type, input_value=3600, input_type=int]

O que fazer

Nada, em runtime. Como nenhuma resposta de cobrança chegava a ser construída, o tipo antigo nunca entregou um valor a ninguém — não existe código lá fora que tenha lido uma str desse campo e funcionado.

Se você anotou o campo, troque o tipo:

from tempest_fastapi_sdk.integrations.payment.openpix import Charge


def seconds_left(charge: Charge) -> int:
    """Quantos segundos faltam para a cobrança expirar."""
    return charge.expires_in or 0

Por que não int | str

Uma união empurraria a ambiguidade para todo consumidor, que passaria a precisar de int(charge.expires_in) defensivo sem nunca saber se algum dia recebe texto. O documento se contradiz sobre esse campo em três lugares (Charge diz string, ChargePayload diz number, WebhookCharge diz integer) — a evidência está em vendor/openpix-evidence.md, seção 7.

0.265.0 — array opcional em corpo de request virou | None

Não muda assinatura de método. Muda o tipo de alguns campos dos modelos de corpo gerados, e o que vai para o fio.

O que muda

Um array que a especificação lista como opcional, num modelo que o SDK só envia, deixa de ser list[T] com default_factory=list e passa a ser list[T] | None com default=None. São 21 campos: 9 na OpenPix, 12 na Mercado Pago.

Junto disso, o _dump gerado passa a usar exclude_unset=True, então nenhum campo que você não tocou vai para o corpo — inclusive nos modelos compartilhados, que mantiveram a grafia de lista.

Modelo de resposta não mudou: Charge.additional_info continua [] quando o provedor não manda nada, que é a convenção do SDK.

O que fazer

Nada, se você só constrói o payload e envia. O caso que a correção conserta é justamente esse:

antes:  {"correlationID": "...", "value": 1190, "additionalInfo": [], "splits": []}  → 400
agora:  {"correlationID": "...", "value": 1190}                                      → 200

Se você lê o campo de volta do payload que construiu, o valor agora é None em vez de []. Antes, for split in payload.splits: percorria uma lista vazia e não executava; agora levanta TypeError. A forma que funciona nas duas versões:

from tempest_fastapi_sdk.integrations.payment.openpix import ChargePayload

payload: ChargePayload = ChargePayload(correlation_id="abc", value=1190)

for split in payload.splits or []:
    print(split.pix_key)

Informar lista vazia continua possível — e continua sendo uma afirmação

ChargePayload(..., splits=[]) envia "splits": [], e a Woovi responde 400 O array de split precisa ter ao menos um item. Isso é correto: você afirmou algo, e o provedor julgou. O que mudou é que o SDK parou de afirmar por você.

0.264.0 — o idioma passa a ser do fluxo, não de cada ponta

Não quebra assinatura nenhuma. Muda o idioma em que algumas páginas saem — para o correto, mas de forma observável.

O que muda

Até a v0.263.0 o e-mail lia AUTH_DEFAULT_LOCALE e a página negociava Accept-Language, cada um por conta própria. Agora os dois chamam resolve_locale, na mesma ordem:

1. ?lang= no link     (a língua em que ESTE e-mail saiu)
2. user.locale        (a preferência que você gravou na linha)
3. Accept-Language    (só páginas)
4. AUTH_DEFAULT_LOCALE

Duas consequências observáveis:

  1. Usuário com locale gravado e navegador em outra língua passa a ver a página no idioma dele. Antes o header vencia.
  2. O link do e-mail ganha ?lang=<locale>. A query anterior é preservada byte a byte — o token não é reencodado —, mas a URL tem um parâmetro a mais.

O que fazer

Nada, se você não guarda idioma de usuário e não valida a query do link. O comportamento sem sinal nenhum é o de antes: Accept-Language, depois AUTH_DEFAULT_LOCALE.

Se a sua rota de frontend recusa query param desconhecido, desligue o carimbo:

AUTH_STAMP_LOCALE_IN_LINK=false

Se você quer que a preferência do usuário mande, guarde-a — o SDK lê o atributo com getattr, então basta existir:

from tempest_fastapi_sdk import BaseUserModel, LocaleColumnMixin


class UserModel(LocaleColumnMixin, BaseUserModel):
    """A coluna `locale` (BCP-47, nullable) entra pelo mixin."""

    __tablename__ = "users"

Locale que o SDK não conhece não é resposta

O SDK ships pt-BR e en-US. Uma linha com locale="fr-FR" não para a busca: ela cai para o próximo sinal, em vez de renderizar numa língua que não existe.

0.263.0 — AdminAuthBackend virou genérica

Não quebra runtime nenhum. Quebra o type-check de quem subclasseia AdminAuthBackend e roda mypy --strict — o que o pyproject.toml escrito pelo tempest new liga por default.

O que muda

AdminAuthBackend passou a ser Generic[PrincipalT], parametrizada pelo principal — o valor que authenticate devolve e que load_principal, principal_id, display_name, mfa_enabled e verify_mfa consomem. Antes, os seis passavam esse valor como Any.

Subclassear sem parâmetro continua válido em runtime e resolve para AdminAuthBackend[Any], que é o comportamento de até a v0.262.0. O que muda é que mypy --strict liga disallow_any_generics, e aí:

src/admin/backend.py:12: error: Missing type arguments for generic type
"AdminAuthBackend"  [type-arg]

Medido: o basedpyright em modo standard não reclama; o mypy strict sim.

O que fazer

1. Escreva o seu principal no parâmetro. É a opção que compra alguma coisa — era class LdapAuthBackend(AdminAuthBackend) com principal: Any em todo método; passa a ser:

from dataclasses import dataclass

from sqlalchemy.ext.asyncio import AsyncSession

from tempest_fastapi_sdk import AdminAuthBackend, AdminAuthError


@dataclass
class LdapEntry:
    """O principal que o seu diretório devolve."""

    dn: str
    common_name: str


DIRECTORY: dict[str, LdapEntry] = {
    "admin@example.com": LdapEntry(dn="cn=admin", common_name="Admin"),
}


class LdapAuthBackend(AdminAuthBackend[LdapEntry]):
    """Autentica contra o diretório em vez da tabela de usuários."""

    async def authenticate(
        self,
        session: AsyncSession,
        *,
        identifier: str,
        password: str,
    ) -> LdapEntry:
        """Devolve a entrada do diretório, ou recusa o login."""
        entry = DIRECTORY.get(identifier)
        if entry is None:
            raise AdminAuthError("Invalid credentials")
        return entry

    async def load_principal(
        self,
        session: AsyncSession,
        principal_id: str,
    ) -> LdapEntry | None:
        """Recarrega a entrada a cada request."""
        for entry in DIRECTORY.values():
            if entry.dn == principal_id:
                return entry
        return None

    def principal_id(self, principal: LdapEntry) -> str:
        """Devolve o que vai serializado no cookie."""
        return principal.dn

    def display_name(self, principal: LdapEntry) -> str:
        """Devolve o que o cabeçalho do admin mostra."""
        return principal.common_name

Com o parâmetro escrito, um display_name que lê outro tipo do que authenticate devolveu vira erro de override — em mypy (que cita Liskov por nome) e em basedpyright. Era exatamente o defeito que o Any deixava passar.

2. Ou preserve o comportamento anterior, literalmente. AdminAuthBackend[Any] é o que a classe sem parâmetro já resolvia:

from typing import Any

from sqlalchemy.ext.asyncio import AsyncSession

from tempest_fastapi_sdk import AdminAuthBackend, AdminAuthError


class LegacyAuthBackend(AdminAuthBackend[Any]):
    """Exatamente o comportamento de até a v0.262.0."""

    async def authenticate(
        self,
        session: AsyncSession,
        *,
        identifier: str,
        password: str,
    ) -> Any:
        """Devolve o principal que o seu IdP entregar."""
        raise AdminAuthError("Invalid credentials")

    async def load_principal(
        self,
        session: AsyncSession,
        principal_id: str,
    ) -> Any | None:
        """Recarrega o principal, ou invalida a sessão."""
        return None

    def principal_id(self, principal: Any) -> str:
        """Devolve o identificador do cookie."""
        return str(principal)

Zero diferença em relação ao que você tinha; só cala o disallow_any_generics.

UserModelAuthBackend não pede nada

A implementação que o SDK ships já declara AdminAuthBackend[BaseUserModel]. Quem usa o backend default — auth_backend=UserModelAuthBackend(user_model=UserModel) — não muda linha nenhuma.

Protocolos de Redis: só ficou mais fácil de satisfazer

A mesma release tornou posicional todo parâmetro obrigatório de ThrottleBackend, _RedisHashClient e RedisLike, e trocou Awaitable[Any] pelo tipo concreto. Isso alarga o que é aceito: um backend seu que já passava continua passando, e redis.asyncio.Redis/fakeredis — que o basedpyright recusava — passam agora. A única forma de sentir isso é ter um call site que declarava o retorno errado apoiado no Any; nesse caso o type-checker vai apontar a linha.

0.261.0 — o router WebSocket manda um hello antes de tudo

Quebra cliente que lê o primeiro frame por posição. Cliente que despacha por type e ignora o que não conhece não sente nada.

O que muda

O primeiro frame que make_websocket_router manda passou a ser:

{"type": "hello", "data": {"heartbeat_seconds": 30}, "request_id": null}

Antes, o primeiro frame do servidor era o ping, ou o que o seu handler mandasse.

O que fazer

1. Se o seu cliente lê o primeiro frame por posição, consuma o hello.

// até a v0.260.0
socket.onmessage = (event) => {
  const first = JSON.parse(event.data);   // era ping ou dado de aplicação
};

// a partir da v0.261.0 — despache por tipo, que é estável
socket.onmessage = (event) => {
  const frame = JSON.parse(event.data);
  if (frame.type === "hello") {
    calibrateWatchdog(frame.data.heartbeat_seconds);
    return;
  }
  if (frame.type === "ping") {
    socket.send(JSON.stringify({ type: "pong", data: {} }));
    return;
  }
  handle(frame);
};

2. Aproveite o número. O socket do browser só reporta conexão que fecha limpo; um link que morre em voo deixa o readyState em OPEN com nada chegando nunca mais. O watchdog de silêncio do cliente não é opcional, e calibrá-lo pelo hello significa que retunar WS_HEARTBEAT_SECONDS no servidor não faz mais todo cliente confundir intervalo normal com queda.

O que ganha sem fazer nada

  • Qualquer frame de entrada conta como prova de vida. Antes só pong contava, então um cliente ocupado respondendo outra coisa podia cruzar o deadline e ser fechado com 4408. O pong continua sendo consumido antes do handler.
  • heartbeat virou público, utilizável em endpoint sem auth e sem hub. Receita em docs/recipes/websocket.md, seção "Heartbeat sem o router".
  • CompactPaginationSchema/CompactPaginationFilterSchema, para quem publica size em vez de page_size. Nada muda para quem não usa.

0.260.0 — a spec da OpenPix foi refrescada, e os métodos mudaram de nome

Quebra todo chamador do OpenPixClient: os 125 métodos mudaram de nome. Quebra também quem importa classe de schema derivada de operação, quem lê OpenPixEnvironment.PRODUCTION, e quem dependia de extra="allow" num model que também é payload.

Por que

O documento vendorizado estava duas versões atrás do publicado — 3.0.3 intitulado "OpenPix" contra o 3.1.0 "Woovi" —, e nada no repositório media isso. O documento novo traz operationId em 125 de 125 operações, onde o antigo tinha zero: o gerador derivava nome do path, e agora usa o nome que o provedor deu.

Isso é upside disfarçado de quebra. post_api_v1_charge era o nome que sobrava de não haver nome; create_charge é o nome da operação.

O que fazer

1. Renomeie as chamadas. Cada linha é uma substituição direta. Das 103, só três mudaram de assinatura:

Método Mudança
update_customer o path param passou de correlation_id para id — quem chamava por posição não sente, quem chamava por keyword sim
get_statement ganhou company_bank_account, opcional
get_transaction ganhou company_bank_account, opcional
from tempest_fastapi_sdk.integrations.payment.openpix import (
    ChargePayload,
    OpenPixClient,
)


async def cobrar(client: OpenPixClient, payload: ChargePayload) -> None:
    """Create one charge with the renamed method."""
    # até a v0.259.0: await client.post_api_v1_charge(body=payload)
    await client.create_charge(body=payload)
Antes (v0.259.0) Agora (v0.260.0)
get_api_image_qrcode_base64_by_id get_charge_qr_code_base64
post_api_v1_account duplicate_account
delete_api_v1_account_register_by_id delete_account_register
get_api_v1_account list_accounts
delete_api_v1_account_by_account_id close_account
get_api_v1_account_by_account_id get_account
post_api_v1_account_by_account_id_withdraw withdraw_from_account
delete_api_v1_application delete_application
post_api_v1_application create_application
post_api_v1_boleto_validate validate_boleto
post_api_v1_cashback_fidelity create_cashback_fidelity
get_api_v1_cashback_fidelity_balance_by_tax_id get_cashback_fidelity_balance
get_api_v1_charge list_charges
post_api_v1_charge create_charge
delete_api_v1_charge_by_id delete_charge
get_api_v1_charge_by_id get_charge
patch_api_v1_charge_by_id update_charge
get_api_v1_charge_by_id_refund list_charge_refunds
post_api_v1_charge_by_id_refund refund_charge
get_api_v1_company get_company
get_api_v1_customer list_customers
post_api_v1_customer create_customer
patch_api_v1_customer_by_correlation_id update_customer
get_api_v1_customer_by_id get_customer
post_api_v1_decode_emv decode_emv
get_api_v1_dispute list_disputes
get_api_v1_dispute_by_id get_dispute
post_api_v1_funds_recovery create_funds_recovery
get_api_v1_funds_recovery_by_id get_funds_recovery
post_api_v1_funds_recovery_by_id_cancel cancel_funds_recovery
get_api_v1_installments_by_id get_installment
post_api_v1_installments_by_id_cobr create_installment_cobr
post_api_v1_installments_by_id_cobr_retry retry_installment_cobr
get_api_v1_invoice list_invoices
post_api_v1_invoice create_invoice
get_api_v1_invoice_integration get_invoice_integration
patch_api_v1_invoice_integration set_invoice_integration_status
post_api_v1_invoice_integration upsert_invoice_integration
put_api_v1_invoice_integration update_invoice_integration_tax_fields
post_api_v1_invoice_integration_certificate upload_invoice_integration_certificate
post_api_v1_invoice_integration_test test_invoice_integration
post_api_v1_invoice_by_correlation_id_cancel cancel_invoice
get_api_v1_invoice_by_correlation_id_pdf get_invoice_pdf
get_api_v1_invoice_by_correlation_id_xml get_invoice_xml
post_api_v1_kyc_onboarding create_kyc_onboarding
get_api_v1_limits_by_account_id get_account_limits
get_api_v1_partner_affiliate list_partner_affiliates
post_api_v1_partner_application create_partner_application
get_api_v1_partner_company list_partner_companies
post_api_v1_partner_company create_partner_company
get_api_v1_partner_company_by_tax_id get_partner_company
get_api_v1_payment list_payments
post_api_v1_payment create_payment
post_api_v1_payment_approve approve_payment
get_api_v1_payment_by_id get_payment
get_api_v1_pix_keys list_pix_keys
post_api_v1_pix_keys create_pix_key
post_api_v1_pix_keys_check check_pix_key
get_api_v1_pix_keys_tokens list_pix_key_tokens
get_api_v1_pix_keys_tokens_logs list_pix_key_token_logs
delete_api_v1_pix_keys_by_pix_key delete_pix_key
get_api_v1_pix_keys_by_pix_key_check check_pix_key_by_key
put_api_v1_pix_keys_by_pix_key_default set_default_pix_key
get_api_v1_psp list_psps
get_api_v1_qrcode_static list_static_qr_codes
post_api_v1_qrcode_static create_static_qr_code
delete_api_v1_qrcode_static_by_id delete_static_qr_code
get_api_v1_qrcode_static_by_id get_static_qr_code
get_api_v1_receipt_by_receipt_type_by_end_to_end_id get_receipt
get_api_v1_refund list_refunds
post_api_v1_refund create_refund
get_api_v1_refund_by_id get_refund
post_api_v1_stablecoin_deposit create_stablecoin_deposit
post_api_v1_stablecoin_deposit_approve approve_stablecoin_deposit
get_api_v1_stablecoin_quote get_stablecoin_quote
get_api_v1_stablecoin_subaccount list_stablecoin_subaccounts
post_api_v1_stablecoin_subaccount create_stablecoin_subaccount
get_api_v1_stablecoin_subaccount_by_sub_account_id get_stablecoin_subaccount
get_api_v1_statement get_statement
get_api_v1_subaccount list_subaccounts
post_api_v1_subaccount create_subaccount
post_api_v1_subaccount_transfer transfer_between_subaccounts
delete_api_v1_subaccount_by_id delete_subaccount
get_api_v1_subaccount_by_id get_subaccount
post_api_v1_subaccount_by_id_credit credit_subaccount
post_api_v1_subaccount_by_id_debit debit_subaccount
get_api_v1_subaccount_by_id_statement get_subaccount_statement
post_api_v1_subaccount_by_id_withdraw withdraw_from_subaccount
get_api_v1_subscriptions list_subscriptions
post_api_v1_subscriptions create_subscription
get_api_v1_subscriptions_by_id get_subscription
put_api_v1_subscriptions_by_id_cancel cancel_subscription
get_api_v1_subscriptions_by_id_installments list_subscription_installments
put_api_v1_subscriptions_by_id_value update_subscription_value
get_api_v1_transaction list_transactions
get_api_v1_transaction_by_id get_transaction
post_api_v1_transfer create_transfer
get_api_v1_webhook list_webhooks
post_api_v1_webhook create_webhook
get_api_v1_webhook_events list_webhook_events
get_api_v1_webhook_ips list_webhook_ips
delete_api_v1_webhook_by_id delete_webhook
get_openpix_charge_brcode_image_id_png get_charge_qr_code_image

2. Três métodos não têm substituição direta.

Antes Agora O que estava errado
post_api_v1_dispute_id_evidence(body=...) upload_dispute_evidence(id, *, body=...) O path era /api/v1/dispute/:id/evidence, com dois-pontos literal na URL, e não havia argumento para nomear a disputa
get_api_v1_account_register() get_account_register(id) Docstring dizia "by CorrelationID" e o método não recebia nada
delete_api_v1_payment_by_id(id) removido O endpoint não existe: o documento publicado tem só get nesse path, e o DELETE que a Woovi documenta é em /api/v1/charge/{id}

3. Classe de schema derivada de operação mudou de nome. As de components não mudaram — Charge, ChargePayload, ChargeStatus, Transaction, Customer seguem iguais. O que mudou é o inline:

O nome que existia até a v0.259.0 era GetApiV1ChargeResponsePageInfo. A partir da v0.260.0:

from tempest_fastapi_sdk.integrations.payment.openpix import (
    ListChargesResponsePageInfo,
)

O padrão é o mesmo do método: GetApiV1ChargeResponse… vira ListChargesResponse…, PostApiV1PaymentBody… vira CreatePaymentBody….

4. OpenPixEnvironment.PRODUCTION agora é https://api.woovi.com. É o servers[0] do documento refrescado. O host antigo continua no ar — medido em 2026-08-28, GET /api/v1/charge devolve 401 em api.openpix.com.br, api.woovi.com e api.woovi-sandbox.com igualmente —, então serviço com a URL fixada não quebra. Se você assere o valor em teste, atualize a expectativa.

5. Se você lia model_extra de um model que também é payload. Uma classe alcançável pela resposta e pelo request body volta a extra="ignore". São 4 classes na OpenPix — PreRegistrationPayloadObject, que é o body e o 200 da mesma operação, mais as três que ela alcança — e 25 na Mercado Pago. O motivo é o que a v0.259.0 já dizia e não conseguia cumprir: chave inesperada num payload é typo de quem chamou, e levá-la ao provedor é pior que descartar.

O que ganha sem fazer nada

  • 24 operações novas: anticipation (7), stablecoin payout e wallets (7), boleto-transaction (2), kyc-validation (2), files, webhook/public-keys.
  • Transaction.webhook_sent[].status passa a ser int — é um código HTTP.
  • to_cents e reais_to_cents recusam sempre com ValueError. Antes, "abc" levantava decimal.InvalidOperation, None levantava TypeError e float("inf") levantava OverflowError.

0.259.0 — dinheiro da OpenPix passa a ser int

Quebra quem anotou variável com o tipo do model gerado, ou quem compara o dump com um JSON esperado.

O que muda

154 campos do pacote integrations/payment/openpix deixam de ser float:

  • valor monetário — Charge.value, ChargePayload.value, ChargeRefundPayload.value, Transaction.value, SubAccount.balance, todos os pix*Limit, e os demais dos 50 value do documento;
  • contagem e offset de dia — skip e limit, installmentsCount, dayDue, daysForDueDate, expiresIn. O par de paginação aparecia 27 vezes como campo de resposta (pageInfo, Pagination) e em 6 operações como query param.

A Woovi liquida em centavo inteiro — a própria spec diz isso na descrição do campo, em 58 schemas numéricos — e tipava tudo como number. O efeito visível é no fio:

from tempest_fastapi_sdk.integrations.payment.openpix import ChargePayload

ChargePayload(correlation_id="abc-1", value=1000).model_dump(
    by_alias=True, mode="json", exclude_none=True
)
# até a v0.258.0: {"correlationID": "abc-1", "value": 1000.0, ...}
# a partir da v0.259.0: {"correlationID": "abc-1", "value": 1000, ...}

19 campos continuam float: basePrice (taxa de câmbio), inputAmount/outputAmount da cotação de stablecoin (o primeiro é documentado como "currency unit, not cents"), rate, o balde de tokens de rate limit, annualRevenue e Installment.expiration — os dois últimos sem unidade declarada no documento, e por isso deixados como estavam.

Corrigido na v0.260.0

Esta seção dizia originalmente "18 campos", e que eles eram "os únicos onde a fração é real". Eram 19, e um deles era Transaction.webhookSent[].status, descrito no próprio documento como "HTTP response status code of the webhook delivery attempt" — código HTTP nunca é fracionário. Ele virou int na v0.260.0.

O que fazer

Na maioria dos casos, nada — int satisfaz onde float era esperado em tempo de execução. Confira três pontos:

  • Anotação sua. valor: float = charge.value passa a ser erro de tipo. Troque para int.
  • Comparação de dump em teste. assert dumped == {"value": 1000.0} continua passando (1000 == 1000.0), mas comparação contra string JSON, ou snapshot de model_dump_json(), não passa.
  • to_cents sobre model gerado. Continua funcionando e continua validando (recusa negativo e fração), só não estreita mais nada. Para payload cru — o dicionário que saiu do JSON, um webhook — ele continua sendo a forma certa.

O outro lado: seus campos param de sumir

Model de resposta gerado agora usa extra="allow". Campo que o provedor responde e a spec não declara fica em model_extra em vez de ser descartado na validação:

from tempest_fastapi_sdk.integrations.payment.openpix import Charge

charge = Charge.model_validate({"value": 1000, "wooviAdicionouIsso": "depois"})
(charge.model_extra or {})["wooviAdicionouIsso"]   # "depois"

Model de payload não mudou: continua extra="ignore", porque ali chave inesperada é erro de digitação de quem chamou.

Três campos que estavam nessa situação passaram a ser declarados e tipados: Charge.fee, Charge.discount e Charge.value_with_discount. Se você lia algum deles pelo HTTPClient cru por causa disso, agora dá para usar o método gerado.

0.258.0 — os arquivos de log passam a rotacionar

Não quebra assinatura nenhuma. Muda o que existe no disco: quem lia logs/info.log de fora do SDK passa a ver uma janela, não o histórico inteiro.

O que muda

Os handlers por nível eram logging.FileHandler — crescimento sem teto. Agora são RotatingFileHandler com max_bytes=10_000_000 e backup_count=5: cada nível para em ~60 MB (cinco rotacionados mais o que está sendo escrito), e info.log.1, info.log.2, … aparecem ao lado do arquivo corrente.

O motivo é o mesmo que já tinha fechado o lado leitor deste par: o router de /logs limita a leitura a DEFAULT_MAX_RECORDS_PER_FILE = 20_000 registros por arquivo, adicionado depois que um serviço com diretório de log em gigabytes respondeu com worker morto. Num serviço que loga uma linha por request, rodando em host de longa duração, é o info.log que enche o disco — e disco cheio derruba o serviço e o que mais dividir a partição.

O que fazer

Nada, na maioria dos casos. Confira dois pontos:

  • Coletor que segue o arquivo pelo nome. Filebeat, Promtail, Fluent Bit e companhia lidam com rotação, mas um tail -F caseiro ou um script que abre o arquivo uma vez no boot perde a virada. Aponte o padrão para info.log* se você precisa dos rotacionados.
  • GET /logs mostra a janela corrente. O endpoint lê os nomes exatos (info.log, error.log, …), então rotacionado não entra na resposta. Retenção mais longa é trabalho de coletor, não do endpoint.

Se você quer o comportamento antigo

max_bytes=0 volta ao FileHandler puro — para host onde logrotate ou um sidecar já é o dono da retenção:

from tempest_fastapi_sdk import configure_logging

configure_logging(level="INFO", max_bytes=0)

Os dois knobs são keyword-only e independentes: backup_count só é lido quando a rotação está ligada.

0.257.0 — os fakes de agente passam a chamar o parâmetro de tools

Quebra quem chamava chat_with_tools(..., specs=[...]) por keyword.

O que muda

ScriptedBackend e FailingBackend existem para fingir os protocolos ChatBackend / ToolCallingBackend — e não satisfaziam nenhum dos dois sob mypy. O protocolo chama o parâmetro de tools e aceita **kwargs; os fakes chamavam de specs e não aceitavam nada além. Um membro de protocolo só é implementado por assinatura com o mesmo nome de parâmetro, então a linha de abertura da receita de teste era erro de tipo:

from tempest_fastapi_sdk.agents import Agent
from tempest_fastapi_sdk.agents.testing import ScriptedBackend, replies

agent = Agent(ScriptedBackend([replies("ok")]))
# até a v0.256.0:
# Argument 1 to "Agent" has incompatible type "ScriptedBackend";
# expected "ChatBackend | ToolCallingBackend"  [arg-type]

O que fazer

Nada, se você chama posicionalmente — que é o que o Agent faz por dentro, e o que a receita mostra. Se o seu teste chama o fake direto, por keyword:

-decision = await backend.chat_with_tools(messages, specs=specs)
+decision = await backend.chat_with_tools(messages, tools=specs)

O atributo specs_seen — onde o fake grava os nomes de tool oferecidos a cada turno — não mudou de nome.

O que passou a compilar

Três anotações que recusavam o argumento que a própria doc mandava passar:

  • EventStream.response(on_disconnect=task.cancel), porque Task.cancel devolve bool e a anotação pedia None;
  • RedisIdempotencyStore(Redis.from_url(...)), RedisResponseCacheStore(...) e RedisWebAuthnChallengeStore(...), porque os protocolos exigiam o nome de parâmetro key/name e retorno Coroutine, e o redis-py devolve Awaitable;
  • require_authenticated(identity) com um FirebaseIdentity, porque o TypeVar estava preso a BaseUserModel.

Nenhuma delas muda runtime — só param de exigir contorno (# type: ignore, cast) de quem roda type-checker.

0.256.0 — o 429 do RateLimitMiddleware passa a ser JSON

Quebra quem lê o corpo do 429 como texto.

O que muda

Até a v0.255.0 o middleware respondia text/plain com o error_message cru:

HTTP/1.1 429 Too Many Requests
content-type: text/plain; charset=utf-8

Too many requests

Agora responde o mesmo envelope que register_exception_handlers escreve em todo handler:

HTTP/1.1 429 Too Many Requests
content-type: application/json
retry-after: 60

{"detail": "Too many requests",
 "code": "TOO_MANY_REQUESTS",
 "details": {"retry_after_seconds": 60, "limit": 15}}

O motivo é uma contradição do próprio SDK: error_responses() sempre apontou o 429 para o ErrorResponseSchema, então cliente gerado a partir do OpenAPI quebrava ao desserializar o texto — e quem adotava register_exception_handlers junto com o middleware ficava com duas formas de erro na mesma API.

O que fazer

  • Cliente que ramifica por status === 429: nada. O status e o Retry-After não mudaram.
  • Cliente que lê o corpo como texto: passe a ler JSON e a usar detail para exibir, code para ramificar.
// antes
const message = await response.text();

// depois
const { detail, code } = await response.json();
  • Serviço que reescrevia a resposta (subclasse do middleware convertendo o texto em envelope) pode apagar o contorno: error_message e o novo error_code cobrem o caso.

Se você precisa do texto de volta

Não há flag para isso. O corpo antigo era incompatível com o schema que a própria rota documenta; manter as duas formas manteria o defeito atrás de uma opção. Um serviço que realmente precise de outro formato pode subclassar o middleware e sobrescrever a resposta, como antes.

0.252.0 — :memory: do SQLite ganha conexão por sessão

Não quebra API. Muda a topologia de conexão de um banco :memory:, e por isso vale ler antes de atualizar se você depende do comportamento antigo.

O que muda

AsyncDatabaseManager("sqlite+aiosqlite:///:memory:") passa a construir o engine sobre um banco in-memory de cache compartilhado (file:<nome-único>?mode=memory&cache=shared&uri=true) com pool normal, em vez de deixar o SQLAlchemy escolher StaticPool com uma única conexão. O manager mantém uma conexão viva enquanto existir, porque banco de cache compartilhado morre com a última conexão.

Isso conserta o erro que apareceu na v0.200.0, quando o BEGIN explícito passou a ser emitido para todo engine SQLite:

sqlite3.OperationalError: cannot start a transaction within a transaction
[SQL: BEGIN]

Duas sessões sobrepostas voltam a funcionar em :memory:, e o RELEASE SAVEPOINT continua não sendo um commit disfarçado.

Se você depende de uma conexão só

Passe o pool explicitamente — pool informado pelo caller nunca é sobrescrito:

from sqlalchemy.pool import StaticPool
from tempest_fastapi_sdk import AsyncDatabaseManager

db = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:", poolclass=StaticPool)

Isso restaura a topologia anterior, incluindo a falha em sessões sobrepostas.

Se você trocou :memory: por arquivo temporário como contorno

Pode voltar para :memory:. O contorno continua funcionando, então não é urgente.

0.251.0 — a assinatura de webhook do Mercado Pago passa a seguir o algoritmo do provedor

Quebra quem passava manifest_template= ou desempacotava o retorno de parse_signature_header. Se você só chama verify_signature, nada muda no seu código — só passam a verificar entregas que antes eram rejeitadas.

O manifesto omite par ausente, e por isso não é mais template

A implementação anterior renderizava "id:{data_id};request-id:{request_id};ts:{ts};". O validador oficial do Mercado Pago (mercadopago/sdk-nodejs, src/utils/webhook/index.ts, commit 99857f33) omite o par cujo valor é ausente. Uma entrega sem data.id assina request-id:...;ts:...;, e o template fixo assinava id:;request-id:...;ts:...; — hash diferente, verificação falhando sempre.

from tempest_fastapi_sdk.integrations.payment.mercado_pago import build_manifest

# antes: DEFAULT_MANIFEST_TEMPLATE + str.format
# agora: a regra, exportada
build_manifest(data_id="", request_id="req-1", timestamp="1771891200")
# "request-id:req-1;ts:1771891200;"

DEFAULT_MANIFEST_TEMPLATE e o parâmetro manifest_template= foram removidos: existiam porque o algoritmo era desconhecido, e um template não consegue expressar a regra de omissão. Se você havia medido um manifesto diferente e passava o seu, compare com build_manifest e abra uma issue se ainda divergir.

parse_signature_header devolve um objeto, não uma tupla

from tempest_fastapi_sdk.integrations.payment.mercado_pago import parse_signature_header

# antes
# timestamp, digest = parse_signature_header(header)

# agora
parsed = parse_signature_header("ts=1771891200,v1=abc123")
parsed.timestamp        # "1771891200"
parsed.digest()         # "abc123" — a primeira versão suportada
parsed.hashes           # {"v1": "abc123"} — o header pode carregar v1 e v2

O que passou a existir

  • versions= em verify_signature, default ("v1",). Uma migração do provedor para v2 vira versions=("v2", "v1"), sem esperar release.
  • tolerance_seconds= (e now=, para teste), que é o que faz o ts do manifesto trabalhar contra replay. Continua opt-in, como no upstream.
  • Chave de header case-insensitive, valor só-espaço tratado como ausente, e ts não-numérico rejeitado como header malformado — três regras do upstream que faltavam.

0.234.0 — modelo gerado se constrói pelo nome Python, e o type-checker aceita

Nada muda em runtime. Muda o que o pyright aceita.

Construa pelo nome do campo, não pelo alias

Os campos com nome de fio passaram de Field(alias=...) para Field(validation_alias=..., serialization_alias=...). O runtime é o mesmo — os modelos já tinham populate_by_name=True, então as duas grafias sempre validaram. O que muda é o parâmetro que um type-checker enxerga:

from tempest_fastapi_sdk.integrations.payment.openpix import ChargePayload

# antes: rodava, mas o pyright acusava "No parameter named correlation_id"
# agora: aceito pelos dois
payload = ChargePayload(correlation_id="pedido-1", value=1990)

Se o seu código escrevia pelo alias só para calar o checker (ChargePayload(correlationID="pedido-1")), ele continua rodando — a validação aceita o alias — mas agora é o checker que reclama. Troque pelo nome Python.

Leitura e escrita não mudaram: model_validate({"correlationID": ...}) continua aceitando a grafia do fornecedor, e model_dump(by_alias=True) continua emitindo ela.

0.233.0 — dois enums gerados da OpenPix mudaram de nome

Uma mudança, e ela só quebra quem importava os dois enums de pagamento pelo nome.

PaymentType e PaymentDestinationAliasType foram renomeados

O gerador passou a emitir as variantes de PaymentCreatePayload (oneOf com quatro formas: Pix key, QR Code, Manual, Boleto), e são elas que agora registram esses enums primeiro. O nome passou a vir da variante:

Antes Agora
PaymentType PaymentCreatePayloadPixKeyType
PaymentDestinationAliasType PaymentCreatePayloadPixKeyDestinationAliasType

Mesmos membros, mesmos valores — só o nome da classe mudou:

from tempest_fastapi_sdk.integrations.payment.openpix import (
    PaymentCreatePayloadPixKeyType,
)

assert PaymentCreatePayloadPixKeyType.PIX_KEY.value == "PIX_KEY"

Se você comparava o valor em vez de importar a classe (payment.type == "PIX_KEY"), nada a fazer.

O que não quebrou

PaymentCreatePayload e PostApiV1PaymentBody continuam importáveis: viraram alias de união sobre as variantes, então uma anotação body: PostApiV1PaymentBody segue válida. O que mudou é que agora elas carregam os campos do pagamento — antes eram modelos sem propriedade nenhuma, e extra="ignore" descartava em silêncio tudo que você passasse.

0.229.0 — saída estruturada do Ollama sai de /api/generate para /api/chat

Uma mudança, e ela só quebra teste, não runtime.

generate_structured agora fala com /api/chat

OllamaGenerator.generate_structured postava em /api/generate com o schema no campo format. Isso está quebrado em modelo de raciocínio: contra o gpt-oss:20b, o daemon responde 200 OK com eval_count não-zero e response vazio, porque a resposta cai num canal que aquele endpoint não expõe. Em /api/chat o JSON vem em message.content, e modelo sem raciocínio se comporta igual nos dois.

Em runtime não há o que ajustar — a chamada que devolvia lixo (ou nada) passa a devolver a instância. O que quebra é teste com mock preso ao endpoint antigo:

import httpx
from pydantic import BaseModel

from tempest_fastapi_sdk.genai import OllamaGenerator
from tempest_fastapi_sdk.utils import HTTPClient


class Pessoa(BaseModel):
    nome: str


async def antes() -> Pessoa:
    """Mock que casava com /api/generate — para de casar."""

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json={"response": '{"nome": "Ana"}', "done": True})

    client = HTTPClient(transport=httpx.MockTransport(handler))
    gen = OllamaGenerator("llama3.2", http_client=client)
    return await gen.generate_structured("Uma pessoa.", Pessoa)


async def depois() -> Pessoa:
    """A resposta agora vem em message.content."""

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(
            200,
            json={"message": {"content": '{"nome": "Ana"}'}, "done": True},
        )

    client = HTTPClient(transport=httpx.MockTransport(handler))
    gen = OllamaGenerator("llama3.2", http_client=client)
    return await gen.generate_structured("Uma pessoa.", Pessoa)

Duas mudanças de comportamento acompanham:

  • Conteúdo vazio levanta ValueError em vez de devolver nada. Se você tinha try/except tratando resultado vazio como "modelo não respondeu", troque por except ValueError.
  • system= é um parâmetro novo, opcional. Use-o para a instrução quando o prompt for um documento longo: instrução colada acima do documento é ignorada — medido, 0 itens extraídos contra 20 com a instrução no turno system.

0.174.0 — erros que eram 500 viram 422, e o order_by é validado

Correções de robustez. Todas trocam um crash por uma resposta correta; nenhuma exige mudança de código, mas quatro mudam o status ou a exceção que o seu serviço vê.

Senha longa agora é 422

Existe um teto: AUTH_PASSWORD_MAX_BYTES, default 72 — o limite duro do bcrypt, contado em bytes UTF-8. Antes, senha acima disso levantava ValueError do hashpw e subia como 500 no signup / reset / troca. Agora é ValidationException (422).

Se o seu frontend não valida comprimento, ele passa a receber 422 onde recebia 500. Se você trocou o hasher por um sem esse limite, suba o valor.

order_by inválido agora é ValidationException

BaseRepository.paginate e cursor_paginate resolvem order_by pelo mapper do model. Nome que não é coluna mapeada levanta ValidationException (422) em vez de AttributeError (500).

Mudança de contrato em cursor_paginate: ele levantava ValueError nesse caso. Quem tinha except ValueError em volta precisa ajustar:

import asyncio

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from tempest_fastapi_sdk import BaseRepository
from tempest_fastapi_sdk.exceptions import ValidationException

from src.db.models import UserModel

# Num serviço, a sessão real vem de `db.get_session_context()`; aqui, do SQLite.
session = AsyncSession(create_async_engine("sqlite+aiosqlite:///:memory:"))

repo = BaseRepository(session, model=UserModel)


async def main() -> None:
    """Run this example."""
    try:
        page = await repo.cursor_paginate(order_by="nao_e_coluna")
    except ValidationException:
        ...


asyncio.run(main())

ValueError continua sendo o erro de cursor malformado.

BodySizeLimitMiddleware: body grande em streaming responde 413

O 413 passou a ser emitido no instante em que a contagem estoura, e o que o app enviar depois é descartado. Antes ele saía num finally, depois de o app já ter respondido — e o FastAPI responde, convertendo o ClientDisconnect do guard em 400. O segundo http.response.start fazia o uvicorn levantar RuntimeError: Response already started.

Efeito prático: um upload em streaming acima do limite responde 413 onde recentemente respondia 400 (com um RuntimeError no log). Um handler que não lê o body continua respondendo o que ele mesmo respondia — não há como retirar uma resposta já enviada.

Antes ela só devolvia o token, então o cookie ficava ausente e o POST seguinte caía com 403. Agora ela grava (Secure + SameSite=Lax, não HttpOnly — o cliente precisa ler pra ecoar no header).

Se você já gravava o cookie à mão no handler, o valor é o mesmo (request.state.csrf_token) e nada muda: a dependency não sobrescreve cookie existente. Em dev sobre HTTP puro, passe secure=False, senão o browser não devolve o cookie.

OAuthUser.email_verified

Campo novo (default None), nada quebra. Mas leia a nota: se você liga login social a conta existente pelo e-mail, exija profile.email_verified is True. No GitHub o valor é sempre None — o GET /user não traz campo de verificação, e o e-mail que ele devolve é o do perfil público, que o GitHub não exige verificar.

GET /logs lê no máximo 20 000 registros por arquivo

Ajuste com make_logs_router(max_records_per_file=...). São os mais recentes; o endpoint ordena do mais novo e pagina, então o que ficou fora não era alcançável. Um WARNING é logado quando o corte acontece.

0.173.0 — token só vale onde foi emitido pra valer, e cache não é mais compartilhado

Três correções de segurança mudam comportamento de default. Nenhuma exige mexer em código, mas vale conferir se você dependia do comportamento antigo.

Refresh e MFA-pendente deixam de autorizar rota

make_bearer_token_dependency, make_jwt_user_dependency, make_role_dependency, make_permission_dependency e UserAuthService.current_user_dependency() aceitam agora só token de tipo access.

Antes, os três JWTs que o UserAuthService emite com o mesmo segredo verificavam identicamente, então o refresh token e o mfa_token do passo 1 do login funcionavam como bearer em qualquer rota autenticada — o segundo fator era contornável com só a senha.

Você é afetado se de propósito mandava um refresh token para uma rota comum:

from tempest_fastapi_sdk import (
    JWTUtils,
    REFRESH_TOKEN_TYPE,
    make_bearer_token_dependency,
)

from src.core.settings import settings

tokens = JWTUtils(settings)


# Volta a aceitar aquele tipo naquela rota específica:
require_refresh = make_bearer_token_dependency(tokens, accepted_typ=(REFRESH_TOKEN_TYPE,))

Token assinado à mão com JWTUtils.encode() e sem typ continua aceito — a atualização não derruba sessão ativa. Só os marcadores que o próprio SDK estampava (refresh: True, purpose: "mfa_pending") passam a ser rejeitados como access.

ResponseCacheMiddleware: private por padrão, credencial não usa o store

Dois defaults mudaram:

  • O Cache-Control emitido passou de public, max-age=N para private, max-age=N. Se você servia conteúdo genuinamente compartilhado e contava com cache de CDN, declare de novo: cache_control="public, max-age=N".
  • Requisição com Authorization ou Cookie não lê nem escreve no store compartilhado (ETag/304 continuam). Para recuperar cache em rota autenticada, passe cache_credentialed=True — a credencial entra na chave, então cada chamador tem a sua entrada.

O header X-Cache também só aparece quando existe store=; antes vinha MISS mesmo no modo só-ETag.

IdempotencyMiddleware: chave escopada por chamador

A chave passou de (method, path, key) para (chamador, method, path, key), com o chamador vindo de um digest de Authorization/Cookie. Reuse da chave de outra pessoa não devolve mais a resposta dela.

Se o seu cliente troca de credencial entre o pedido original e o retry (rotação de token no meio do backoff), o retry deixa de bater na entrada anterior. Nesse caso aponte a identidade para algo estável:

from fastapi import FastAPI

from tempest_fastapi_sdk import IdempotencyMiddleware, MemoryIdempotencyStore

store = MemoryIdempotencyStore()

app = FastAPI()


app.add_middleware(
    IdempotencyMiddleware,
    store=store,
    principal_resolver=lambda request: request.headers.get("x-api-key-id", ""),
)

Também mudou: 5xx não é mais cacheado (cache_server_errors=True restaura), Set-Cookie fica fora da cópia guardada, e requisições concorrentes com a mesma chave no mesmo processo são serializadas.

0.138.1 — BaseAppSettings tem que ser a última base

A 0.138.1 passou a fazer todo mixin de settings herdar BaseAppSettings (antes eles estendiam pydantic_settings.BaseSettings cru). Isso conserta o .env deixando de ser carregado quando um mixin aparecia antes da base — o model_config canônico agora é materializado em cada mixin, independente da ordem.

Em troca, a ordem das bases deixou de ser estilo e virou regra dura: como os mixins são subclasses de BaseAppSettings, a linearização C3 do Python proíbe a base preceder a própria subclasse.

# docs-guard: skip — os dois primeiros exemplos são o erro que a seção descreve
# ❌ quebra em tempo de import

from tempest_fastapi_sdk import BaseAppSettings, DatabaseSettings, RedisSettings


class Settings(DatabaseSettings, BaseAppSettings, RedisSettings): ...

# ❌ também quebra
class Settings(BaseAppSettings, DatabaseSettings): ...

# ✅ BaseAppSettings por último
class Settings(DatabaseSettings, RedisSettings, BaseAppSettings): ...

Antes da 0.159.1 o sintoma era o TypeError cru do pydantic, que não indica a correção:

TypeError: Cannot create a consistent method resolution order (MRO) for bases BaseAppSettings, RedisSettings

e o mypy (com o plugin do pydantic) acusava duas vezes na mesma linha, sendo a segunda enganosa — sugere conflito de metaclasse quando a causa é só a posição de uma base:

settings.py:4: error: Cannot determine consistent method resolution order (MRO) for "Settings"  [misc]
settings.py:4: error: Metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases  [metaclass]

A partir da 0.159.1, BaseAppSettings usa a metaclasse AppSettingsMeta, que pré-checa a posição das bases e troca a mensagem por uma instrução:

TypeError: Settings: BaseAppSettings must be the LAST base — RedisSettings already subclasses it, so listing BaseAppSettings before it is an invalid method resolution order (MRO). Move BaseAppSettings to the end of the base list: class Settings(RedisSettings, BaseAppSettings).

Verifique

# procure Settings com BaseAppSettings fora do fim da lista de bases
grep -rn "class Settings(" -A 12 src/core/settings.py
  • Mova BaseAppSettings para o último item da lista de bases.
  • A ordem entre os mixins continua livre — só a posição da base importa.
  • Nenhuma mudança de env var, de campo ou de valor: é exclusivamente ordem de herança.

0.92.0 — coluna payload no token de usuário

A 0.92.0 adiciona o fluxo de troca / re-verificação / recuperação de e-mail. Para carregar o e-mail pendente até a confirmação, BaseUserTokenModel ganhou uma coluna nova:

from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column


payload: Mapped[str | None] = mapped_column(String(320), nullable=True, default=None)

Como sua tabela user_tokens herda de BaseUserTokenModel, a coluna aparece automaticamente no modelo — mas o banco precisa de uma migration. É aditiva e segura (coluna anulável, sem default obrigatório):

# gere e aplique
tempest db revision -m "add payload to user_tokens"
tempest db upgrade

Ou, na mão:

ALTER TABLE user_tokens ADD COLUMN payload VARCHAR(320) NULL;

Só isso

Nenhuma renomeação, nenhum default backfill. Fluxos existentes (ativação, reset de senha) continuam gravando payload = NULL. O novo fluxo de e-mail é totalmente opt-in — a recuperação (POST /auth/email-recovery/request) só é montada com AUTH_EMAIL_RECOVERY_ENABLED=True.

Verifique

  • Rode a migration antes de subir a 0.92.0 (a coluna precisa existir).
  • Se você escreve src/db/models/user_token.py à mão em vez de usar make_user_token_model, a coluna vem da base abstrata — não precisa redeclarar, só migrar.

0.63.0 — usuário autenticado carregado na sessão de request

Antes da 0.63.0, UserAuthService.current_user_dependency() carregava o usuário autenticado chamando load_user, que abria a própria sessão (via db.get_session_context()) e a fechava ao terminar. O UserModel entregue à rota ficava detached: mutá-lo e dar commit/refresh na sessão de request (a dos seus repositories) levantava InvalidRequestError: Instance is not persistent within this Session.

A partir da 0.63.0 a dependência carrega o usuário na sessão de request (db.session_dependency por padrão), via get_user(subject, session). O usuário fica anexado à mesma sessão que os repositories usam, então leituras de relacionamentos lazy e escritas funcionam sem reanexar nada.

Compatibilidade

A dependência de auth e seus repositories precisam compartilhar o mesmo callable de sessão para o cache de sub-dependências do FastAPI casar. Quem segue o padrão recomendado já está coberto:

# resources.py
get_session = db.session_dependency          # um único objeto, reutilizado

Se você embrulha a sessão num provider próprio (async def get_session(): ...), passe-o explicitamente para a dependência, senão ela abre uma segunda sessão e o usuário volta a ficar detached:

get_current_user = auth.current_user_dependency(session_dependency=get_session)

Defesa adicional

BaseRepository.resolve() agora reanexa instâncias detached via session.merge(). Mesmo que algum fluxo ainda receba um usuário detached, o resolve o traz de volta à sessão ativa em vez de quebrar — então serviços que faziam workarounds manuais (re-fetch por id antes de mutar) podem removê-los.

Verifique

  • Remova qualquer workaround do tipo "re-fetch por id antes de mutar o usuário autenticado" — não é mais necessário.
  • Se você passava um user_loader de um argumento para make_jwt_user_dependency, ele continua funcionando. Para compartilhar a sessão de request, passe session_dependency= e use um loader de dois argumentos (subject, session).

0.8.0 — renomeação de ServerSettings

A 0.8.0 renomeia todos os campos de ServerSettings, extrai os campos de log para um novo mixin LogSettings e adiciona onze outros primitivos. As renomeações são as únicas mudanças que quebram — todo primitivo novo é opt-in.

1. Renomeie as variáveis de ambiente

Antiga Nova Mixin
HOST SERVER_HOST ServerSettings
PORT SERVER_PORT ServerSettings
DEBUG SERVER_DEBUG ServerSettings
(nova) SERVER_RELOAD ServerSettings
LOG_LEVEL LOG_LEVEL movida para LogSettings
LOG_JSON LOG_JSON movida para LogSettings

sed mecânico em todo .env / docker-compose.yml / manifesto de deploy:

sed -i \
  -e 's/^HOST=/SERVER_HOST=/' \
  -e 's/^PORT=/SERVER_PORT=/' \
  -e 's/^DEBUG=/SERVER_DEBUG=/' \
  .env .env.example .env.test

LOG_LEVEL e LOG_JSON mantêm os nomes — só o mixin muda.

2. Renomeie as referências no código

# `settings.HOST` → `settings.SERVER_HOST`, idem para PORT/DEBUG
grep -rn "settings\.\(HOST\|PORT\|DEBUG\)\b" src/ tests/

Substitua cada ocorrência pela forma SERVER_*. Se um serviço usava a flag antiga settings.DEBUG para comportamento de debug a nível de aplicação, troque para settings.SERVER_DEBUG; se ela era lida apenas para o auto-reload do uvicorn, troque para settings.SERVER_RELOAD.

3. Misture LogSettings no Settings do projeto

 from tempest_fastapi_sdk import (
     BaseAppSettings,
     CORSSettings,
     DatabaseSettings,
     JWTSettings,
+    LogSettings,
     RabbitMQSettings,
     RedisSettings,
     ServerSettings,
 )


 class Settings(
     ServerSettings,
+    LogSettings,
     DatabaseSettings,
     RedisSettings,
     RabbitMQSettings,
     JWTSettings,
     CORSSettings,
     BaseAppSettings,
 ):
     ...

Pule este passo se o serviço nunca leu settings.LOG_LEVEL / settings.LOG_JSON — configure_logging aceita os valores diretamente como argumentos nomeados.

4. (Opcional) Adote os novos primitivos

Escolha o que se encaixa. Nenhum deles é obrigatório.

5. Verifique

uv sync                      # pega as novas deps do pyproject
uv run pytest -q             # suite completa
uv run ruff check src tests  # confirma que nenhuma referência a `HOST`/`PORT`/`DEBUG` escapou

Se o pytest falhar com um ValidationError do Pydantic referenciando HOST / PORT / DEBUG, alguma variável de ambiente não foi renomeada (olhe o ambiente do processo ou o .env).