A note on language and opacity
I write this series in English because Polymarket itself operates in English and is inaccessible in a number of jurisdictions — writing in English keeps the audience roughly aligned with the platform. On the money-making side of things, I stay deliberately vague: the specific wallets I follow, the filters I apply before entering a position, and how I size trades are the edge, and sharing them would erase that edge. The engineering, though, costs me nothing to share in full, so that is what this series is about.
How the problem announced itself
The bot had been running quietly for a while. The polling loop would wake up, fire a batch of requests to the market endpoints, process the responses, and sleep until the next cycle. In practice “sleep until the next cycle” meant “sleep until the processing was done,” which was fast, which meant the effective sleep was nearly zero, which meant the bot was hammering the API as hard as the network would carry it.
That worked until it didn’t. A burst of slightly heavier processing on my side coincided with a burst of market activity, and suddenly every response came back with a 429. My first instinct — and I suspect this is nearly everyone’s first instinct — was to catch the 429 and retry immediately. That is exactly the wrong move. Immediate retries into a throttled endpoint do not clear the throttle; they deepen it. You are sending more requests precisely when the server has already decided you are sending too many. In a worst case, an API that started with a soft rate limit hands you a temporary ban for sustained abuse. I had turned a recoverable situation into a worse one by responding to a signal I did not understand.
What a 429 is actually telling you
A 429 is not an error in the conventional sense. It is not saying that something is broken or that your request was malformed. It is the server communicating a constraint: you are moving faster than I am willing to serve you, slow down. The right response to that message is to slow down. Simple in principle, easy to get wrong in code.
The bug in my original loop was that rate limiting was not part of the design at all. It was something I assumed I would handle if it came up, which is another way of saying I had not handled it. The fix required rethinking the request path from the outside in, starting with the constraint rather than treating the constraint as an afterthought.
Pacing: steady flow instead of bursts
The first change was to add a pacing layer so that requests leave at a controlled, steady rate regardless of how fast the rest of the loop runs. Instead of firing all the requests for a cycle as quickly as possible, I introduced a small, deliberate gap between each outgoing request. The idea is the same as traffic shaping on a network: smooth out the bursts so the average rate stays within a budget the server is happy with.
In practice this is a thin wrapper around the HTTP call that tracks the timestamp of the last request and sleeps for whatever time remains before the next one is allowed to go out:
import time
class RateLimitedSession:
def __init__(self, min_interval_seconds):
self.min_interval = min_interval_seconds
self._last_request_at = 0.0
def _wait(self):
elapsed = time.monotonic() - self._last_request_at
gap = self.min_interval - elapsed
if gap > 0:
time.sleep(gap)
self._last_request_at = time.monotonic()
def get(self, url, **kwargs):
self._wait()
return requests.get(url, **kwargs)
Nothing clever here. The cleverness, if there is any, is in recognising that this layer needs to exist at all, and in placing it low enough in the stack that every request path goes through it automatically rather than relying on individual call sites to remember to pace themselves.
Backoff with jitter: spreading out the retries
Pacing handles the steady-state case. The failure case — when a 429 does arrive despite pacing, or when the server is briefly overwhelmed for reasons unrelated to my request rate — needs a different mechanism: backoff.
Exponential backoff means waiting longer after each successive failure before trying again. Wait one second, then two, then four, then eight. The idea is to give the server time to recover and to reduce the pressure you are adding while it does. But pure exponential backoff has a subtle problem: if multiple clients fail at the same moment and all of them back off by exactly the same schedule, they will all retry at exactly the same moment too. The burst that caused the failure repeats itself on a timer. This is sometimes called the thundering herd, and it is a real phenomenon even when you are the only process, because a single process can have multiple concurrent request paths all backing off in lockstep.
Jitter fixes it by adding randomness to the wait time. Instead of waiting exactly 2^n seconds, wait for a duration sampled uniformly from zero to 2^n seconds. The retries spread out over a window rather than all landing at the same instant:
import random
import time
def backoff_wait(attempt, base=1.0, cap=60.0):
"""
Full jitter backoff.
attempt: zero-indexed count of failures so far.
base: multiplier in seconds.
cap: maximum wait in seconds.
"""
ceiling = min(cap, base * (2 ** attempt))
wait = random.uniform(0, ceiling)
time.sleep(wait)
The cap parameter matters. Unbounded exponential backoff will eventually produce waits long enough to be operationally useless — a bot waiting several minutes between retries has effectively stopped. Capping at a reasonable ceiling keeps the system responsive while still giving the server meaningful breathing room.
Putting the two pieces together, the retry loop looks roughly like this:
MAX_RETRIES = 6
def fetch_with_retry(session, url):
for attempt in range(MAX_RETRIES):
response = session.get(url)
if response.status_code == 200:
return response
if response.status_code == 429:
backoff_wait(attempt)
continue
# Non-retryable error — raise immediately
response.raise_for_status()
raise RuntimeError(f"Exhausted retries for {url}")
The 429 path backs off and continues. Everything else either succeeds or raises immediately, because retrying a 400 or a 404 is pointless and retrying a 500 without a plan is usually also pointless. The distinction matters: not every failure is a failure worth retrying.
The mental shift that actually mattered
The code changes were not complicated. The harder thing was recognising that the rate limit is an architectural constraint, not an error class. An error class is something you handle at the edges of your code, after the main logic is written. An architectural constraint is something you design around from the start, the same way you design around the fact that a network call can be slow or that a disk write can fail.
When I treated the rate limit as an afterthought, I built a loop that was structurally incapable of respecting it. The 429 handling I bolted on afterward was always fighting the shape of the code rather than working with it. Once I moved the pacing logic to the bottom of the request stack and made it impossible to bypass, the problem became much harder to reintroduce accidentally. Future me would have to deliberately remove the pacing layer to break this, rather than simply forgetting to add a sleep in one new call site.
There is a broader pattern here that keeps appearing in this project: the bugs that are hardest to fix are the ones where the fix requires changing what you assume the architecture is responsible for, not just changing a function. The logging bug in Part 2 was the same kind of thing — the issue was not a wrong value but a wrong assumption about where in the pipeline a certain concern belonged. Rate limiting turned out to be the same lesson wearing different clothes.
What is still open
The current setup handles pacing and backoff well enough, but it is single-threaded. If I ever want to fan out requests across multiple market endpoints concurrently — which would be useful as the number of markets I watch grows — the pacing layer will need to become aware of concurrent callers so that parallelism does not let bursts sneak back in through the side door. That is a problem I have deferred for now, but I know roughly where it lives.
Next up: what happens when the bot’s state and the on-chain state quietly diverge, and how I eventually noticed.