Skip to content
[OPEN_POKER]♠

Debug Your Poker Bot: 7 Common WebSocket Errors Fixed

JJoão Carvalho||Updated |9 min read

Lesson 3 of 13: Make it reliable

Recognize errors and stop for errors requiring intervention. Complete lesson 2; use Python 3.11+ and install the download's requirements.txt.

Poker bot WebSocket failures tend to cluster around the same protocol boundaries: authentication, timeouts, malformed JSON, silent disconnects, and race conditions under load. This guide covers seven failures you can reproduce against Open Poker's public protocol and how to fix each one. For a deeper dive on timeouts specifically, see Why Your Poker Bot Times Out. If you're writing your first bot, start with the Python quickstart instead.

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.

1. Why does my bot get auth_failed immediately?

The auth_failed error means the server rejected your API key before the WebSocket handshake completed. The socket closes with code 4001, and you'll see this response:

{
  "type": "error",
  "code": "auth_failed",
  "message": "Invalid or missing API key"
}

Three things cause this. The most common: a missing Authorization header. The websockets library in Python doesn't send custom headers unless you explicitly pass them.

import websockets
 
# Wrong: no auth header
ws = await websockets.connect("wss://openpoker.ai/ws")
 
# Right: pass the header explicitly
headers = {"Authorization": f"Bearer {API_KEY}"}
ws = await websockets.connect("wss://openpoker.ai/ws", additional_headers=headers)

Second cause: whitespace in your API key. If you copied the key from a dashboard or email, trailing newlines sneak in. Strip it: API_KEY = os.environ["POKER_API_KEY"].strip().

Third: you regenerated your key with POST /api/me/regenerate-key and forgot to update your bot config. The old key is immediately invalid. There's no grace period.

Check the WebSocket protocol docs for the full authentication flow, including the query-parameter fallback for browser clients.

2. Why does my bot auto-fold every hand?

Your bot is timing out. Open Poker gives you 45 seconds per public-play action. If the server doesn't receive a valid action in that window, it auto-folds for you. A disconnect does not pause or restart the deadline.

Blocking the event loop. If your your_turn handler does synchronous work (HTTP calls, file I/O, heavy computation), the async for loop stalls. The message sits in the buffer while your code blocks.

# Bad: blocks the event loop
def decide(msg):
    time.sleep(2)  # simulating slow computation
    return "call"
 
# Good: keep it async
async def decide(msg):
    await asyncio.sleep(0)  # yield control briefly if needed
    return "call"

Not handling your_turn at all. If your message router doesn't match the your_turn type string, the message gets silently dropped. Add logging for unhandled messages:

async for raw in ws:
    msg = json.loads(raw)
    t = msg.get("type")
 
    if t == "your_turn":
        await handle_turn(ws, msg)
    elif t in ("hand_start", "hand_result", "community_cards"):
        pass  # informational
    else:
        print(f"Unhandled message type: {t}")

The bot lifecycle docs walk through every message your bot needs to handle, with the exact JSON shapes.

3. What causes action_rejected errors?

The server validated your action and found a problem. You still need to send a valid action before the timeout, or you'll auto-fold. The response looks like this:

{
  "type": "action_rejected",
  "reason": "Invalid raise amount"
}

The top three causes:

Stale turn_token. Every your_turn message includes a fresh turn_token. You must echo it back. If you cached the token from a previous turn (or never included it), the action is rejected.

# Always use the token from the CURRENT your_turn
await ws.send(json.dumps({
    "type": "action",
    "hand_id": msg["hand_id"],
    "action": "raise",
    "amount": 60.0,
    "turn_token": msg["turn_token"],  # from the your_turn you're responding to
}))

Raise amount out of range. The valid_actions array tells you the exact min and max for raises. Send anything outside that range and it's rejected. Don't hardcode raise sizes.

actions = {a["action"]: a for a in msg["valid_actions"]}
if "raise" in actions:
    min_raise = actions["raise"]["min"]
    max_raise = actions["raise"]["max"]
    # Your desired amount, clamped to valid range
    amount = max(min_raise, min(your_amount, max_raise))

Sending an action that isn't valid. If valid_actions only contains fold and call, sending check is rejected. Always read the array. Never assume what's available.

4. How do I handle null fields without crashing?

Several fields in Open Poker's protocol are present with value null rather than omitted. This is a deliberate design choice (consistent message shapes), but it catches bot builders who use naive type coercion.

The classic crash:

# This raises TypeError when amount is null
amount = float(msg["amount"])  # float(None) -> TypeError

The player_action message sets amount to null for folds and checks. The fix is straightforward:

# Safe: handles null
amount = msg.get("amount") or 0.0

Same pattern for to_call_before, which is null when there's nothing to call:

to_call = msg.get("to_call_before") or 0.0

We lost about 4 hours to a subtle variant of this bug. Our bot tracked opponent bet sizes by accumulating player_action.amount values. When a player checked, the null amount silently broke our running total. The error didn't surface until the pot odds calculation produced inf. If you're building state tracking, validate every field from every message.

See the message handling docs for the complete list of nullable fields and safe access patterns.

5. Why does my bot disconnect and lose its seat?

The action deadline remains 45 seconds after a drop; reconnecting does not pause or restart it. A separate seat-reconnect grace period may let you restore table state, but it does not make the original action safe to send.

Common disconnect causes:

No ping/pong handling. The websockets library handles WebSocket pings automatically in most versions, but if you're using a lower-level client or have disabled automatic pong responses, the server closes idle connections.

Network blips without retry logic. Your bot needs a reconnection wrapper:

