DEV Community

Cover image for My AI bill wasn’t exploding from tokens — it was exploding because the same failed step ran 3 times
Lars Winstand
Lars Winstand

Posted on Originally published at standardcompute.com

My AI bill wasn’t exploding from tokens — it was exploding because the same failed step ran 3 times

I found an ugly failure mode in an agent workflow recently:

one timeout turned into multiple LLM executions because three different layers all thought they were being helpful.

  • Zapier retried the webhook
  • my worker retried the job
  • the OpenAI-compatible SDK retried the model call

The result was not “slightly higher usage.”

It was duplicate GPT-5 calls, duplicate Claude Opus 4.6 calls, duplicate tool runs, and logs that made the whole thing look haunted.

If you run AI automations in Zapier, Make, n8n, OpenClaw, BullMQ, SQS, Celery, or custom workers, this is one of the easiest ways to burn money without noticing.

The wrong diagnosis: “token usage is high”

At first I blamed prompt size.

That’s the obvious story:

  • long system prompt
  • second-pass validation
  • tool call in the middle
  • maybe a fallback from GPT-5 to Claude Opus 4.6

Sure, that can get expensive.

But in my case, the bigger leak was architectural.

The same prompt was being executed more than once, with slightly different timestamps, because the failure happened at the exact point where every layer had its own retry policy.

That’s the bug.

Not bad prompting. Not model choice. Retry multiplication.

The most expensive prompt is the one you accidentally ran 4 times

Retries are good.

I want retries when:

  • a model endpoint times out
  • a network hop flakes out
  • a provider returns 429
  • a temporary 5xx happens upstream

But layered retries are a reliability feature that turns into a cost bug.

Here’s the kind of flow that causes trouble:

  1. Zapier sends a webhook to /agent-step
  2. Your API enqueues a job
  3. A worker calls GPT-5 through an OpenAI-compatible client
  4. The model call times out
  5. The worker retries
  6. Zapier retries because your endpoint returned 503
  7. The SDK retries the HTTP request too

Now one user action is bouncing around your stack like a pinball.

And because LLM calls are slow, stateful, and often side-effectful, the damage is bigger than just spend.

You also get:

  • duplicate summaries
  • duplicate CRM updates
  • duplicate emails
  • duplicate Slack or Discord messages
  • duplicate tool calls
  • confusing traces and logs

That’s why this shows up later as:

  • “why did this customer get 3 emails?”
  • “why did usage spike overnight?”
  • “why does this agent feel unreliable?”

What Zapier actually does on webhook retries

Zapier is not being weird here. It’s doing what it should do.

If your endpoint responds with:

  • 2xx: delivery is considered successful
  • 429: Zapier may retry
  • 5xx: Zapier may retry
  • timeout / connection error: Zapier may retry
  • other 4xx: treated as permanent failure

That behavior is reasonable.

The problem is when your app also retries, and your SDK also retries, and nobody decided who actually owns the LLM retry.

Pick one retry owner for the LLM step

This is the rule I’d put on the wall:

Every LLM step gets one retry owner and one idempotency key.

Not Zapier plus your worker plus your SDK.

One owner.

My opinion: the worker or activity layer should usually own retries for the actual model call.

Why?

Because that layer has the most context.

It knows whether the failure was:

  • a timeout
  • a rate limit
  • malformed input
  • schema mismatch in tool calling
  • bad downstream dependency
  • permanent validation error

Zapier and Make are great orchestrators. They are usually not the best place to make fine-grained retry decisions about GPT-5, Claude Opus 4.6, Grok 4, or a self-hosted Qwen endpoint.

Temporal has the cleanest mental model here

Even if you don’t use Temporal, I think its retry model is the right way to think about agent workflows.

The useful split is:

  • Activities are retried by default
  • Workflows are not retried by default

And the flaky stuff — API calls, network operations, LLM invocations — belongs in Activities.

That maps really well to AI automations.

Here’s the comparison that matters:

