TL;DR
I run a fully autonomous coding agent that kicks off scheduled jobs around the clock, and for months I didn't have a real plan for what happens when the LLM provider says "no more requests right now." Eventually it happened enough times that I had to design for it properly. Here's what I learned about detecting rate limits, failing fast instead of retry-looping, and making every scheduled run safe to skip.
The Problem
When you run an AI agent as a background worker instead of an interactive chat session, you stop thinking about rate limits as an edge case and start thinking about them as a certainty. Somewhere, on some day, a scheduled run is going to hit a provider-side cap — a session limit, a rolling 5-hour window, a hard 429 — and your agent needs to have an opinion about what happens next.
My setup is simple on paper: a background job wakes up once a day, spins up an autonomous coding agent, and lets it do a scoped chunk of work — writing code, updating state, whatever the day's task is. No human watches it run. That's the whole point of "autonomous." But "no human watches it run" cuts both ways: if the agent gets a 429 and responds by hammering the API in a tight retry loop, there's nobody there to notice until the bill shows up or the account gets flagged.
I found out the hard way. Two days in a row, my scheduled job fired, immediately hit a session-limit error, and then just... stopped, with a two-line log entry and nothing else. That's actually the good outcome. The bad outcome — the one I was originally set up for — was a naive retry: catch the error, wait a second, try again, catch the error, wait a second, try again. On a 5-hour rate limit window, that's not a retry strategy, that's a denial-of-service attack against myself.
So the real problem wasn't "the API returned an error." APIs return errors; that's normal. The problem was that I hadn't designed my agent to treat "I am currently rate-limited" as a first-class state, distinct from "something is broken" or "the task failed." Those are three different situations that call for three different responses, and I'd been collapsing them into one.
How I Solved It
The fix ended up being less about clever retry math and more about being honest with the agent's control flow about what kind of failure it was looking at.
1. Classify the error before deciding what to do with it.
The first thing I did was stop treating every non-2xx response the same way. A rate-limit response usually comes with enough metadata to tell you why you're being throttled and when it'll clear:
{
"type": "rate_limit_event",
"rate_limit_info": {
"status": "rejected",
"resetsAt": 1784397600,
"rateLimitType": "five_hour",
"overageStatus": "rejected"
}
}
That resetsAt field is gold. It turns "retry with exponential backoff and hope" into "I know exactly when this clears, so I don't need to guess." Once I started parsing that field explicitly, the agent's decision tree got a lot simpler: if the reset time is more than a few minutes away, don't retry at all — just record it and exit.
2. Fail fast, log clearly, and let the next scheduled run be the retry.
This was the biggest mental shift. I stopped treating "the job didn't finish today" as a failure that needed same-session recovery. If the agent hits a hard rate limit, the correct response is almost never "wait it out inside this process." It's "exit cleanly, write down exactly what happened, and trust tomorrow's scheduled run to pick it up."
if error.status_code == 429:
log_failure(reason="rate_limited", resets_at=error.resets_at)
mark_task_status("Failed", note=f"rate limit, resets {error.resets_at}")
sys.exit(0) # not sys.exit(1) — this isn't a crash, it's an expected state
Notice the exit code choice. A rate limit isn't a bug in my code — it's an expected operating condition for a system that runs unattended. I don't want my monitoring to treat "got rate limited" with the same alarm as "the process segfaulted." Those need different severities, or you train yourself to ignore alerts.
3. Make every scheduled run idempotent.
Once I accepted that some runs would simply not complete, I had to make sure a skipped or half-finished run couldn't corrupt state. Every scheduled task now starts by creating (or finding) a tracking record with status InProgress before doing any real work, and only flips it to Done at the very end. If a run dies partway through — rate limit, crash, whatever — the record just sits at InProgress or Failed, and the next run's dedup logic knows not to trust it as completed work.
This matters more than it sounds like it should. Without it, a rate-limited run that partially executes side effects (say, half-written output, or a partially-updated log) leaves you with corrupted state that's harder to debug than the original rate limit ever was. Idempotency isn't a nice-to-have for autonomous agents — it's what lets you treat "just skip today" as a safe, boring outcome instead of a scary one.
The pattern I settled on is boring on purpose: create the tracking record in a clearly-provisional state before touching anything else, do the real work, and only flip the record to "done" as the very last step. Everything in between is disposable. If the process dies at any point — rate limit, crash, laptop goes to sleep, whatever — the worst case is a provisional record sitting there, and the next run's own startup check treats that as "not actually finished" rather than trusting it. I originally skipped this step because it felt like overhead for something that "almost never happens." It happens more than you'd think, and the one time it matters, it saves you an afternoon of forensic log-reading.
4. Distinguish session limits from hard account-level limits.
Not all rate limits are the same shape. Some clear in minutes, some are tied to a rolling window (in my case, a 5-hour window), and some are account-level caps that won't clear until a fixed daily reset. I now log the rateLimitType explicitly so that when I'm skimming a week of logs, I can immediately tell "this was a routine 5-hour window bump" apart from "this is a structural capacity problem I need to actually fix" (like needing a higher-tier plan, or spacing out scheduled jobs so they don't compete for the same window).
flowchart TD
A[Scheduled run starts] --> B{API call succeeds?}
B -- yes --> C[Do the work, mark Done]
B -- no, 429 --> D{Parse rate_limit_info}
D --> E[Log type + resetsAt]
E --> F[Mark Failed, exit 0]
F --> G[Next scheduled run retries naturally]
B -- no, other error --> H[Mark Failed, exit 1, alert]
Lessons Learned
A rate limit is a state, not an error. Treat it differently from a genuine bug, both in your exit codes and in whatever alerts you. If your monitoring can't tell the difference, you'll start ignoring both.
The best retry strategy for a daily job is often "tomorrow." I spent way more time than I should have designing in-process backoff logic before realizing that a system that already runs on a schedule has a free, built-in retry mechanism: the next scheduled run. Use it instead of reinventing a worse one inside a single process.
Idempotency is what makes "just fail today" safe. If your agent can partially complete work before dying, a rate limit becomes a data-corruption risk, not just a missed day. Structure state changes so a run either fully completes or leaves a clearly-unfinished marker behind, never something that looks done when it isn't.
Parse the metadata the provider actually gives you. Most APIs that rate-limit you also tell you when it clears. Don't guess with exponential backoff when you can just read
resetsAtand act on it directly.Log the type of limit, not just the fact that you got one. A session cap and a hard account-level cap look identical from "I got a 429," but they mean completely different things about whether you have an actual capacity problem to solve.
What's Next
I'm now looking at spacing out my scheduled jobs so they're less likely to stack inside the same rolling rate-limit window in the first place — basically capacity planning for an agent instead of just reacting after the fact. I'm also curious whether I can get the agent to self-report "I'm close to a rate-limit boundary, maybe skip non-essential work today" instead of finding out only after the 429 comes back.
There's also a harder question underneath all of this that I don't have a clean answer for yet: how do you budget API usage across a fleet of independent scheduled agents that don't know about each other? Right now each job just finds out it's rate-limited when it hits the wall, the same way you'd find out a shared resource is exhausted by tripping over it. A smarter version would have some shared sense of "how much runway is left in this window" before any of them start work, so the first job of the day doesn't accidentally starve the rest. That's the next thing I want to build — some kind of lightweight, shared rate-limit budget that every scheduled job checks before it even starts, not just after it fails.
Wrap-up
If you're running any kind of unattended AI agent — a background job, a scheduled task, anything without a human watching the terminal — do yourself a favor and design for rate limits on day one instead of day ninety. It's a small amount of upfront thinking that saves you from a very confusing debugging session later.
If this was useful, follow me here on Dev.to — I'm writing up more of these lessons as I keep building out this autonomous coding system. Also curious: how are you handling rate limits in your own agent setups? Drop a comment, I'd love to compare notes.
Top comments (0)