Curso / Lição 14  ·  EN
Lição 14 · Motor & método · 1 de 8

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 — flui por uma única forma de função: uma chamada assíncrona que nunca lança exceção e retorna um valor discriminado por ok. É o invariante mais importante do código. O pacote de fusão que você conheceu nas lições 7–13 se conecta a esta cintura; entendê-la é entender por que uma rede instável ou um 429 não derruba uma run. Fonte: packages/contracts/src/model.ts + packages/adapters/src/adapter-core.ts, governado pela ADR-0009.

A forma: ModelRunResult

O contrato é uma união discriminada por um campo literal ok. Não há throw no tipo — a falha é um valor, não um evento de fluxo de controle:

// 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]);

Quem chama escreve if (result.ok) e o TypeScript estreita o tipo. Crucialmente, o braço de falha carrega um booleano retryable — o ponto de chamada não precisa adivinhar se um 429 vale uma nova tentativa; o resultado já diz. A própria interface documenta a lei: run(input): Promise<ModelRunResult> // NEVER throws (invariant).

Como a garantia é imposta, não apenas documentada

Um comentário dizendo "nunca lança" é inútil se um adapter esquecer. Então o invariante é estrutural: todo adapter implementa apenas um attempt() interno, e uma única espinha compartilhada — runWithGuards — o envolve com quatro camadas, em ordem:

runWithGuards(adapterId, attempt, input) ① validação Zod do input na fronteira — input ruim ⇒ ModelRunFailure (nunca chega ao attempt) ④ try/catch externo ao redor de withRetry — "rede de segurança final para preservar o invariante" ③ withRetry — re-executa enquanto result.error.retryable, com backoff ② guardedAttempt — try/catch + feedback do circuit-breaker attempt(input) — a única coisa que cada adapter escreve um throw em QUALQUER profundidade vira um ModelRunFailure tipado via failureFromThrown — a união é total
// 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); // ④
  }
};

A camada ② é onde o circuit-breaker aprende: guardedAttempt chama breaker?.recordSuccess() num resultado ok e breaker?.recordFailure() caso contrário, e reporta retry: true apenas quando result.error.retryable (adapter-core.ts:102-110). O catch externo em ④ é logicamente inalcançável — existe apenas para que a promessa em nível de tipo ("nunca lança") se sustente mesmo se uma refatoração futura quebrar uma suposição interna. Isso é defesa em profundidade na costura mais crítica do sistema.

Por que uma união em vez de exceções? Exceções são invisíveis no tipo de uma função — você não consegue dizer por run(): Promise<X> o que ela pode lançar, então quem chama ou super-captura ou esquece. Uma união discriminada torna todo caminho de falha visível e exaustivo: o compilador força você a tratar ok: false. Num sistema que distribui um pedido a dezenas de modelos em paralelo, essa é a diferença entre "o 500 de um provedor degrada aquela faixa" e "o 500 de um provedor derruba a run".

O irmão: Result<T, E> para todo o resto

Uma segunda união discriminada, mais leve — também por ok, com braços value/error — existe para trabalho falível não-modelo: IO de arquivo, parsing, wrapper de subprocesso. Ela espelha deliberadamente 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.

1. O attempt() interno de um adapter lança um TypeError cru no fundo do corpo. O que quem chamou run() observa?
Correto: b. O throw é capturado na camada ② e convertido num failure tipado; até o catch externo logicamente inalcançável em ④ o converteria. A união é total — run() resolve para um ModelRunResult, nunca rejeita.
2. Por que ModelRunFailure carrega um retryable: boolean em vez de deixar quem chama decidir?
Correto: c. guardedAttempt retorna retry: true exatamente quando result.error.retryable vale, e withRetry faz backoff por essa flag. Classificar a retryability na origem mantém a política uniforme e evita divergência entre pontos de chamada.
3. O try/catch externo em runWithGuards é descrito como "uma rede de segurança final". Por que manter código logicamente inalcançável?
Correto: d. withRetry só rejeita se guardedAttempt rejeitar, o que nunca acontece — então hoje o catch externo não dispara. Ele fica porque o custo é um try e o prejuízo de quebrar o invariante é catastrófico. O comentário no código diz exatamente isso.

Confusões comuns

"Nunca-lança significa que erros são engolidos." Não — erros são expostos, só que como valores em vez de exceções. Uma falha é um ModelRunFailure totalmente tipado com code, message e flag retryable; nada fica oculto. O que se elimina é o salto invisível de fluxo de controle, não a informação.
"As duas uniões são redundantes." Não — ModelRunResult é o contrato rico de chamada de modelo (usage, custo, duração, id do adapter); Result<T, E> é o contrato mínimo de operação falível. Compartilham o discriminante ok de propósito, para que os pontos de chamada se leiam igual, mas carregam payloads diferentes para trabalhos diferentes.