Why Your Poker Bot Times Out: Causes and Async Fixes
A poker bot timeout auto-folds whatever hand you were holding. Pocket aces, the nut flush, doesn't matter: the server forfeits the action and your stack moves backward. The 120-second window sounds generous until you watch a real bot blow through it on hand #47 of a session. Here's what causes it and how to fix every variant.
Part of: The Complete Guide to Building an AI Poker Bot in 2026 — the full pillar covering frameworks, decision logic, equity, testing, and where to compete.
What happens when your poker bot times out?
Open Poker enforces a hard 120-second window from the moment your bot receives your_turn to the moment it sends a valid action message. If you miss the deadline, the server forces a fold (or check, if checking is the cheapest valid action). Your bot stays at the table and gets the next hand normally, but the timed-out hand is gone.
The timeout penalty is more than the lost chips. The server logs every timeout against your bot, and repeated timeouts eventually trigger a "consistent timer abuse" disconnect. Your bot loses its seat, gets cashed out, and has to rejoin the lobby. We've seen bots lose 30-40 minutes of table time over a single buggy session because of cascading timeouts. Full timer behavior is documented in the action timeouts reference.
The other thing that surprised us: timeouts skew toward your strongest hands. Bots tend to think harder about decisions that matter. A complex Monte Carlo simulation runs faster on 72 offsuit (insta-fold) than on AKs facing a 3-bet (every street has analysis). The hands you most want to play are the hands your bot is most likely to time out on.
Why isn't 120 seconds actually generous?
Three reasons, in order of how often we see each in production bots.
1. Synchronous network calls inside your decision loop. This is the #1 cause we've debugged. A bot calls a hand evaluator that hits an external API, or queries a database for opponent stats, or uses requests.get() instead of an async client. Every synchronous call blocks the entire bot. If you're using websockets and asyncio (the standard stack), a single sync call freezes the event loop until it completes. When your event loop is frozen, you're not just slow on this decision: you're queueing up future messages that get processed late after the current decision finishes.
2. Reconnects that happen during a decision. Open Poker's connection is persistent, but it can drop. If your network blips while your bot is mid-decision, the WebSocket disconnects, your action send fails silently, and you wait for the timeout. Bots without reconnect logic just hang. Bots with bad reconnect logic try to reconnect synchronously and double the latency.
3. Garbage collection pauses on long-running bots. Less common but real. A bot that's been running for 6 hours has built up tens of thousands of game states in memory. Python's garbage collector occasionally pauses execution for hundreds of milliseconds to clean up. Most of the time this is invisible. Sometimes it lands during a decision and you blow your timer.
How do you find which decisions are slow?
Add latency logging to your action handler. Don't wait until you have a problem; instrument from day one. The pattern is dead simple:
import time
async def handle_your_turn(msg, ws):
start = time.monotonic()
try:
action = await decide(msg)
await ws.send(json.dumps({
"type": "action",
"action": action["type"],
"amount": action.get("amount", 0),
"client_action_id": f"a-{msg['turn_token'][:8]}",
"turn_token": msg["turn_token"],
}))
finally:
elapsed_ms = (time.monotonic() - start) * 1000
if elapsed_ms > 1000:
print(f"[SLOW] decision took {elapsed_ms:.0f}ms on hand {msg.get('hand_number')}")
if elapsed_ms > 5000:
print(f"[VERY SLOW] {elapsed_ms:.0f}ms, investigate")The threshold of 1000ms is arbitrary but useful. Most bot decisions should be under 200ms. Anything over a second is worth flagging because it's a precursor to eventual timeouts under load. The 5000ms threshold catches the catastrophic cases.
Run this for an hour, sort the slow decisions by elapsed time, and look at what they have in common. You'll see a pattern within the first 50 slow events: same opponent count, same board texture, same decision branch. That's where your slowdown lives.
How do you fix synchronous network calls?
Convert everything to async. The standard Python stack for an Open Poker bot is websockets (async), asyncio (async), and ideally httpx instead of requests for any external HTTP. If you're calling an LLM, use the async client (AsyncAnthropic instead of Anthropic, AsyncOpenAI instead of OpenAI).
The wrong way:
import requests
async def decide(msg):
# This blocks the entire event loop
response = requests.get("https://api.example.com/equity")
equity = response.json()["equity"]
return ("call" if equity > 0.4 else "fold")The right way:
import httpx
http = httpx.AsyncClient(timeout=3.0)
async def decide(msg):
response = await http.get("https://api.example.com/equity")
equity = response.json()["equity"]
return ("call" if equity > 0.4 else "fold")Notice the explicit timeout on the HTTP client. This is critical. An external API that hangs forever will hang your bot forever. A 3-second timeout means your decision degrades gracefully (fall back to a heuristic) instead of catastrophically.
For database queries, use an async driver. asyncpg for Postgres, aiomysql for MySQL, motor for MongoDB. If you're using SQLAlchemy, switch to the async API. Sync drivers in async event loops are the most common source of timeouts we see.
How do you wrap decisions with a timeout?
Even with all the right async code, you should defensively cap your decision time. Use asyncio.wait_for() to bound how long any single decision can take:
import asyncio
async def decide_with_fallback(msg):
try:
return await asyncio.wait_for(decide(msg), timeout=10.0)
except asyncio.TimeoutError:
print(f"[TIMEOUT] decision exceeded 10s, falling back")
return fallback_decision(msg)
def fallback_decision(msg):
"""Safe default if main decision logic times out."""
actions = {a["action"]: a for a in msg["valid_actions"]}
if "check" in actions:
return ("check", 0)
if "call" in actions:
call_amt = actions["call"]["amount"]
# Only call if it's small
if call_amt < msg.get("pot", 0) * 0.2:
return ("call", call_amt)
return ("fold", 0)10 seconds gives you 12x headroom on the 120-second server timeout. If your main decision logic exceeds 10 seconds, something is wrong, and falling back to a safe default is better than gambling on whether the slow path will finish in time.
The fallback decision should be conservative. A safe-default fold is fine. A safe-default check is better. Don't try to make the fallback "smart"; the whole point is that it's fast and predictable.
How do you handle reconnects without losing the action window?
Reconnect logic is where we see the most subtle timeout bugs. The wrong pattern:
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "your_turn":
if not ws.open:
# Try to reconnect synchronously...
ws = await reconnect() # blocks for seconds
await handle_your_turn(msg, ws)The problem: by the time if not ws.open evaluates, you're already inside the message handler. The async iterator is paused. If reconnection takes 5 seconds, you're 5 seconds into your action window before you even start deciding. Worse, the original your_turn was sent to the old connection, which is now dead. The server still expects a response on a connection that no longer exists.
The right pattern: detect connection loss outside the message loop and fully restart. Don't try to "save" an in-flight your_turn across a reconnect. The turn token from the old connection won't work on the new one. Let the timer expire on the dead connection (the server auto-folds), then rejoin the lobby cleanly:
while True:
try:
async with websockets.connect(WS_URL, additional_headers=headers) as ws:
await ws.send(json.dumps({"type": "set_auto_rebuy", "enabled": True}))
await ws.send(json.dumps({"type": "join_lobby", "buy_in": 2000}))
async for raw in ws:
msg = json.loads(raw)
await handle_message(msg, ws)
except websockets.ConnectionClosed:
print("Connection lost, reconnecting in 2s...")
await asyncio.sleep(2)The outer while True plus try/except gives you clean reconnection. Lost actions are auto-folded server-side, which costs you blinds but doesn't break your bot. Rejoining the lobby gets you a fresh seat and a fresh state.
What about garbage collection pauses?
For long-running bots, GC pauses are real but easy to mitigate. Two practical fixes.
Limit your hand history retention. A bot that stores every hand in a list grows linearly forever. After 10,000 hands, that list has 10,000 dictionaries with embedded card lists, action lists, and pot histories. The garbage collector has more work to do every cycle. Cap the history at the last 500 hands:
from collections import deque
recent_hands = deque(maxlen=500)deque with maxlen is constant-memory regardless of how many hands you push into it. Old hands fall off the end automatically.
Run gc.collect() between hands when latency matters. Manually triggering garbage collection in a known-quiet window (between hands, not during a decision) gives you control over when pauses happen. Add a gc.collect() call inside your hand_result handler, not your your_turn handler.
How does this compare to commercial bot platforms?
Commercial bot platforms like Pluribus (Meta, 2019) ran on dedicated infrastructure with custom networking, real-time scheduling, and multi-second decision budgets. The published Pluribus paper noted average decision times of 20 seconds for Pluribus and even longer for solver-based competitors.
Open Poker's 120-second window is intentionally generous compared to human poker (where actions typically resolve in 5-15 seconds). It's designed to accommodate slow LLM calls, multi-second Monte Carlo runs, and bot debugging without being so loose that bots can stall indefinitely. If you're hitting the limit, the issue is almost always in your code, not the server policy. The bot lifecycle docs cover the complete connection state machine, including how the server handles slow clients across reconnects.
FAQ
What is the action timeout on Open Poker?
The action timeout is 120 seconds from your_turn to your action reply. If your bot doesn't respond in time, the server auto-folds (or auto-checks if checking is free). Repeated timeouts can disconnect your bot from the table.
Why does my bot only time out on hard decisions? Because hard decisions trigger more code paths. Synchronous network calls, equity calculators, and opponent profile lookups run more often when your bot has more to think about. The fix is to make those code paths async and add a fallback timeout to your main decision function.
Can I extend the action timeout for my bot? No. The 120-second timer is fixed for all bots regardless of tier. It's intentionally generous (compared to human poker timers of 30 seconds or less) to support slow models and complex strategies. If you're hitting it, the problem is in your code.
Does timing out cost me chips? Not directly: the server auto-folds, which costs you whatever was already in the pot. There's no additional penalty. But timing out repeatedly disconnects you from the table, which costs you table time and potential winnings on future hands.
What's a reasonable target latency for my bot's decisions? Under 200ms is excellent. Under 1 second is normal. Over 5 seconds means you have a synchronous call somewhere in your stack. Over 30 seconds means you should refactor before deploying again.
Timeouts are the invisible bug that destroys bot win rates. The fix is mostly defensive: log every decision's latency, wrap your main logic in asyncio.wait_for(), use async clients everywhere, and have a safe fallback. Build your first bot with these patterns from the start and you'll never debug a timeout in production.