Recipe
Handle limits gracefully
A small wrapper that waits out the limits worth waiting for, and stops on the ones that are not.
The rules
BURST_LIMIT_EXCEEDEDandSERVICE_BUSY: wait theRetry-Afterseconds, then retry. Neither counts against your day.DAILY_LIMIT_EXCEEDED: stop. Retrying cannot succeed beforeresetsAt, and every attempt still counts.- Anything else (a
400,403or422) is about the request itself. Fix it rather than retry.
Full details on Limits.
Needs
KELLYIQ_API_KEY. Uses two optimizes each time it runs.The script
handling-limits
import os
import time
import requests
API = os.environ.get("KELLYIQ_API_BASE", "https://api.kellyiq.com")
KEY = os.environ["KELLYIQ_API_KEY"]
RETRYABLE = ("BURST_LIMIT_EXCEEDED", "SERVICE_BUSY")
class DailyLimitReached(Exception):
pass
def optimize(body, max_attempts=5):
"""POST /v1/optimize, waiting out per-minute limits and busy periods."""
for attempt in range(1, max_attempts + 1):
resp = requests.post(f"{API}/v1/optimize", headers={"Authorization": f"Bearer {KEY}"},
json=body, timeout=60)
if resp.ok:
print("optimizes left today:", resp.headers.get("X-RateLimit-Remaining"))
return resp.json()
try:
err = resp.json()
except ValueError:
# Not from the API (e.g. a proxy timeout page): treat it like SERVICE_BUSY.
err = {"error": "SERVICE_BUSY"}
if err["error"] == "DAILY_LIMIT_EXCEEDED":
# Done for today. Retrying only adds to the count.
raise DailyLimitReached(f"allowance used up; resets at {err['resetsAt']}")
if err["error"] not in RETRYABLE:
# A problem with the request itself: fix it rather than retry.
raise RuntimeError(f"{resp.status_code} {err['error']}: "
f"{err.get('violations') or err.get('message')} (request {err.get('requestId')})")
if attempt < max_attempts:
wait = int(resp.headers.get("Retry-After", "5"))
print(f"{err['error']}: waiting {wait}s (attempt {attempt})")
time.sleep(wait)
raise RuntimeError(f"still limited after {max_attempts} attempts")
slates = [
{"bankroll": 1000, "picks": [
{"sport": "baseball_mlb", "team": "Dodgers", "opponent": "Padres",
"odds": -145, "opponentOdds": 125, "confidence": "STRONG_EDGE"}]},
{"bankroll": 1000, "picks": [
{"sport": "baseball_mlb", "team": "Cubs", "opponent": "Cardinals",
"odds": 120, "opponentOdds": -140, "confidence": "MODERATE_EDGE"}]},
]
try:
for s in slates:
result = optimize(s)
print(result["allocation"]["totalStake"])
except DailyLimitReached as e:
print(e)Pacing a batch
Optimize runs one at a time per account, so send slates one after another, never in parallel. The per-minute budget refills one optimize every 6 seconds; a loop that waits for each response before sending the next rarely hits it, and the wrapper absorbs it when it does.