agentic examples

The Retry Budget

The Retry Budget: a retry storm is a self-inflicted DDoS - every job politely retrying 3x turns one outage into four. Retries are a SHARED resource, so give the fleet one wallet: transient failures spend from it, hopeless failures spend nothing (they get no retry at all), and when the wallet is empty the kindest thing left is failing fast - the fleet already knows the upstream is down.

Reliability & Recovery Round 10 Mike Perham exit 0

source on github

bundle exec ruby examples/retry_budget.rb

a real captured run

RETRY BUDGET (12 jobs, upstream dead, max 3 retries each)

  strategy A - every job for itself:
    45 calls fired at a host that was down for all of them.
    11 transient jobs x (1 + 3 retries) + 1 auth job x 1 = 45:
    the outage was 1 incident; the fleet made it 45 requests.

  strategy B - one wallet of 5 retries for the whole fleet:
    17 calls total: 12 first attempts + 5 budgeted retries.
    10 jobs failed FAST once the wallet emptied - no call, no
    timeout, no bill. the auth job spent nothing from the wallet:
    hopeless failures don't get retries, so they can't drain the
    budget the transient ones might still need.

  the fleet cut 28 pointless requests (45 -> 17) and lost nothing -
  every retry was doomed anyway. per-job retry policies answer
  "should I try again?"; the budget answers "should ANYONE?" -
  round 9's breaker asked that per-upstream, this asks it per-
  window, and both read the same journaled verdicts. retries are
  a shared resource. give them a wallet, not a habit. (and the
  wallet is now a real windowed RateLimit - try_acquire says no
  without making anyone wait for it, which is the entire point.)

source

# frozen_string_literal: true

# The Retry Budget: a retry storm is a self-inflicted DDoS - every
# job politely retrying 3x turns one outage into four. Retries are a
# SHARED resource, so give the fleet one wallet: transient failures
# spend from it, hopeless failures spend nothing (they get no retry
# at all), and when the wallet is empty the kindest thing left is
# failing fast - the fleet already knows the upstream is down.
#
#   bundle exec ruby examples/retry_budget.rb
#
# Runs offline; the upstream is dead for the whole run.

require class="s">"bundler/setup"
require class="s">"agentic"
require class="s">"tmpdir"

Agentic.logger.level = class="y">:fatal

MAX_RETRIES = 3
JOBS = 12

# The wallet is a windowed RateLimit asked politely: try_acquire
# (round 11) answers false RIGHT NOW instead of making the caller
# wait for capacity - and waiting for retry capacity during an
# outage would just be the storm with extra steps. The custom
# budget class this example shipped with has retired.
class RetryBudget
  def initialize(allowance, per: 60)
    @wallet = Agentic:class="y">:RateLimit.new(allowance, per: per)
    @spent = 0
  end

  attr_reader class="y">:spent

  def spend?
    admitted = @wallet.try_acquire
    @spent += 1 if admitted
    admitted
  end
end

def run_job(name, error, journal)
  orchestrator = Agentic:class="y">:PlanOrchestrator.new(
    lifecycle_hooks: journal.lifecycle_hooks,
    retry_policy: {max_retries: 0, retryable_errors: []}
  )
  orchestrator.add_task(Agentic:class="y">:Task.new(
    description: name, agent_spec: {class="s">"name" => class="s">"w", class="s">"instructions" => class="s">"sync"}
  ), agent: ->(_t) { raise error })
  orchestrator.execute_plan
end

def drill(strategy, budget: nil)
  path = File.join(Dir.tmpdir, class="s">"agentic_budget_#{strategy}.jsonl")
  File.delete(path) if File.exist?(path)
  journal = Agentic:class="y">:ExecutionJournal.new(path: path)

  calls = 0
  fast_failed = 0
  JOBS.times do |i|
    # Job 7 hits a revoked key; everyone else hits the dead upstream
    error = (i == 7) ? Agentic:class="y">:Errors:class="y">:LlmAuthenticationError.new(class="s">"401") : Agentic:class="y">:Errors:class="y">:LlmServerError.new(class="s">"503")
    attempts = 0
    loop do
      run_job(class="s">"job#{i}", error, journal)
      calls += 1
      attempts += 1

      verdict = Agentic:class="y">:ExecutionJournal.replay(path: path).events
        .reverse.find { |e| e[class="y">:event] == class="s">"task_failed" }[class="y">:retryable]
      break if verdict == false # hopeless: no retry spends anything, ever
      break if attempts > MAX_RETRIES

      if budget
        unless budget.spend?
          fast_failed += 1
          break
        end
      end
    end
  end
  [calls, fast_failed, budget&.spent]
end

puts class="s">"RETRY BUDGET (#{JOBS} jobs, upstream dead, max #{MAX_RETRIES} retries each)"
puts

calls_a, = drill(class="s">"naive")
puts class="s">"  strategy A - every job for itself:"
puts class="s">"    #{calls_a} calls fired at a host that was down for all of them."
puts class="s">"    11 transient jobs x (1 + #{MAX_RETRIES} retries) + 1 auth job x 1 = #{calls_a}:"
puts class="s">"    the outage was 1 incident; the fleet made it #{calls_a} requests."
puts

budget = RetryBudget.new(5)
calls_b, fast_failed, spent = drill(class="s">"budgeted", budget: budget)
puts class="s">"  strategy B - one wallet of 5 retries for the whole fleet:"
puts class="s">"    #{calls_b} calls total: #{JOBS} first attempts + #{spent} budgeted retries."
puts class="s">"    #{fast_failed} jobs failed FAST once the wallet emptied - no call, no"
puts class="s">"    timeout, no bill. the auth job spent nothing from the wallet:"
puts class="s">"    hopeless failures don't get retries, so they can't drain the"
puts class="s">"    budget the transient ones might still need."
puts
puts class="s">"  the fleet cut #{calls_a - calls_b} pointless requests (#{calls_a} -> #{calls_b}) and lost nothing -"
puts class="s">"  every retry was doomed anyway. per-job retry policies answer"
puts class="s">"  \"should I try again?\class="s">"; the budget answers \"should ANYONE?\class="s">" -"
puts class="s">"  round 9's breaker asked that per-upstream, this asks it per-"
puts class="s">"  window, and both read the same journaled verdicts. retries are"
puts class="s">"  a shared resource. give them a wallet, not a habit. (and the"
puts class="s">"  wallet is now a real windowed RateLimit - try_acquire says no"
puts class="s">"  without making anyone wait for it, which is the entire point.)"