JuliusBrussee/caveman · error

cave_breaker_retry_backoff_invalid

cave_breaker_retry_backoff_invalid

Error message

cave_breaker_retry_backoff_invalid

What it means

normalizeRunBreakers validates retry.backoffMs, defaulting to DEFAULT_RETRY_BACKOFF_MS when omitted. The value must be a non-negative safe integer of milliseconds; backoff drives the deterministic exponential delay between retries, so fractional, negative, non-integer, or out-of-safe-integer values are rejected with this sentinel error.

Solutions

  1. Set backoffMs to a whole, non-negative millisecond count (e.g. 500).
  2. Omit backoffMs entirely to use DEFAULT_RETRY_BACKOFF_MS.
  3. Convert seconds/config strings to integer ms with Number.parseInt before passing them in.

Example fix

// before
retry: { maxSpend: 0.5, backoffMs: 0.5 }
// after
retry: { maxSpend: 0.5, backoffMs: 500 }
Defensive patterns

Strategy: validation

Validate before calling

function validBackoffMs(v) { return v === undefined || (Number.isSafeInteger(v) && v >= 0); }
if (breakers.retry && !validBackoffMs(breakers.retry.backoffMs)) throw new Error('cave_breaker_retry_backoff_invalid: need safe integer ms >= 0');

Type guard

const isMsDuration = (v: unknown): v is number => Number.isSafeInteger(v) && v >= 0;

Try / catch

try { normalizeRunBreakers(cfg); } catch (e) {
  if (e.message === 'cave_breaker_retry_backoff_invalid') { cfg.breakers.retry.backoffMs = Math.max(0, Math.round(Number(cfg.breakers.retry.backoffMs))); }
  else throw e;
}

Prevention

When it happens

Trigger: Passing `breakers.retry.backoffMs` as a negative number, a float (e.g. 1.5), a non-numeric string coerced to NaN, or a value beyond Number.MAX_SAFE_INTEGER.

Common situations: Specifying backoff in seconds instead of milliseconds (0.5); computing backoff with arithmetic that yields NaN from a bad config string; copy-pasting durations with units ('500ms' as a string).

Understand the failure class

Background: "invalid duration" / "failed to parse duration": why your timeout, interval, or TTL string is rejected and which formats each library accepts — this error's family across 32 libraries.

Related errors


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

Appendix: source

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

    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,
  });
}

/** One breaker decision, recorded on the receipt so a break is never silent. */
export interface BreakerEvent {
  readonly kind: "loop_detected" | "no_progress" | "fan_out_blocked" | "retry_attempted" | "retry_exhausted";
  readonly tool: string | undefined;
  /** Repeats for a loop, identical turns for no-progress, blocked calls for fan-out, attempt number for a retry. */
  readonly count: number;
  /** The call hash for a loop break, so the offending window is identifiable. */

View on GitHub (pinned to 3ee70a1026)