Approach What actually happens
Zapier webhook retry semantics Retries on 429/5xx and timeout/connection errors; stops on 2xx success; treats other 4xx as permanent failure
Temporal Activity vs Workflow retries Activities retried by default; Workflows not retried by default; supports non-retryable failures and explicit retry policy fields
Single retry owner vs layered retries One component owns backoff and max attempts; easier idempotency and observability; avoids duplicate LLM executions across orchestrator, worker, and SDK

If you’re building with n8n, Make, Zapier, OpenClaw, BullMQ, SQS, Celery, or your own queue, that mental model is worth stealing.

The expensive mistake: treating permanent failures like transient ones

This is where teams quietly waste money for hours.

Some failures are transient:

  • timeout
  • 429
  • temporary network split
  • short upstream outage

Some are permanent:

  • missing required field
  • invalid tool arguments
  • schema mismatch
  • impossible payload
  • bad JSON shape
  • negative amount where only positive values are valid

If you retry both categories the same way, your system will keep paying to be wrong.

That’s not resilience. That’s theater.

Temporal’s TypeScript examples get this exactly right:

throw ApplicationFailure.create({
  message: `Invalid charge amount: ${chargeAmount} (must be above zero)`,
  nonRetryable: true,
});
Enter fullscreen mode Exit fullscreen mode

That nonRetryable: true flag is doing real cost control.

For AI tool calling, this matters a lot.

A tool-call timeout and a tool-call schema mismatch are not the same class of problem.

If your retry system can’t tell the difference, it will manufacture duplicate cost and duplicate side effects.

The boring fix is the correct fix

This pattern is not glamorous, which is probably why people skip it.

But it works.

1) Carry one stable operation ID end to end

If Zapier triggers a webhook, generate or propagate a stable key:

  • webhook request
  • queue job
  • worker execution
  • model call
  • result storage

That gives you idempotency across the stack.

Example shape:

{
  "run_id": "evt_01j8x8v7m9j7k2",
  "customer_id": "cus_123",
  "action": "summarize_ticket"
}
Enter fullscreen mode Exit fullscreen mode

2) Return the correct HTTP status code

This is where a lot of duplicate retries start.

Use:

  • 2xx when the event has been durably accepted
  • 429 or 5xx only when retrying is actually safe
  • 4xx for bad payloads or permanent failures

If the payload is invalid, don’t return 503 just because “something failed.”

That invites replay for a request that should never run again.

3) Cache the final result for duplicates

If the same idempotency key shows up again, don’t re-run GPT-5 or Claude.

Return the stored result.

A duplicate delivery is not a new request.

It’s bookkeeping.

4) Bound retries with backoff and max attempts

Exponential backoff is good.

Infinite hope is not.

If an upstream API returns 429, honor Retry-After when present.

If a request has already failed 5 times, maybe the answer is not “try forever.”

A practical webhook handler pattern

This is the kind of pattern I wish more teams started with.

app.post('/agent-step', async (req, res) => {
  const key = req.header('Idempotency-Key') || req.body.run_id;
  const existing = await store.get(key);

  if (existing) {
    return res.status(200).json(existing);
  }

  if (!isValid(req.body)) {
    return res.status(422).json({ error: 'invalid payload' });
  }

  try {
    const result = await runAgentStep(req.body);
    await store.put(key, result);
    return res.status(200).json(result);
  } catch (err) {
    return res.status(503).json({ error: 'temporary failure' });
  }
});
Enter fullscreen mode Exit fullscreen mode

And the retry ownership rule should be equally blunt:

// Orchestrator: does not own LLM retries
// Worker/activity: owns retries with bounded exponential backoff
// Endpoint: dedupes by idempotency key
// Duplicates: return cached result with 2xx
Enter fullscreen mode Exit fullscreen mode

What this looks like in a worker

Here’s a minimal Node-style example where the worker owns retries instead of the webhook layer.

