A cintura estreita: um run() que nunca lança
Toda invocação de modelo no Alembic — todo adapter, todo membro de council, todo worker do swarm, toda camada do funil — passa por uma única forma de função: uma chamada assíncrona que nunca lança exceção e devolve um valor discriminado por ok. É o invariante mais importante do código. O pacote de fusão das lições 7–13 se pluga nesta cintura; entendê-la é entender por que uma rede instável ou um 429 não conseguem derrubar uma run.
Esta lição destila o contrato (model.ts) e a espinha que o impõe (adapter-core.ts), lidos linha a linha. Por que importa pra missão: é a costura por onde flui cada chamada de modelo da fusão — se ela rejeitar, qualquer lane derruba a run inteira; se nunca rejeitar, uma falha vira só um valor a tratar.
- Distinguir lançar de nunca-lançar e por que a falha vira um valor, não um salto de fluxo.
- Ler o
ModelRunResultcomo união discriminada poroke como o compilador estreita o tipo. - Seguir as cinco etapas de
runWithGuards(Zod → try/catch → breaker → retry → rede final). - Decidir se um erro é
retryablepelo catálogo deErrorCode— e por que isso fica na origem.
throw no tipo de run()01 · Por que uma chamada de modelo nunca lança
Comece pela intuição. Uma exceção é como um alarme de incêndio: ela interrompe tudo e sobe pela pilha procurando alguém que a apague (try/catch). Quem chama uma função não consegue ver, pelo tipo da função, quais alarmes ela pode disparar — então ou super-captura tudo "por via das dúvidas", ou esquece e o alarme derruba o prédio inteiro. Um valor de falha é o oposto: em vez de soar um alarme, a função te entrega um bilhete dizendo "falhei, eis o porquê". Você lê o bilhete na hora que quiser, e o tipo da função te obriga a olhar.
packages/contracts/src/model.ts, a interface diz, textual: /** Run a single completion. NEVER throws — see invariant above. */. E o bloco acima do método: "INVARIANT: ModelAdapter.run NEVER throws. Any error — network, timeout, quota, adapter bug — MUST be returned as a ModelRunFailure." Quem chama, portanto, nunca precisa de try/catch em volta de run: só ramifica em result.ok.Há uma segunda metade nessa promessa que é fácil de ignorar: run é assíncrona, e toda Promise tem dois desfechos — resolve (entrega um valor) ou rejeita (estoura como uma exceção, que quem chama precisa pegar com .catch / try/await). O invariante elimina o segundo desfecho de propósito: a promessa de run sempre resolve, e o valor resolvido carrega o sucesso ou a falha. Ela nunca rejeita.
Por que "cintura": muitos chamadores, uma forma só
O nome vem do desenho de uma ampulheta. Em cima, muitos chamadores diferentes (adapters de cada provedor, membros de council, workers do swarm, camadas do funil). Embaixo, muitos backends. No meio, uma única forma estreita por onde tudo passa — run(input): Promise<ModelRunResult>. Aperte a cintura num único contrato e o sistema inteiro herda a mesma garantia de uma vez.
02 · A forma: ModelRunResult
O contrato é uma união discriminada por um campo literal ok. "União" porque o valor é um de dois formatos; "discriminada" porque há um campo (ok) cujo valor literal — true ou false — diz qual dos dois você tem. Não existe throw no tipo: a falha é um braço da união, não um evento de fluxo de controle.
ok// packages/contracts/src/model.ts — os dois braços (condensado) type ModelRunSuccess = { // ok: literal(true) é o discriminante ok: true; adapterId: string; durationMs: number; modelId: string; text: string; usage?: TokenUsage; costUsd?: number; raw?: unknown; }; type ModelRunFailure = { // ok: literal(false) ok: false; adapterId: string; durationMs: number; modelId: string; error: { code: string; message: string; retryable: boolean }; }; type ModelRunResult = z.discriminatedUnion('ok', [Success, Failure]);
Snippet condensado do real (no arquivo, ambos os braços também carregam requestId e o Success ainda tem modelVersionUsed? — ver o diagrama acima). O ponto que importa: o braço de falha carrega um booleano retryable — quem chama não precisa adivinhar se um 429 vale nova tentativa; o resultado já diz. A interface documenta a lei: run(input): Promise<ModelRunResult> // NEVER throws (invariant).
throw aparece no tipo de retorno de run()? E o que isso implica para quem chama?
Promise<ModelRunResult> — e ModelRunResult é só Success | Failure. Como nenhuma exceção aparece no tipo, quem chama nunca precisa de try/catch; ele ramifica em result.ok. Se você imaginou "depende de cada adapter", caiu na armadilha que o contrato existe para matar: a exceção seria invisível no tipo — por isso a falha foi movida para dentro do valor, onde o compilador a enxerga.03 · O compilador estreita o tipo por você
A grande vantagem prática da união discriminada: ao testar if (result.ok), o TypeScript estreita automaticamente o tipo dentro de cada ramo. No then, result é só ModelRunSuccess (tem .text); no else, é só ModelRunFailure (tem .error). Acessar .error no ramo de sucesso é erro de compilação — o tipo te impede de ler o campo errado.
ok abre dois caminhos, cada um com seus camposcatch contamina a faixa toda; com a união, cada lane devolve seu Success ou Failure e o agregador apenas transforma valores. É a diferença entre "o 500 de um provedor degrada aquela faixa" e "o 500 de um provedor derruba a run".04 · runWithGuards: a espinha que impõe o invariante
Um comentário dizendo "nunca lança" é inútil se um adapter esquecer. Então o invariante é estrutural: cada adapter implementa apenas um attempt() interno — uma única tentativa de rede/subprocesso — e uma espinha compartilhada, runWithGuards, o envolve. O comentário no topo do arquivo é explícito: "tudo transversal vive aqui para que todos os seis adapters se comportem de forma idêntica". São cinco etapas, em ordem:
// packages/adapters/src/adapter-core.ts:118-147 — a espinha canônica (condensado) export const runWithGuards = async (adapterId, attempt, input, runtime = {}) => { const validation = validate(adapterId, input); // ① Zod na fronteira if (!validation.ok) return validation.result; // input ruim ⇒ Failure, não throw try { return await withRetry( // ③ backoff em resultados retryable () => guardedAttempt(adapterId, attempt, input, runtime.breaker, logger), // ② policy, { clock, logger, random: runtime.random, signal: input.signal }, ); } catch (cause) { // withRetry só rejeita se guardedAttempt rejeitar, o que nunca acontece; // esta é uma rede de segurança final para preservar o invariante. return failureFromThrown({ adapterId, input, durationMs: 0 }, cause); // ④ } };
As quatro guardas em prosa: ① validação Zod do input na fronteira (input ruim já vira Failure, sem nunca tocar o attempt); ② o guardedAttempt com try/catch + feedback do circuit-breaker; ③ o withRetry que re-executa enquanto o resultado for retryable; ④ o try/catch externo — a "rede de segurança final". E a 5ª etapa é o attempt(input) em si, a única que cada adapter escreve.
attempt). O runWithGuards é o supervisor experiente que cerca essa tentativa: confere o pedido antes (Zod), segura a mão num provedor doente (breaker), tenta de novo quando faz sentido (retry) e, se tudo der errado de um jeito impensado, ainda transforma o tropeço num bilhete de falha em vez de deixar o alarme tocar. O estagiário muda; o supervisor é sempre o mesmo.withRetry só rejeita se guardedAttempt rejeitar, e guardedAttempt já captura qualquer throw internamente e o converte via failureFromThrown. O catch externo permanece como defesa em profundidade: custa um try e protege a promessa mais crítica do sistema contra uma refatoração futura que quebre uma suposição interna. O comentário no código diz isto literalmente (linhas 143–144).A mesma espinha, vista como camadas concêntricas
Outra forma de ler: cada guarda envolve a anterior. Um throw em qualquer profundidade é convertido num ModelRunFailure tipado antes de escapar — a união é total.
05 · guardedAttempt por dentro — onde o breaker aprende
A camada ② é o coração operacional. Ela faz quatro coisas, nesta ordem: (1) consulta o circuit-breaker — se ele estiver aberto, devolve uma rejeição retryable sem nem tentar; (2) executa o attempt dentro de um try/catch (qualquer throw vira failureFromThrown — defesa em profundidade); (3) alimenta o breaker (recordSuccess num ok, recordFailure caso contrário); (4) só pede retry: true quando result.error.retryable.
Direto do adapter-core.ts: guardedAttempt chama breaker?.recordSuccess() num resultado ok e breaker?.recordFailure() caso contrário, e reporta retry: true apenas quando result.error.retryable (linhas 102–110). O comentário no catch interno é literal: "Defense in depth: an attempt should not throw, but if it does we honor the never-throws invariant here." (linhas 98–99).
O laço de realimentação: cada tentativa ensina o breaker
Repare que a etapa ② não só consulta o breaker — ela o alimenta de volta. canExecute() decide se a chamada passa; depois, recordSuccess()/recordFailure() atualizam o contador interno que, na próxima chamada, muda a resposta de canExecute(). É um ciclo fechado: o resultado de hoje é a porta de amanhã.
06 · O catálogo de erros — quem decide retryable, e por quê
De onde vem o booleano retryable? Não de um chute em cada ponto de chamada — de um catálogo único, ErrorCode, em packages/adapters/src/errors.ts. Cada código nasce classificado como retryable ou não; o makeError deriva a flag do código (a menos que seja sobrescrita). O adapter, que conhece o provedor, classifica uma vez na origem — e toda a política fica uniforme.
Comparativo direto: os mesmos erros, dois grupos. Verde = transitório, vale repetir; laranja = definitivo, repetir só queima cota.
| Situação | ErrorCode | retryable | Por quê |
|---|---|---|---|
| HTTP 429 (cota) | rate_limited | sim | Transitório — esperar e repetir costuma passar |
| HTTP ≥ 500 | server_error | sim | Falha do upstream, não do pedido |
| HTTP ≥ 400 (≠ 429) | client_error | não | Pedido malformado/auth — repetir não muda nada |
| Corpo ilegível | parse_error | não | A resposta não casa a forma esperada |
| Token ausente/expirado | auth_error | não | Credencial é problema de configuração |
A classificação de HTTP é uma função pura: classifyHttpStatus — 429 → rate_limited, ≥500 → server_error, ≥400 → client_error (errors.ts:64–69). O conjunto retryable é fixado por política em RETRYABLE_CODES (errors.ts:48–54).
07 · withRetry: backoff exponencial com jitter (faça a conta)
Quando o resultado é retryable, a camada ③ espera antes de tentar de novo — e a espera cresce a cada tentativa, para não martelar um provedor já doente. A fórmula é min(base × 2^tentativa, max), e então aplica-se full jitter (um fator aleatório em [0,1]) para espalhar os clientes no tempo. A política padrão: maxAttempts: 3, baseDelayMs: 200, maxDelayMs: 2000.
base = 200 ms, max = 2000 ms, maxAttempts = 3 (logo, até 2 esperas entre 3 tentativas).min(200 × 2^0, 2000) = min(200, 2000) = 200 ms (teto).min(200 × 2^1, 2000) = min(400, 2000) = 400 ms (teto).floor(teto × rand), rand ∈ [0,1) — então cada espera fica entre 0 e o teto. Espalhar evita que 1000 clientes repitam no mesmo milissegundo.base = 200 e max = 2000, qual seria o teto da espera na tentativa 4 → 5 (índice 4)? Faça antes de revelar.
min(200 × 2^4, 2000) = min(3200, 2000) = 2000 ms — o maxDelayMs entra em ação: a curva exponencial é aparada em 2 s. (Na política padrão você nem chega à tentativa 4, pois maxAttempts = 3; mas a fórmula é a mesma para qualquer política.)Antes do gráfico de tetos, veja a sequência no tempo: a política padrão dá maxAttempts: 3, logo até 2 esperas entre 3 tentativas. O loop só repete enquanto o resultado pede retry:true; quando o orçamento acaba, withRetry devolve o último valor produzido — mesmo que ele ainda pedisse retry (linhas 88–100).
Mexa os sliders e veja a curva de backoff (o teto por tentativa) crescer e ser aparada pelo maxDelayMs:
As barras mostram o teto de cada espera (min(base×2^i, max)); a espera real é esse teto × um jitter em [0,1). A linha tracejada é o maxDelayMs — onde a exponencial para de crescer.
withRetry aguarda o clock.sleep injetado — um relógio falso pode adiantá-lo nos testes, mas em produção o backoff genuinamente decorre. E ele nunca rejeita: se o sleep for abortado no meio, o erro é engolido e a próxima tentativa observa o mesmo signal, registrando o abort como uma falha tipada (retry.ts:108–118). Preservar o invariante nunca-lança vale até para a pausa entre tentativas.08 · O circuit-breaker — três estados que poupam um upstream doente
A camada ② consulta um disjuntor (circuit-breaker). A analogia é o disjuntor elétrico: depois de muitas faíscas seguidas, ele desarma e corta a corrente por um tempo, em vez de deixar a casa pegar fogo a cada tentativa. Aqui: depois de failureThreshold falhas consecutivas (padrão 5), ele abre e curto-circuita as chamadas por um cooldownMs (padrão 30 s); depois admite uma tentativa de teste.
halfOpenMaxCalls, padrão 1); o primeiro sucesso fecha, qualquer falha reabre na hora e reinicia o cooldown. Assim uma rajada não atropela um provedor que mal voltou.guardedAttempt devolve circuit_open com retry: true — então o withRetry espera o backoff, que dá tempo do cooldown passar. O breaker e o retry são duas peças que se conversam: uma corta, a outra espera.Clock injetado; não faz IO e nunca lança (circuit-breaker.ts:20–22). É a mesma disciplina da cintura: o tempo entra por uma porta (o Clock), então um relógio falso testa transições de 30 s em microssegundos.09 · O irmão leve: Result<T, E>
Existe uma segunda união discriminada, mais leve — também por ok, mas com braços value/error — para trabalho falível não-modelo: IO de arquivo, parsing, wrapper de subprocesso. Ela espelha de propósito a cintura de modelo, então ambas se leem igual nos pontos de chamada.
// packages/contracts/src/result.ts — o irmão leve type Result<T, E = Error> = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E }; // helpers: ok(v), err(e), isOk, isErr, mapResult, tryCatch, tryCatchAsync
Este é o Result<T, Error> que você viu retornado por todo subsistema do @alembic/hermes nas lições 7–13 — load(), transcribe(), webSearch() todos o retornam. A regra do repositório é explícita: "código de biblioteca retorna Result em vez de lançar" (CLAUDE.md). Duas uniões, uma filosofia: falibilidade é um valor, validado na fronteira, nunca uma surpresa.
10 · Como isso se encaixa
Você acabou de ler a cintura de perto. Agora afaste a câmera e veja onde ela mora no aparelho inteiro. A cintura não é um subsistema isolado — é a junta por onde passa toda chamada de modelo do Alembic. Acima dela, os adapters (um por provedor) só escrevem o attempt(); abaixo, o motor — council, swarm, funil, gates, ciclo de aprendizado — consome o ModelRunResult como um valor a ramificar, nunca como um alarme a apagar.
Clique Percorrer o fluxo: acenda cada estágio na ordem em que uma chamada o atravessa — dos adapters, pela cintura, até o motor e a invariante que sustenta tudo.
As peças que esta cintura conecta
- Lição 05 · Ports & injeção — define o port
ModelAdapterque cada provedor implementa; a cintura é a forma desse port, e a injeção é como o motor recebe o adapter sem conhecer o provedor. - Lição 16 · As quatro invariantes — "uma chamada de modelo nunca lança" é a invariante nº 1; esta lição é a sua implementação concreta, e o VM/teste a defendem.
- Lição 17 · A pipeline de gates — o Proof Gate e o Validator Gate disparam chamadas que descem por esta cintura; um
Failurevira uma falha de gate tratável, não um crash do run. - Lição 27 · Tiers, custo & orçamento — o braço
Successcarregausage/costUsd; é desse campo que o orçamento por tier lê o custo de cada chamada. A cintura é a fonte da telemetria de custo. - Lição 15 · O funil — cada camada T0→T3 do funil é só mais um chamador da cintura; é por isso que um 429 numa camada degrada aquela faixa em vez de derrubar a destilação inteira.
11 · Na prática
A cintura é um contrato de tipo — você não a "roda" com um comando próprio. O jeito honesto de exercitá-la é rodar a suíte que prova o invariante (a adapter-core.test.ts verifica, no boundary real, que um throw, uma rejeição assíncrona e um input inválido viram todos um ModelRunResult com ok:false — nunca uma exceção) e, depois, lembrar que todo alembic run roteia cada chamada de modelo por ela.
# 1 · a suíte que prova o invariante da cintura (boundary real) # o tipo ModelRunResult vive em @alembic/contracts/src/model.ts; # a espinha runWithGuards que o impõe é exercitada em @alembic/adapters. # vitest aceita um filtro por nome de arquivo — rode só a suíte da cintura: pnpm -w test adapter-core # saída esperada (resumida): # ✓ src/adapter-core.test.ts (N) # ✓ runWithGuards never-throws invariant # ✓ a sync throw inside attempt() resolves to a Failure # ✓ an async rejection resolves to a Failure # ✓ invalid input fails Zod and never reaches attempt() # Test Files 1 passed # 2 · o baseline completo do repositório (o que todo PR precisa manter verde) pnpm -r typecheck && pnpm -r build && pnpm -w test # 3 · ver a cintura "viva": qualquer run roteia CADA chamada de modelo por ela. # offline = hermético, $0 — o adapter offline também devolve ModelRunResult. alembic run --goal GOAL.md --plan alembic.plan.ts --yes
Nota honesta sobre o comando: @alembic/contracts (onde ModelRunResult é declarado) é um pacote só-de-tipos — não tem script test próprio, então pnpm --filter @alembic/contracts test não roda. Quem exercita o invariante é a adapter-core.test.ts em @alembic/adapters; por isso usamos o filtro por nome de arquivo do vitest na raiz (pnpm -w test adapter-core) — ele casa o arquivo onde quer que esteja. [uncertain] o nome exato e a contagem de cada caso de teste podem variar entre versões; rode o comando para ver a lista corrente — a estrutura ("never-throws invariant") é estável.
cd /Users/acf/Documents/Projects/appfy/alembic (ou o caminho do seu clone) e instale uma vez: pnpm install.pnpm -w test adapter-core. Procure a linha runWithGuards never-throws invariant — é a prova de que um throw vira um ModelRunResult, não um crash.packages/contracts/src/model.ts e ache o comentário NEVER throws — see invariant above no método run; depois packages/adapters/src/adapter-core.ts (a função runWithGuards, ~linha 118) para ver as cinco etapas que você estudou.GOAL.md + alembic.plan.ts, rode alembic run --goal GOAL.md --plan alembic.plan.ts --yes. Cada chamada de modelo que aparecer nos eventos passou por run() — inspecione o run-dir em <dataDir>/runs/<run-id> (alembic runs list lista os ids).pnpm --filter @alembic/contracts test? Pense antes de revelar.
@alembic/contracts não define um script test — o pnpm erra com "No projects matched ... a script named test". O contrato é provado de fora, em @alembic/adapters: o tipo declara a lei, e a suíte do adapter a verifica no boundary. Por isso o comando certo é o filtro por arquivo na raiz.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
ModelRunResult?ok: ModelRunSuccess (ok:true, text…) ou ModelRunFailure (ok:false, error{code,message,retryable}). Sem throw no tipo.runWithGuards?retryable?ErrorCode na origem. 429/5xx/network/timeout/circuit_open = retryable; 4xx/parse/auth/subprocess = não. makeError deriva a flag do código.Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As opções têm tamanho parecido de propósito — sem pista pela forma.
attempt() interno de um adapter lança um TypeError cru no fundo do corpo. O que quem chamou run() observa?catch externo logicamente inalcançável em ④ o converteria. a é exatamente o que o invariante existe para impedir — run() resolve, nunca rejeita. c inventa um sucesso: um throw vira falha, não sucesso vazio. d ignora o orçamento finito: maxAttempts limita as tentativas, e só erros retryable repetem.ModelRunFailure carrega um retryable: boolean em vez de deixar quem chama decidir?guardedAttempt retorna retry: true exatamente quando result.error.retryable vale, e withRetry faz backoff por essa flag. a confunde propósito com tamanho — o campo carrega decisão, não volume. b é falso: a flag dirige o retry, não só o log. d inverte a causa: o Zod valida a forma porque ela existe; ela existe para classificar a retryability na origem e manter a política uniforme.try/catch externo em runWithGuards é descrito como "uma rede de segurança final". Por que manter código logicamente inalcançável?withRetry só rejeita se guardedAttempt rejeitar, o que nunca acontece — então hoje o catch externo não dispara. a erra a ordem: a validação Zod é a etapa ① e já retorna Failure antes do try. b não faz sentido — um try não acelera nada. c é falso: o retry acontece dentro do withRetry, não pelo catch externo. O comentário no código diz exatamente d.401 Unauthorized (token expirado). O withRetry vai tentar de novo?classifyHttpStatus manda 4xx (≠429) para client_error, e credenciais ausentes/expiradas são auth_error — ambos não-retryable, então guardedAttempt devolve retry: false e o resultado é terminal. b generaliza errado: só 429/5xx/network/timeout/circuit_open são retryable. c ignora o orçamento finito do withRetry. d confunde papéis: o breaker conta falhas, mas é a flag retryable (não o breaker) que decide repetir.catch externo se ele nunca dispara?", "Como o breaker e o retry se coordenam no caso 429?", "Por que duas uniões em vez de uma só?". É só dizer.