Certificação Claude Developer Foundations · parte 7 de 20
Engenharia de software para aplicações com Claude
Como isolar a chamada ao modelo numa camada única do código, separar os erros que valem nova tentativa e manter testes sem depender da rede.
Publicada em · 30 min de leitura
Esta aula trata do código que chama o modelo. Quando você escreve um app que manda texto para o Claude e usa a resposta, o trecho que faz essa chamada costuma começar pequeno, num arquivo só, e cresce até que cada tela do sistema conhece detalhes da API: nome do modelo, formato da resposta, tipo de exceção, limite de tempo. Quando o modelo muda ou a API passa a responder de outro jeito, o estrago aparece em vinte lugares ao mesmo tempo.
A engenharia de software resolve esse problema com ferramentas que você já conhece de outros projetos: uma fronteira clara entre o seu código e o SDK, testes que não dependem da rede, erros classificados pelo que o app pode fazer com cada um, configuração fora do código e dependências que só mudam de propósito. Nada disso é específico de IA. O que muda é o custo do erro: uma chamada ao modelo leva segundos, custa dinheiro e pode devolver uma resposta que parece certa e não é.
Os exemplos usam Python e TypeScript. O código foi executado no ambiente de estudo, com os SDKs oficiais instalados, contra um servidor local que devolve respostas roteirizadas. Nenhuma chamada foi feita à API real, porque a chave não estava configurada. Isso fica explícito nas seções de teste e no fim da aula.
Mapa da aula
| Parte | Seções | Pergunta que responde |
|---|---|---|
| 1. Fundamentos | 1 a 3 | Como separar o cliente da API do resto do app, e como configurar por ambiente? |
| 2. Testes | 4 a 5 | Como testar sem chamar a API real, e o que é um teste de contrato? |
| 3. Erros e saída | 6 a 8 | Como classificar falhas, e como validar a resposta antes de usar? |
| 4. Dependências e operação | 9 a 10 | Como fixar o SDK, atualizar com controle e registrar logs sem vazar dados? |
| 5. Prática | 11 a 13 | Quais erros são comuns, o que fazer e como fixar o vocabulário? |
Pré-requisito: saber o que é a chamada messages.create e o que vem na resposta. Se isso não estiver claro, leia primeiro 1.3 Mecânica da API Claude.
Parte 1: fundamentos
1. O que o código que chama o modelo precisa resolver
Pense num app de suporte que classifica chamados. A primeira versão tem uma função que monta a mensagem, chama a API, lê o texto e devolve a categoria. Funciona. Na segunda semana, alguém quer usar a mesma classificação numa tela de relatórios, e copia a função. Na terceira, o time troca de modelo, e a função copiada continua com o nome antigo. Na quarta, um erro 429 aparece na tela do cliente como uma stack trace.
Esse é o padrão que a engenharia de software existe para evitar. O código de negócio quer saber se um chamado é de cobrança e qual a urgência. Ele não quer saber qual cabeçalho HTTP foi enviado, nem que a API devolve uma lista de blocos de conteúdo em vez de uma string. Quanto mais o código de negócio conhece a API, mais difícil fica mudar qualquer uma das duas coisas.
Três perguntas guiam o resto da aula. Onde fica a fronteira entre o seu código e o SDK? Como você sabe que a classificação está certa sem gastar chamadas? E o que acontece quando algo dá errado, do jeito que dá errado de verdade? As seções seguintes respondem uma de cada vez.
2. Uma camada única para o cliente
A solução mais direta é criar uma camada que é a única parte do app que importa o SDK. Ela expõe para o resto do código uma interface pequena, com um método só. No nosso exemplo, esse método recebe uma instrução de sistema e a mensagem do usuário, e devolve um objeto com o texto e alguns metadados.
Essa interface pequena é o ponto central da seção. Em Python, ela pode ser um Protocol. Em TypeScript, uma interface. O resto do app depende da interface, não do SDK. Isso tem três consequências práticas. A primeira é que trocar o SDK, ou a forma de autenticar, mexe num arquivo só. A segunda é que um teste pode colocar no lugar da camada qualquer objeto que cumpra a interface, sem tocar na rede. A terceira é que a lógica de negócio fica legível, porque não aparece nela nada que a API tenha inventado.
O arquivo abaixo é o da camada em Python. A classe TextModel é a interface, e AnthropicTextModel é a única implementação que fala com a API:
class TextModel(Protocol):
"""A interface pequena que o resto do app conhece. Um método."""
def complete(self, system: str, user: str) -> Completion: ...
class AnthropicTextModel:
"""Único arquivo do app que importa o SDK. Traduz as exceções dele para as nossas."""
def __init__(self, settings: Settings, client: Optional[anthropic.Anthropic] = None):
self._settings = settings
self._client = client or anthropic.Anthropic(
timeout=settings.timeout_s,
max_retries=settings.max_retries,
)
def complete(self, system: str, user: str) -> Completion:
try:
msg = self._client.messages.create(
model=self._settings.model,
max_tokens=self._settings.max_tokens,
system=system,
messages=[{"role": "user", "content": user}],
)
except anthropic.APIConnectionError as e:
# APITimeoutError herda de APIConnectionError, então cai aqui também.
raise NetworkError(type(e).__name__) from e
except anthropic.APIStatusError as e:
raise _traduz_status(e) from e
texto = "".join(bloco.text for bloco in msg.content if bloco.type == "text")
request_id = getattr(msg, "_request_id", None)
if msg.stop_reason == "refusal":
# Vem como HTTP 200. Sem tratar aqui, o erro passaria por texto vazio.
raise ContentError("modelo recusou responder", request_id=request_id)
return Completion(
text=texto,
stop_reason=msg.stop_reason,
input_tokens=msg.usage.input_tokens,
output_tokens=msg.usage.output_tokens,
request_id=request_id,
)
Repare em dois detalhes. O construtor aceita um client opcional. Em produção, ele fica vazio e a classe cria o cliente com as configurações. Nos testes, quem cria o cliente é o teste, apontando para um servidor local. Isso é injeção de dependência em tamanho pequeno, e é o que torna a seção 4 possível.
O segundo detalhe é o try em volta da chamada. Só ali o SDK pode lançar exceções. Tudo o que vem depois trabalha com a resposta já pronta, e nenhum erro do SDK escapa para o resto do app. Quem chama complete recebe um Completion ou um dos tipos de ModelError, e nada mais.
Algumas bibliotecas são tentadoras de usar em todo lugar, e vale a pena ser disciplinado com isso. Se o import anthropic aparece em três arquivos, a fronteira já foi quebrada, mesmo que cada arquivo pareça pequeno.
A figura mostra a camada única como uma peça só. O código de negócio fala com a interface do modelo, e só o gateway conhece o SDK. Os testes trocam o gateway por um modelo falso ou por um transporte gravado, sem mudar o código que está sendo testado.
3. Configuração por ambiente
Modelo, limite de tokens, tempo de espera, número de repetições e chave de API mudam entre ambientes. O modelo que você usa no teste rápido não é o da produção, e o limite de custo do desenvolvedor não é o do servidor. Por isso nenhum desses valores deve estar escrito no código. A regra prática é que o código lê o ambiente, valida o que recebeu e falha cedo se algo faltar.
A classe Settings concentra essa leitura:
@dataclass(frozen=True)
class Settings:
"""Tudo o que varia entre ambientes. Nenhum valor fixo no código."""
model: str
max_tokens: int
timeout_s: float
max_retries: int
@classmethod
def from_env(cls, env: Mapping[str, str]) -> "Settings":
def obrigatoria(nome: str) -> str:
valor = env.get(nome, "").strip()
if not valor:
# Falha no startup, com o nome da variável. Melhor que falhar na primeira chamada.
raise RuntimeError(f"variável de ambiente {nome} não definida")
return valor
if "ANTHROPIC_API_KEY" not in env or not env["ANTHROPIC_API_KEY"].strip():
raise RuntimeError("variável de ambiente ANTHROPIC_API_KEY não definida")
return cls(
model=obrigatoria("APP_MODEL"),
max_tokens=int(obrigatoria("APP_MAX_TOKENS")),
timeout_s=float(obrigatoria("APP_TIMEOUT_S")),
max_retries=int(obrigatoria("APP_MAX_RETRIES")),
)
O porquê da validação no startup é simples. Um valor faltando que só explode na primeira chamada aparece no meio de uma requisição de usuário, longe de quem configurou o ambiente. Um RuntimeError com o nome da variável aparece no deploy, no momento em que ainda dá para corrigir.
A chave de API merece um cuidado extra. O SDK lê ANTHROPIC_API_KEY do ambiente quando você não passa a chave no construtor. A documentação oficial do SDK Python diz que esse é o comportamento padrão. A chave nunca deve aparecer em código-fonte, em exemplo copiado para o repositório ou em log. A seção 10 volta a esse ponto.
Há mais uma variável útil. Segundo a documentação do SDK Python, ANTHROPIC_BASE_URL define o endereço da API quando o construtor não recebe base_url. Testamos o comportamento nos dois SDKs: com a variável definida, o cliente criado sem argumentos usou o endereço dela. É essa variável que permite apontar um ambiente de homologação para um gateway interno, sem mudar código.
Dois valores vão além da configuração: o ID do modelo e os limites de tokens. Os IDs de modelo mudam ao longo do tempo, e a documentação de modelos lista os que estão disponíveis em cada momento. Por isso o ID fica em APP_MODEL, e não num if espalhado pelo código. Voltaremos a isso na seção 9, porque trocar o modelo é uma mudança de contrato e não só de configuração.
Parte 2: testes
4. Testar sem chamar a API real
Chamar a API real num teste tem três problemas. Custa dinheiro a cada execução, depende de rede e do estado do serviço naquele minuto, e não é reproduzível, porque a mesma pergunta pode devolver outra resposta. Um teste que depende da internet para passar acaba virando pedido de favor a um servidor de terceiros. Por isso o time precisa de uma forma de testar a lógica sem a API e de uma forma separada, e bem marcada, de testar a integração.
A saída é usar três camadas de teste, cada uma com um tipo diferente de substituição. A figura mostra o que é real e o que é controlado em cada uma.
A primeira camada testa a lógica de negócio com um objeto falso que cumpre a interface TextModel. Ele devolve um texto pronto e nada mais. Esse é o mock mais simples possível, e é ótimo para verificar se a validação da saída rejeita o que deveria rejeitar. Na prática, o teste da triagem prova que um JSON com categoria inventada vira OutputError, sem nenhuma chamada.
class FakeModel:
"""Implementa a interface TextModel devolvendo um texto pronto."""
def __init__(self, texto: str, stop_reason: str = "end_turn"):
self.texto = texto
self.stop_reason = stop_reason
def complete(self, system: str, user: str) -> Completion:
return Completion(self.texto, self.stop_reason, 10, 5, "req_fake")
A segunda camada testa o gateway, a classe que fala com o SDK. Aqui o objetivo é verificar se o SDK real faz o que esperamos diante de cada resposta: repetir um 500, desistir de um 401, esgotar as tentativas de um 429. Para isso, subimos um servidor HTTP local que devolve uma sequência roteirizada de respostas, e apontamos o cliente para ele com base_url. O SDK de verdade roda, com retentativas e timeout reais. Só o servidor é falso, e ele conta quantas requisições recebeu.
Esse servidor é um pouco mais que um stub, e não é difícil de montar. O código abaixo é o núcleo dele em Python:
class Stub:
"""roteiro: lista de (status, cabeçalhos, corpo, atraso_em_segundos), consumida em ordem."""
def __init__(self, roteiro):
self.roteiro = list(roteiro)
self.hits = 0
self._servidor = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
self._servidor.stub = self
self.url = f"http://127.0.0.1:{self._servidor.server_address[1]}"
threading.Thread(target=self._servidor.serve_forever, daemon=True).start()
O teste do 429 mostra o padrão. O servidor devolve três respostas de limite, e a asserção confirma que o SDK fez exatamente três tentativas (uma inicial e duas repetições automáticas) antes de levantar LimitError:
def test_429_esgota_as_tentativas_e_vira_limit_error():
rl = {"retry-after": "0", "request-id": "req_rl_01"}
stub = Stub([(429, rl, erro("rate_limit_error", "limite"), 0)] * 3)
try:
modelo = AnthropicTextModel(settings(max_retries=2), cliente_para(stub.url))
with pytest.raises(LimitError) as info:
modelo.complete("s", "u")
assert stub.hits == 3 # 1 tentativa + 2 repetições
assert info.value.status == 429
assert info.value.request_id == "req_rl_01"
finally:
stub.fechar()
A terceira camada é o teste de contrato, descrito na seção 5. Ela confere a forma da resposta, não a lógica.
Um aviso sobre a biblioteca de HTTP. O SDK Python usa httpx2, que a documentação descreve como um fork compatível do httpx. Isso importa para quem tenta mockar a rede com ferramentas que patcheiam o httpx, como o respx ou o pytest-httpx. Segundo a documentação, essas ferramentas não enxergam as requisições do SDK por padrão, e é preciso chamar httpx2.alias_httpx() antes de qualquer importação. O servidor local da aula evita o problema, porque o SDK faz uma requisição HTTP de verdade, para um endereço que você controla.
Os testes Python passaram com o SDK instalado, e o resultado foi este:
.s............... [100%]
16 passed, 1 skipped in 9.14s
O teste ignorado é o de contrato ao vivo, que só roda com uma chave real. Não rodamos esse teste porque a chave não estava configurada, e isso está declarado nas fontes e no fim da aula.
5. Teste de contrato e respostas gravadas
Um teste de contrato verifica que o formato que o seu código lê continua existindo. Ele não pergunta se a classificação está certa. Pergunta se os campos que você usa estão lá, com o tipo esperado. Se a API ou o SDK mudarem um nome de campo, o teste de contrato quebra, e a quebra aparece no CI, não em produção.
A forma mais útil de montar um contrato é com respostas gravadas. A prática é esta: uma vez, com uma chave real, você faz algumas chamadas representativas, salva a resposta JSON como fixture, remove o que for sensível e passa a reproduzir essa resposta nos testes. A fixture vira a foto de um comportamento real, e os testes que a usam continuam rodando sem rede. Quando o formato muda, você regrava a fixture, e o diff mostra exatamente o que mudou.
Nesta aula, as fixtures não foram gravadas da API real. Não tínhamos chave. Elas foram escritas à mão a partir do formato documentado de Message, e isso é uma limitação: uma fixture escrita à mão só prova que o seu código lê o que você acha que a API manda. O teste abaixo valida a fixture contra o modelo Message do próprio SDK, que é uma verificação útil, mas não substitui uma gravação real.
def test_forma_da_resposta_que_o_gateway_le():
msg = Message.model_validate(mensagem("{}"))
# Os campos que AnthropicTextModel.complete() lê:
assert msg.content[0].type == "text"
assert msg.stop_reason == "end_turn"
assert msg.usage.input_tokens == 42
assert msg.usage.output_tokens == 17
O teste ao vivo fica marcado para rodar só quando houver chave. Ele faz uma chamada mínima e confere se o stop_reason é um dos valores esperados. Ele não entra no CI por padrão, porque custa uma chamada e depende da rede. Esse é o lugar certo para descobrir que a API mudou um comportamento antes que os usuários descubram.
Os valores de stop_reason que o contrato precisa conhecer estão na documentação e no próprio SDK. A página de motivos de parada lista sete valores: end_turn, max_tokens, stop_sequence, tool_use, pause_turn, refusal e model_context_window_exceeded. A definição de tipo do SDK Python traz a mesma lista, e comparar as duas é um bom teste de contrato em si mesmo. Se um deles aparecer com um valor novo, o seu código precisa decidir o que fazer.
Parte 3: erros e saída
6. Quatro tipos de erro
Erro, num app que chama modelo, se divide em várias categorias. Uma falha de rede e um limite de taxa têm causas diferentes e exigem reações diferentes. Quem trata tudo como “erro do modelo” acaba repetindo requisições que nunca vão dar certo, ou desistindo de requisições que bastava repetir. Por isso a camada da seção 2 traduz as exceções do SDK para quatro categorias, cada uma definida pelo que o app pode fazer com ela.
A categoria de rede cobre as falhas em que nenhuma resposta utilizável chegou: a conexão caiu, o tempo esgotou ou o servidor respondeu com 5xx ou 529. Aqui o app pode repetir mais tarde, com cuidado, e a seção 7 explica o cuidado. A categoria de limite cobre o 429, que significa que você bateu numa cota ou taxa. Repetir depois de uma espera pode funcionar. A categoria de conteúdo cobre o que foi recusado por causa da entrada ou da própria resposta: um 400 de requisição malformada, um 413 de corpo grande demais, ou um stop_reason igual a refusal. Repetir a mesma entrada não resolve. A categoria de configuração cobre 401, 402, 403 e 404, que indicam chave inválida, cobrança pendente, permissão negada ou recurso inexistente. Repetir também não resolve, e o caminho certo é alertar alguém.
Falta uma quinta categoria, que não vem do SDK: a saída que chegou mas não serve. Ela é tratada na seção 8, porque acontece depois da chamada, quando o texto já foi recebido. A figura abaixo mostra como uma falha percorre as decisões até virar uma das categorias.
A figura tem duas regras de leitura. A primeira é que a ordem importa: primeiro se chegou resposta, depois qual o status, e só se a resposta for 200 é que faz sentido olhar o stop_reason. A segunda é que o 200 também pode ser erro, como acontece com refusal. Um app que só olha exceções do SDK deixa passar essa falha, porque ela não lança exceção nenhuma.
A tradução em Python fica numa função pequena, e a regra mais importante está no fim, onde tudo o que não foi classificado cai em rede:
_CONFIG_STATUS = {401, 402, 403, 404}
def _traduz_status(e: anthropic.APIStatusError) -> ModelError:
# A mensagem do SDK pode trazer trechos do servidor. Por isso só repassamos o tipo e o status.
request_id = e.response.headers.get("request-id")
status = e.status_code
nome = type(e).__name__
if isinstance(e, anthropic.RateLimitError):
return LimitError(nome, request_id=request_id, status=status)
if status in _CONFIG_STATUS:
return ConfigError(nome, request_id=request_id, status=status)
if status in (400, 413):
return ContentError(nome, request_id=request_id, status=status)
# 408, 409, 5xx e 529: falha do lado de lá, a mesma categoria de rede para o app.
return NetworkError(nome, request_id=request_id, status=status)
Esse “o que sobra vira rede” é uma escolha, e vale defendê-la. A categoria de rede, neste desenho, quer dizer “não houve resposta utilizável e a falha é transitória”. Um 409 de conflito se encaixa mal aí, porque a documentação manda resolver o conflito antes de repetir. Ainda assim o app trata como transitório, e o log registra o status exato, então quem investiga vê a causa real. Se o seu app precisa de uma categoria separada para conflito, crie uma. O importante é que a decisão esteja escrita num lugar só.
A documentação de erros traz um caso que merece atenção. Um 429 pode vir de uma cota de gasto do mês atingida, e não de um limite passageiro. Nesse caso a resposta não traz retry-after e continua falhando até que o acesso seja liberado. Repetir com espera não ajuda. Um app que trata todo 429 como transitório vai repetir para sempre uma falha que nenhuma espera resolve. O tratamento correto inclui um limite de tentativas e um alerta, e não só uma repetição infinita.
Um erro que não aparece nessa tabela merece uma nota. Um nome de modelo digitado errado costuma voltar como 404. A documentação de erros não cita esse caso pelo nome, então confirme o comportamento na sua conta antes de contar com ele. Se ele for de fato 404, a classificação como configuração é a certa, porque repetir não corrige um nome errado.
7. Retentativas do SDK e o que sobra para você
O SDK repete algumas falhas sozinho, e você precisa saber exatamente quais. Segundo a documentação oficial, o padrão é repetir duas vezes, com espera exponencial curta. São retentadas as falhas de conexão, 408, 409, 429 e os 5xx. Os valores padrão estão no próprio SDK Python: duas repetições, espera inicial de 0,5 segundo e teto de 8 segundos. Se a resposta traz retry-after, o SDK respeita esse tempo. O timeout padrão é de 10 minutos, e você pode mudar tudo isso no construtor ou por requisição.
A figura mostra a sequência que o teste da seção 4 produziu: uma tentativa, uma espera, outra tentativa, e assim por diante, até que o SDK desista e levante a exceção.
Repare no que a figura não mostra. O SDK não sabe se a sua chamada pode ser repetida sem efeito colateral. Ele repete a requisição porque ela falhou por um motivo transitório, e não porque conhece o significado do seu pedido. Se o pedido for “classifique este chamado”, repetir é inofensivo. Se for “crie um pedido de reembolso”, uma repetição depois de um timeout pode criar dois reembolsos, caso a primeira tentativa tenha chegado ao servidor antes de cair a conexão.
Essa distinção é a ponte para o conceito de idempotência. Uma operação é idempotente quando executá-la duas vezes produz o mesmo resultado que executá-la uma vez. Classificar um texto é idempotente na prática, porque não altera nada do lado de fora. Criar um registro com efeito externo não é, a menos que você passe uma chave de idempotência ou verifique antes se o registro já existe. A nota Idempotência aprofunda isso, e a regra prática para esta aula é só esta: a repetição automática do SDK cuida da falha de transporte, e a decisão de repetir a operação inteira é sua.
Quando as duas repetições automáticas não bastam, o app decide o próximo passo, e há três caminhos comuns. Um é colocar o pedido numa fila para tentar mais tarde, o que só faz sentido para operações idempotentes. Outro é usar um caminho de contingência, como um modelo menor ou uma resposta padrão marcada como degradada. O terceiro é devolver o erro ao usuário com uma mensagem que diga o que ele pode fazer. Qualquer um dos três é melhor do que deixar a exceção subir até a tela.
8. Tipar e validar a saída antes de usar
Até aqui tratamos das falhas que o SDK percebe. A mais traiçoeira é a que ele não percebe: a resposta chegou com status 200, sem erro nenhum, e o texto não é o que o seu código precisa. Pode ser um JSON cortado porque max_tokens acabou no meio. Pode ser um JSON válido com uma categoria que você não previu. Pode ser prosa educada (“Claro! Aqui está a classificação”) no lugar do objeto pedido. Se o código faz json.loads e usa o dicionário sem checar, o erro aparece dez passos depois, num lugar onde ninguém associa a causa ao modelo.
A regra é validar a saída na fronteira, antes de qualquer uso, e tratar a falha como um tipo próprio. Em Python, Pydantic faz isso bem. O esquema descreve o contrato, e model_validate_json rejeita o que não cumpre o contrato:
class Triagem(BaseModel):
"""O contrato da saída. extra=forbid rejeita chaves que o prompt não pediu."""
model_config = ConfigDict(extra="forbid")
categoria: Literal["cobranca", "bug", "duvida", "outro"]
urgencia: int = Field(ge=1, le=3)
def triar(modelo: TextModel, chamado: str) -> Triagem:
resposta = modelo.complete(SYSTEM, chamado)
if resposta.stop_reason == "max_tokens":
# JSON cortado no meio nunca passa pelo parser. Checar antes deixa a causa clara.
raise OutputError("resposta cortada pelo limite de tokens", request_id=resposta.request_id)
try:
return Triagem.model_validate_json(resposta.text)
except ValidationError as e:
raise OutputError(
f"saída fora do contrato ({e.error_count()} erro(s))",
request_id=resposta.request_id,
) from e
Há três decisões nesse código que valem ser explicadas. A primeira é o extra="forbid". Sem ele, uma chave extra que o modelo inventou passa em silêncio, e o seu código ignora uma informação que talvez fosse importante. Com ele, a chave extra vira erro e aparece no log. A segunda é checar max_tokens antes de tentar o parser. Um JSON cortado produz uma mensagem de erro de parsing que não diz a causa, e a checagem prévia diz exatamente o que aconteceu. A terceira é que o OutputError leva o request_id. Quando você abrir um chamado com o suporte, ou procurar a requisição nos logs, o identificador é o que liga a falha à chamada.
No TypeScript, o equivalente usa Zod. O contrato é um z.object com .strict(), e safeParse devolve sucesso ou a lista de problemas. A mesma função triar existe lá com a mesma ordem de checagens. Os testes em TypeScript cobrem os quatro casos fora do contrato e o JSON cortado, e passaram.
Um detalhe que costuma passar despercebido: um enum no esquema também é contrato. Se amanhã você adicionar uma categoria reembolso ao prompt e esquecer de atualizar o esquema, a saída nova vira OutputError em produção. Por isso a lista de categorias deve estar num lugar só, e ser usada tanto no prompt quanto no esquema.
Parte 4: dependências e operação
9. Versão do SDK, fixação e atualização controlada
Um SDK novo pode mudar o comportamento do seu app de três formas: trocando um nome de classe ou de parâmetro, mudando um padrão (como o de retentativas ou de timeout), ou trocando a biblioteca de HTTP por baixo. A documentação do SDK Python diz que o pacote segue versionamento semântico, mas admite que algumas mudanças incompatíveis podem sair em versões menores. Isso não justifica medo de atualizar, mas justifica um procedimento antes de cada atualização.
Fixar a versão significa escrever no arquivo de dependências a versão exata que foi testada, e não um intervalo aberto. Em Python, o arquivo requirements.txt gerado durante a aula ficou assim:
anthropic==1.15.0
httpx2==2.13.1
pydantic==2.14.1
pydantic_core==2.50.1
pytest==9.1.1
Repare numa linha que ninguém escreveu. O httpx2 entrou porque o anthropic depende dele. É uma dependência transitiva, e é justamente o tipo de coisa que muda sem você perceber numa atualização do SDK. Por isso o arquivo de lock precisa cobrir também as dependências transitivas, não só as que você importa. No TypeScript, o mesmo papel é feito pelo package-lock.json, que registra a versão exata da árvore inteira, e a versão do SDK usada aqui foi a 0.135.0.
A atualização controlada é um processo com etapas, e a figura mostra as principais:
A primeira etapa muda só o lockfile. Assim, se algo quebrar, o diff é pequeno e a causa é fácil de achar. Em seguida vêm os testes com o stub, que rodam sem rede e em todo pull request. Se passarem, o contrato ao vivo confere uma chamada real. Depois, um conjunto fixo de chamados é classificado com a versão antiga e com a nova, e as duas saídas são comparadas. Se a nova piorar, a versão anterior fica, e o motivo é registrado para ninguém precisar redescobrir o problema.
O último ponto é o modelo. Trocar o ID do modelo é tão perigoso quanto trocar o SDK, e às vezes mais. A documentação de erros lista vários exemplos de requisições que antes funcionavam e agora retornam 400. Modelos mais novos não aceitam o preenchimento de resposta do assistente (o chamado prefill), que retorna um erro 400 invalid_request_error informando que a conversa deve terminar com uma mensagem do usuário. Alguns modelos também não aceitam tool_choice forçado, com os tipos any ou tool. E o modo de raciocínio estendido mudou de configuração em modelos recentes. Por isso a troca de modelo passa pela mesma etapa de avaliação, e não só pela alteração de APP_MODEL.
10. Logs úteis sem vazar dados sensíveis
Log serve para responder perguntas depois do fato. Quantas falhas de limite houve hoje? Qual requisição deu erro às três da tarde? Quantos tokens o endpoint de triagem gastou esta semana? Para responder isso, o log precisa de metadados: tipo da falha, status, identificador da requisição, modelo, contagem de tokens e motivo de parada. Esses campos não revelam nada sobre o usuário.
O texto do chamado é outra história. Ele pode ter nome, CPF, número de cartão, endereço e conteúdo de conversa. Se entrar no log, o log passa a ser um repositório de dados pessoais com regras de acesso frouxas, e isso vale mesmo para o log de desenvolvimento, que alguém copia para um ticket. A figura mostra a fronteira: o que sai do processo para a API é necessário, mas o que vai para o log precisa ser filtrado.
O código de observabilidade da aula tem uma regra simples: cada função recebe um objeto de domínio e escolhe explicitamente os campos que registra. Não há um log.info(chamado) em lugar nenhum.
def log_ok(c: Completion) -> None:
# Metadados apenas. Texto do chamado e texto da resposta ficam de fora.
log.info(
"modelo respondeu request_id=%s stop_reason=%s input_tokens=%d output_tokens=%d",
c.request_id, c.stop_reason, c.input_tokens, c.output_tokens,
)
def log_erro(e: ModelError) -> None:
log.warning(
"falha do modelo tipo=%s status=%s request_id=%s",
type(e).__name__, e.status, e.request_id,
)
Para verificar a regra, o demo da aula usa um chamado com CPF e número de cartão fictícios. Depois de rodar todos os cenários, contamos quantas linhas de log contêm esses dados. O resultado foi zero. Esse tipo de teste vale a pena virar teste automatizado, porque a regra costuma quebrar quando alguém adiciona um print de depuração na pressa.
Duas outras fontes de vazamento merecem atenção. A primeira é a mensagem da exceção. Uma APIStatusError carrega a mensagem que o servidor mandou, e ela pode ecoar partes da entrada. Por isso a tradução da seção 6 repassa só o nome do tipo e o status, e não o texto da exceção. A segunda é o nível de log do próprio SDK. A documentação do SDK TypeScript avisa que o nível debug registra todas as requisições e respostas, com cabeçalhos e corpos, e que dados sensíveis nos corpos ainda podem aparecer. O SDK Python tem a variável ANTHROPIC_LOG, com os níveis debug e info. Não encontramos na documentação uma descrição do que cada nível mostra no Python, então trate o debug com o mesmo cuidado: ele não deve estar ligado em produção.
Por último, a chave de API. Ela não entra em log, não entra em mensagem de erro e não entra no código. O lugar dela é o cofre de segredos do ambiente de execução, exposto como variável. Se uma chave aparecer num log, trate como vazada e troque imediatamente.
Parte 5: prática
11. Erros comuns e boas práticas
Os erros abaixo aparecem em quase todo projeto que chama modelo pela primeira vez. Cada um tem uma correção pequena e um motivo que vale lembrar.
O primeiro é espalhar o SDK pelo app. Ele parece inofensivo no começo, porque a chamada é só uma linha. O custo aparece quando a API muda, e o tamanho da mudança é proporcional a quantos arquivos importam o SDK. A correção é a camada da seção 2, com um único arquivo que conhece a biblioteca.
O segundo é tratar todo erro igual, geralmente com um except Exception que loga e segue. Isso mistura uma falha que vai se resolver sozinha com uma que nunca vai, e o app ou repete o que não devia, ou desiste do que bastava esperar. A correção é classificar em categorias com decisões associadas, como na seção 6.
O terceiro é confiar no json.loads e no texto que vem do modelo. A saída é entrada não confiável, e deve ser validada como qualquer dado externo. A correção é o esquema da seção 8, com extra="forbid" e checagem de max_tokens.
O quarto é mockar a rede com uma ferramenta que não enxerga o SDK, e concluir que os testes provam alguma coisa. Se a ferramenta de mock não intercepta as requisições reais, o teste passa e a integração quebra. A correção é testar contra um servidor que o SDK realmente alcança, como fizemos na seção 4, ou seguir a orientação da documentação sobre httpx2.
O quinto é deixar o modelo como constante no código. Parece uma simplificação, e vira um problema no dia em que o modelo sai de uso ou um ambiente precisa de outro. A correção é a configuração da seção 3.
O sexto é atualizar o SDK sem teste. Uma versão menor pode mudar um padrão que o seu app depende. A correção é o processo da seção 9, com o lockfile versionado.
O sétimo é logar o conteúdo do chamado para facilitar a depuração. Funciona no começo e vira um incidente de privacidade depois. A correção é o log com campos escolhidos, da seção 10.
Há também algumas boas práticas que valem como hábito. Escolha o nome do campo de cada erro pensando no que o app faz com ele, não no nome da exceção. Guarde o request_id de toda falha. Escreva o contrato de saída antes do prompt, porque ele diz ao prompt o que deve ser produzido. E mantenha uma lista pequena de chamados representativos, com a saída esperada, para rodar sempre que o prompt, o modelo ou o SDK mudar.
12. Exercícios
Estes exercícios usam o código da aula. Você pode fazê-los no projeto Python ou no TypeScript.
Primeiro, adicione uma categoria de erro para conflito (409) e faça a tradução separar esse caso da rede. Escreva o teste antes da mudança, usando o stub com um 409 na primeira resposta e um 200 na segunda. Pergunte a si mesmo se o SDK repete ou não repete o 409 por padrão, e confirme na documentação antes de escrever a asserção.
Segundo, crie um teste que confirme que nenhuma linha de log contém o texto do chamado, mesmo quando a resposta do modelo ecoa o chamado inteiro. Use um FakeModel que devolve o próprio texto de entrada.
Terceiro, escreva um teste que prove que a falha de max_tokens não passa pelo parser. Use um texto de JSON cortado e confirme que o erro é OutputError com a mensagem sobre o limite, e não sobre o parsing.
Quarto, rode o gateway contra o stub com um atraso de 2 segundos e um timeout de 0,5 segundo, e depois com max_retries=2. Conte quantas requisições o stub recebe em cada caso e explique a diferença com base nos valores padrão da seção 7.
Quinto, descreva por escrito, em um parágrafo, o procedimento de atualização do SDK para o seu time. Liste quais testes rodam em cada etapa, quem aprova a troca do lockfile e o que fazer se a comparação de saídas piorar.
13. Glossário
Camada de integração: o único trecho do app que conhece o SDK e traduz suas exceções para os tipos do domínio.
Interface: o contrato mínimo que o resto do app usa, com os métodos e os tipos de entrada e saída. Em Python, um Protocol. Em TypeScript, uma interface.
Mock: objeto que substitui uma dependência real e devolve respostas controladas. Um FakeModel é um mock da camada de modelo.
Stub: servidor ou objeto que devolve respostas roteirizadas, sem lógica própria. O servidor local da aula é um stub HTTP.
Fixture: dado de teste fixo, geralmente um JSON de resposta, que representa um comportamento conhecido.
Teste de contrato: teste que verifica se a forma da resposta que o código lê continua existindo, com os campos e tipos esperados.
Retentativa: nova tentativa de uma requisição que falhou por motivo transitório. O SDK faz algumas automaticamente.
Backoff: espera que cresce a cada tentativa, para não sobrecarregar um serviço que já está em dificuldade.
Idempotência: propriedade de uma operação que produz o mesmo efeito quando executada uma ou várias vezes. Veja Idempotência.
Stop reason: motivo pelo qual o modelo parou de gerar a resposta, como end_turn, max_tokens ou refusal.
Esquema: descrição formal da forma de um dado, usada para validar entrada e saída.
Lockfile: arquivo que registra a versão exata de cada dependência, inclusive as transitivas, para que a instalação seja reproduzível.
Dependência transitiva: biblioteca que não aparece no seu arquivo de dependências, mas é instalada porque outra depende dela.
Dado sensível: informação pessoal ou segredo, como CPF, cartão, endereço, senha ou chave de API, que não deve aparecer em log.
Fontes
Consultadas em 18 de outubro de 2026.
- Python SDK, documentação oficial da Anthropic: instalação, cliente, variáveis de ambiente, exceções, retentativas, timeouts,
with_options,httpx2ealias_httpx. - TypeScript SDK, documentação oficial da Anthropic: instalação, opções
maxRetriesetimeout, classes de erro,withResponsee níveis de log. - Erros da API Claude, documentação oficial: tabela de status HTTP, tipos de erro, cota de gasto que devolve 429,
request-ide exemplos de requisições recusadas por modelo. - Motivos de parada (stop reasons), documentação oficial: lista dos valores de
stop_reasone o tratamento derefusalemax_tokens. - Repositórios anthropic-sdk-python e anthropic-sdk-typescript: os READMEs foram lidos, mas são curtos e remetem à documentação acima.
- Código instalado:
anthropic1.15.0 no Python (constantes de retentativa, nomes de exceção, leitura deANTHROPIC_BASE_URLeANTHROPIC_API_KEY, e a definição de tipo deStopReason) e@anthropic-ai/sdk0.135.0 no TypeScript. Essas verificações foram feitas no código, e não só na documentação.
O que não foi verificado: nenhuma chamada foi feita à API real, então o comportamento de 404 para nome de modelo inexistente e o de cotas de gasto foram tirados da documentação, sem teste ao vivo. O SDK TypeScript não declara _request_id no tipo Message da versão usada, por isso o código lê o identificador com withResponse(). O comportamento exato de ANTHROPIC_LOG=debug no Python não está descrito na documentação lida.