async function callModelWithRetry(fn, opts = {}) {
  const maxAttempts = opts.maxAttempts ?? 4;
  const baseDelayMs = opts.baseDelayMs ?? 1000;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const status = err.status || err.response?.status;
      const retryAfter = Number(err.response?.headers?.['retry-after'] || 0);

      const transient = status === 429 || status >= 500 || err.code === 'ETIMEDOUT';
      if (!transient) throw err;
      if (attempt === maxAttempts) throw err;

      const delay = retryAfter > 0
        ? retryAfter * 1000
        : baseDelayMs * Math.pow(2, attempt - 1);

      await new Promise(r => setTimeout(r, delay));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then your actual step can look like this:

async function runAgentStep(payload) {
  return callModelWithRetry(async () => {
    return client.responses.create({
      model: 'gpt-5',
      input: payload.prompt,
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

The point is not the exact code.

The point is having one place where retry policy lives.

How to spot retry multiplication in production

If you think this might already be happening, check for these signals:

  • same prompt hash appears multiple times within a short window
  • same run_id creates multiple model calls
  • webhook delivery count is higher than expected
  • queue retries correlate with LLM usage spikes
  • users report duplicate outputs or duplicate side effects
  • logs show multiple timestamps for what should be one execution

A quick grep can already tell you a lot.

grep "run_id=evt_01j8x8v7m9j7k2" worker.log
Enter fullscreen mode Exit fullscreen mode

Or if you log prompt hashes:

grep "prompt_hash=7f3c9a" app.log
Enter fullscreen mode Exit fullscreen mode

If the same operation shows up in webhook logs, queue logs, and model logs more than once, you probably don’t have a token problem.

You have a retry ownership problem.

Why this gets worse in no-code and low-code automation stacks

Zapier and Make are excellent at making automation feel simple.

That’s also why this bug is easy to miss.

When you connect boxes on a canvas, you think in terms of “the step failed” and “the step reran.”

You do not naturally picture:

  • webhook redelivery
  • queue replay
  • SDK backoff
  • duplicate model execution
  • repeated side effects downstream

But once you add agents, the stack gets deep fast.

A single run might do:

  • GPT-5 for planning
  • Claude Opus 4.6 for revision
  • search API lookup
  • database write
  • Slack message
  • CRM update

Now duplicate retries don’t just multiply cost.

They multiply side effects and destroy trust in the automation.

The architecture rule I’d actually enforce

If I had to reduce this whole post to one rule:

Every LLM step gets one retry owner and one idempotency key.

If Zapier is the trigger, let Zapier handle delivery retries to your endpoint.

Once your service has durably accepted the event, return 2xx.

After that, move retry responsibility to the worker or activity layer.

Then make that worker the only place that retries the actual GPT-5, Claude Opus 4.6, or Grok 4 call.

If the payload is bad, fail with 4xx.

If the request is a duplicate, return the cached result.

If the upstream model is flaky, retry with bounded exponential backoff.

If you hit a rate limit, honor Retry-After.

That’s how you stop background AI processes from becoming invisible money shredders.

Why this is really a cost post, not just a reliability post

A lot of teams spend time on:

  • prompt compression
  • model routing
  • response length limits
  • shaving a few cents off each call

Those things matter.

But if the same broken step is quietly executing 3 times because nobody decided who owns retries, you’re stepping over dollars to pick up pennies.

That’s also why predictable AI pricing gets more attractive as automations get more complex.

When you’re running agents all day across Zapier, Make, n8n, or custom workers, per-token billing punishes both legitimate usage and architectural mistakes. A flat-cost setup gives you more room to instrument, test, and fix these workflows without every duplicate execution turning into a mini finance incident.

That’s a big part of why products like Standard Compute are interesting for agent-heavy teams: OpenAI-compatible API access, but with unlimited compute at a predictable monthly price instead of watching token spend every time a workflow gets noisy.

Still, even with flat pricing, the engineering rule does not change:

bad retry design makes automations unreliable.

You want lower cost, yes.

But you also want a system you can trust.

And the first step is brutally simple:

pick one retry owner.

Once you see retry multiplication clearly, you can’t unsee it.

Top comments (0)