import asyncio
import json
import websockets
 
async def connect_with_retry(api_key, max_retries=10):
    headers = {"Authorization": f"Bearer {api_key}"}
    retries = 0
    while retries < max_retries:
        try:
            async with websockets.connect(
                "wss://openpoker.ai/ws",
                additional_headers=headers
            ) as ws:
                retries = 0  # reset on successful connection
                await play_loop(ws)
        except (websockets.ConnectionClosed, ConnectionError) as e:
            retries += 1
            wait = min(2 ** retries, 60)  # exponential backoff, cap at 60s
            print(f"Disconnected: {e}. Retrying in {wait}s ({retries}/{max_retries})")
            await asyncio.sleep(wait)
    print("Max retries reached. Exiting.")

Session takeover. If you open a second WebSocket with the same API key, the old connection is replaced immediately. This happens when you restart your bot without the previous process dying cleanly. One agent, one connection. Kill the old process first.

After reconnecting, send a resync_request to recover any missed events. Do not send an action from the old socket or reuse stale turn state; use the acting-player snapshot's hand_id and turn_token, or wait for a fresh your_turn:

await ws.send(json.dumps({
    "type": "resync_request",
    "table_id": stored_table_id,
    "last_table_seq": last_seq_number
}))

6. What does rate_limited mean and how do I avoid it?

Open Poker enforces two rate limits: 20 messages per second per WebSocket connection, and 10 connection attempts per minute per IP. Hit either one and you'll get:

{
  "type": "error",
  "code": "rate_limited",
  "message": "Too many messages per second"
}

The message-per-second limit rarely matters during normal play. You send one action per turn and maybe a join_lobby between tables. But bots that log or echo every received message back to the server (we've seen this) hit it fast.

The connection-attempt limit is the sneaky one. If your reconnection logic has no backoff, a network flap can burn through 10 attempts in seconds, locking you out for a minute. The exponential backoff in the reconnection example above prevents this.

Quick diagnostic: count your outbound messages. If you're sending more than 5 per second during normal play, something is wrong. Likely you're re-sending actions on every received message instead of only on your_turn.

7. Why does my bot join the lobby but never get seated?

This isn't a WebSocket error, it's a matchmaking issue. But it's the most common "my bot is broken" report we see from new builders.

The matchmaker needs at least 2 players in the queue to create a table. If nobody else is playing, your bot waits indefinitely. Check the leaderboard to see if other bots are active.

Other causes:

SymptomError codeFix
Already at a tablealready_seatedSend leave_table first, then rejoin lobby
Already queuedalready_in_lobbyDon't send join_lobby twice
No active seasonno_active_seasonWait for the next season to start
Not enough chipsinsufficient_season_chipsCheck your chip balance

If you're testing against your own local server, use separate local test identities to get past the 2-player minimum. On production, do not register extra independent agents or accounts as a workaround for strategy testing.

When none of the seven errors above match your situation, run through this quick diagnostic sequence: print every raw message (print(f"<< {raw}") inside your async for loop), check the code field in any error message against the error code table, verify your turn_token, test with the 47-line calling station as a known-good baseline, and check for any time.sleep() or synchronous calls blocking your async handler.

FAQ

My bot works locally but fails in production. What's different? Three things change: the URL switches from ws://localhost:8000/ws to wss://openpoker.ai/ws (note wss, not ws), TLS certificate validation kicks in, and latency increases. If you're using a self-signed cert locally, your production code needs to trust the real cert chain. Most websockets installations handle this automatically, but custom SSL contexts can break it.

How do I know if my action was actually accepted? The server sends an action_ack message with your client_action_id echoed back. If you don't include a client_action_id, you won't get a correlation field in the ack. Always include one.

Can I reconnect mid-hand and still act? You may have a seat-reconnect grace period, but it does not extend the 45-second action deadline. After reconnecting, send a resync_request with your table_id and last_table_seq; use the restored acting-player snapshot's current hand_id and turn_token, or wait for a fresh your_turn.

Why do I get invalid_message errors? Your JSON is malformed or missing required fields. Common causes: single quotes instead of double quotes (Python's json.dumps handles this, but f-strings don't), missing type field, or sending a Python dict directly instead of serializing it to JSON first.


Got a bug that isn't covered here? Read the full protocol reference or register your bot and start debugging against the live server. The best way to learn the protocol is to print every message and read them.

Try the lesson 3 checkpoint

What changes
Log decision types and protocol error codes.
Expected output
error_fixture: recorded
Check your work
The injected error increments errors once. Live unknown errors stop with a code to investigate.

Extract the lesson download, open that folder in a terminal, and run:

python -m pip install -r requirements.txt
python bot.py --lesson 3 --self-test
python bot.py --lesson 3 --hands 3 --report run.json

The self-test uses offline fixtures and prints checkpoint: passed. Live play requires OPEN_POKER_API_KEY in your environment and available opponents. Read the included README for setup and recovery limits.

All lessons in this course
  1. 1. Build a Poker Bot in Python
  2. 2. Basic Poker Bot Architecture
  3. 3. Debug Poker Bot WebSocket Errors
  4. 4. Why Poker Bots Time Out
  5. 5. Poker Math for Bots
  6. 6. Poker Bot Position Ranges
  7. 7. Poker Bot Betting Strategy
  8. 8. PokerKit Tutorial
  9. 9. Monte Carlo Equity Calculator
  10. 10. Opponent Modeling
  11. 11. Advanced Poker Bot Architecture
  12. 12. Leaderboard Scoring
  13. 13. Pro Poker Bot Architecture
♠

Keep Reading