JuliusBrussee/caveman · error

cave_breaker_retry_requires_budget

cave_breaker_retry_requires_budget

Error message

cave_breaker_retry_requires_budget

What it means

normalizeRunBreakers validates run breaker configuration. A `retry` breaker inherently spends more budget (repeated model calls), so it is only meaningful when a budget is configured. If `breakers.retry` is provided but no budget (hasBudget) exists on the run, the library throws this sentinel error to prevent an unbounded retry loop with no spending cap.

Solutions

  1. Add a budget to the run config (maxUsd or maxTokens) alongside the retry breaker.
  2. Remove the `retry` breaker if you intentionally want an uncapped run.
  3. Ensure the budget is passed to the same normalize/validate call so hasBudget is true.

Example fix

// before
breakers({ retry: { maxSpend: 0.5 } })
// after
breakers({ budget: { maxUsd: 2 }, retry: { maxSpend: 0.5 } })
Defensive patterns

Strategy: validation

Validate before calling

function assertRetryNeedsBudget(breakers) {
  if (breakers?.retry && (breakers.budget === undefined || (breakers.budget.maxUsd === undefined && breakers.budget.maxTokens === undefined))) {
    throw new Error('cave_breaker_retry_requires_budget: retry requires a budget');
  }
}

Try / catch

try { normalizeRunBreakers(cfg); } catch (e) {
  if (e.message === 'cave_breaker_retry_requires_budget') {
    cfg = { ...cfg, budget: { maxUsd: cfg.budget?.maxUsd ?? DEFAULT_BUDGET_USD } };
  } else throw e;
}

Prevention

When it happens

Trigger: Passing a breakers object with `retry: { maxSpend, backoffMs, ... }` set while the run configuration has no budget (no maxUsd/maxTokens) — e.g. calling breakers normalization with retry options on a run lacking normalizeRunBudget.

Common situations: Developers copy a retry-tuning snippet from docs but forget the budget section; an existing config gains retry settings while budget stays unset; a CLI/UI lets users tune retries independently of the spend cap.

Understand the failure class

Background: Conflicting config options: "cannot be used together" — configuration validation errors across open-source libraries — this error's family across 162 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/1c94bb9c1dc6f5fe. Report an issue: GitHub.

Appendix: source

Thrown at packages/agent/src/breakers.ts:89

  hasBudget: boolean,
): NormalizedBreakers {
  const repeatedToolCalls = breakers.repeatedToolCalls ?? DEFAULT_REPEATED_TOOL_CALLS;
  const repeatedToolCallWindowTurns = breakers.repeatedToolCallWindowTurns ??
    DEFAULT_REPEATED_TOOL_CALL_WINDOW_TURNS;
  const noProgressTurns = breakers.noProgressTurns ?? DEFAULT_NO_PROGRESS_TURNS;
  const maxToolCallsPerTurn = breakers.maxToolCallsPerTurn ?? DEFAULT_MAX_TOOL_CALLS_PER_TURN;
  for (const value of [
    repeatedToolCalls,
    repeatedToolCallWindowTurns,
    noProgressTurns,
    maxToolCallsPerTurn,
  ]) {
    if (!Number.isSafeInteger(value) || value <= 0) {
      throw new Error("cave_breaker_threshold_invalid");
    }
  }
  if (breakers.retry !== undefined && !hasBudget) {
    throw new Error("cave_breaker_retry_requires_budget");
  }
  if (breakers.retry !== undefined &&
      (!Number.isFinite(breakers.retry.maxSpend) || breakers.retry.maxSpend <= 0)) {
    throw new Error("cave_breaker_retry_spend_invalid");
  }
  const retryBackoffMs = breakers.retry?.backoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
  if (!Number.isSafeInteger(retryBackoffMs) || retryBackoffMs < 0) {
    throw new Error("cave_breaker_retry_backoff_invalid");
  }
  return Object.freeze({
    repeatedToolCalls,
    repeatedToolCallWindowTurns,
    noProgressTurns,
    maxToolCallsPerTurn,
    retryMaxSpend: breakers.retry?.maxSpend,
    retryBackoffMs,
  });
}

View on GitHub (pinned to 3ee70a1026)