One request looks fine. Ten look fine too. Then you add a loop, and the server starts coughing. Why?
Free model servers handle bursts, not beams. They queue. They throttle. They drop. You only find out after your pipeline stalls.
I wanted a probe that maps these failure modes fast. No dashboards. No load-testing platform. Just Python and 30 minutes.
Here's what I run before I trust any free model endpoint for parallel work.
Why Concurrency Is the Real Test
Single requests hide the truth. A server can answer one call quickly. Then it chokes on eight simultaneous calls. Queueing, rate limits, and cold starts only appear under pressure.
So I test the pressure. The goal is not to benchmark the server. The goal is to find where it breaks. That boundary becomes my retry policy.
The Setup
First, the target. I used MonkeyCode's free model access and its free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
You need three things. An endpoint. A token. A prompt that returns a fixed marker.
My marker is the string OK. If it's missing, the call failed. Even when the status code says 200.
The free server option is enough for this test. You don't need a paid plan to learn how a server behaves under pressure.
The Probe
The idea is simple. Fire N concurrent requests. Record status, latency, and content integrity. Repeat with N = 1, 2, 4, 8, 16.
I send 20 requests per level. Enough to see a pattern. Small enough to stay polite.
import asyncio
import time
import httpx
URL = "YOUR_ENDPOINT"
TOKEN = "YOUR_TOKEN"
PROMPT = "Reply with exactly: OK"
async def one(client, semaphore, i):
async with semaphore:
t0 = time.perf_counter()
try:
r = await client.post(
URL,
json={"prompt": PROMPT},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
dt = time.perf_counter() - t0
ok = r.status_code == 200 and "OK" in r.text
return i, r.status_code, round(dt, 3), ok
except Exception as e:
return i, type(e).__name__, round(time.perf_counter() - t0, 3), False
async def ramp(concurrency, total=20):
semaphore = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient() as client:
return await asyncio.gather(
*[one(client, semaphore, i) for i in range(total)]
)
for c in [1, 2, 4, 8, 16]:
results = asyncio.run(ramp(c))
ok = sum(1 for r in results if r[3])
errors = [r for r in results if not r[3]]
lat = [r[2] for r in results if r[3]]
p50 = sorted(lat)[len(lat) // 2] if lat else None
print(f"concurrency={c:2d} ok={ok}/20 errors={len(errors)} p50={p50}s")
Run it with Python 3.10+. Install httpx first.
pip install httpx
python probe.py
The Recovery Phase
The ramp shows where it breaks. The recovery phase shows how long the damage lasts.
Wait 60 seconds after the ramp. Then send five single requests with a two-second gap. If they still fail, the throttle window is longer than your patience.
async def recovery():
semaphore = asyncio.Semaphore(1)
async with httpx.AsyncClient() as client:
for i in range(5):
result = await one(client, semaphore, i)
print(f"recovery {i}: ok={result[3]} status={result[1]}")
await asyncio.sleep(2)
asyncio.run(recovery())
A server that recovers is forgiving. A server that stays throttled for minutes is dangerous. That knowledge changes your retry design.
What to Record
Four numbers matter. Error rate. p50 latency. p95 latency. Content integrity.
Content integrity is the one people skip. A 200 with an empty body is still a failure. A 200 with a canned apology is still a failure. Check the marker.
Reading the Results
Your numbers will differ from mine. That's the point. The probe maps failure modes, not universal truths.
| Symptom | What it means | What I do |
|---|---|---|
| Errors appear at 4+ concurrent | Per-user rate limit | Serialize calls, add jitter |
| Latency climbs, zero errors | Server-side queueing | Lower concurrency, batch async |
| Timeouts after an idle gap | Cold start | Warm-up ping, longer first timeout |
| Truncated or partial replies | Token cap | Shorten prompt, check finish_reason |
| Errors persist after the pause | Long throttle window | Longer cooldown, switch endpoints |
The pattern matters more than any single number. A server that degrades smoothly is usable. A server that flips from 200 to 429 is a trap.
The Retry Policy
Once you know the failure mode, build the retry policy. Exponential backoff with jitter. Four tries max. Never retry on a 400.
import random
import time
def call_with_backoff(fn, tries=4):
for n in range(tries):
try:
return fn()
except Exception:
if n == tries - 1:
raise
sleep = 0.5 * (2 ** n) + random.uniform(0, 0.3)
time.sleep(sleep)
Add a circuit breaker when the server fails five times in a row. Open the circuit. Wait 30 seconds. Probe once. Close it only on success.
class Breaker:
def __init__(self, threshold=5, cooldown=30):
self.threshold = threshold
self.cooldown = cooldown
self.failures = 0
self.open_until = 0
def allow(self):
return time.time() >= self.open_until
def success(self):
self.failures = 0
def failure(self):
self.failures += 1
if self.failures >= self.threshold:
self.open_until = time.time() + self.cooldown
Limitations
One run is a snapshot, not a promise. Free tiers change without notice. My results from today mean nothing next month. Run the probe yourself. Run it before every release.
This probe measures availability, not quality. A fast 200 can still be nonsense. Check the content, not just the status code.
Who should skip this? Teams with hard SLOs. Production user traffic. Consistent p95 requirements. Those need a paid tier and a contract. A probe won't fix a missing SLA.
The Takeaway
Free model servers are great for experiments. They are terrible for surprises. A 30-minute probe turns surprises into decisions.
Run the script. Map your failure modes. Then choose your retry policy with evidence.
Found a failure mode I missed? Tell me in the comments. I keep a list.
Top comments (0)