Crypto Trading Systems Bug Report

A timeout and a retry opened two positions: idempotent order placement with client order ids

During a volatile minute, our place-order request timed out and the bot retried. Both requests reached the exchange, and we held twice the intended position. A timeout does not mean the order failed. Client order ids, query-before-retry and an unknown state fixed it.

Ryan Pham Ryan Pham @ryan_bugs · July 09, 2025 · 5 min read · 16 Views
Banner: duplicate trading orders caused by retrying a request that timed out

A reconciliation alert fired: the bot's position in ETHUSDT was twice what its strategy state said. The order log showed one intended order and two fills:

14:31:02.118 INFO  place_order ETHUSDT BUY MARKET qty=2.000
14:31:07.120 WARN  place_order timeout after 5000 ms, retrying
14:31:07.391 INFO  place_order ETHUSDT BUY MARKET qty=2.000 -> orderId=4820019123 FILLED

The exchange's trade history showed two orders, 4820018877 at 14:31:06.9 and 4820019123 at 14:31:07.4.

Reproducing it

On the testnet, we added an artificial 6-second delay in a proxy between the bot and the exchange for the first request only. The first request reached the exchange and executed; our client gave up after 5 seconds and sent the retry, which also executed.

What we ruled out

  • A double call from strategy logic. The strategy emitted one order intent.
  • Exchange duplication. The two orders had different request timestamps matching our two attempts.

Root cause

Our HTTP client treated a timeout as "the order failed" and retried. A timeout only means we did not get a response in time. The request may have been lost, rejected, or fully executed. For non-idempotent operations like placing orders, a blind retry after an unknown outcome can do the operation twice. Our orders had no client-assigned id, so the exchange had no way to recognise the retry as the same order.

What is happening under the hood

Order placement is a write. Distributed systems give three possible outcomes for a write request: success, failure, or unknown (timeout, connection reset, 5xx after the request was processed). Only the first two are safe to act on directly.

Most exchanges let you attach a client order id to each order (newClientOrderId on Binance, clientOrderId on many others). It serves two purposes:

  • Lookup: you can query the order by your own id, without knowing the exchange's order id.
  • Duplicate protection on some venues: an exchange may reject a new order with a client id already used by an open order. Behaviour varies by exchange and order state, so do not rely on it alone.

Environment

Component Version / value
Bot Python 3.12, httpx with 5 s timeout and automatic retries
Exchange Binance Spot REST
Orders Market and limit, no client order id
Market High volatility period, exchange latency elevated

Minimal reproduction

Place a market order through a proxy that delays the response beyond the client timeout, with retries enabled. Two orders execute.

The fix

1. Assign a client order id before the first attempt, derived from the strategy's intent so that any retry of the same intent carries the same id:

import hashlib

def client_order_id(strategy: str, intent_id: str) -> str:
    # Binance allows up to 36 chars from a limited character set
    return f"{strategy[:8]}-{hashlib.sha1(intent_id.encode()).hexdigest()[:24]}"

2. Never auto-retry order placement on unknown outcomes. Retries for writes were removed from the generic HTTP client. Order placement has its own logic:

async def place_order_once(intent) -> OrderState:
    cid = client_order_id(intent.strategy, intent.id)
    try:
        resp = await api.new_order(symbol=intent.symbol, side=intent.side, type=intent.type,
                                   quantity=intent.qty, newClientOrderId=cid, timeout=5)
        return OrderState.from_response(resp)
    except (TimeoutError, ConnectionError, ServerError):
        return OrderState.unknown(cid)

async def resolve_unknown(cid: str, symbol: str, attempts: int = 10) -> OrderState:
    for i in range(attempts):
        try:
            remote = await api.get_order(symbol=symbol, origClientOrderId=cid)
            return OrderState.from_response(remote)          # it exists: do not resend
        except OrderNotFound:
            if i >= 2:                                       # give the exchange time to register it
                return OrderState.not_placed(cid)
        await asyncio.sleep(0.5 * (i + 1))
    raise StillUnknown(cid)

Only when the order is confirmed not to exist does the strategy decide whether to place it again, with the same client order id.

3. Strategy waits on unknown. While an order is in UNKNOWN, the strategy does not issue new orders for that symbol. If StillUnknown is raised, the bot pauses the symbol and alerts a human.

4. Reconcile fills by client order id prefix, so any order the bot placed, even one it lost track of, is attributed correctly.

Alternatives and trade-offs

  • WebSocket order placement APIs where offered. Lower latency, same unknown-outcome problem on disconnects.
  • Limit orders instead of market orders for non-urgent entries. A duplicate limit order is easier to detect and cancel before it fills.

How to test the fix

The delayed-proxy test on testnet now produces exactly one order: the bot reports UNKNOWN, queries by client order id, finds the filled order, and records it. A second test drops the request entirely; the query returns not found and the bot places the order once.

Confirming in production

  • No duplicate positions since the change. Unknown outcomes occur a few times per week, almost all resolved within a second by query.
  • Reconciliation differences attributable to order placement dropped to zero.

Regression guard

  • The HTTP client refuses to auto-retry non-GET requests; order code must use the order manager.
  • Every order sent without a client order id fails a check in the order manager.
  • Chaos tests with delayed and dropped responses run against the testnet weekly.

Security implications

Duplicate orders directly create unintended exposure, a financial risk. Treating unknown outcomes explicitly and pausing when uncertain are core risk controls. Separately, client order ids should not encode sensitive information, since they appear in exchange records and exports.

Prevention checklist

  • Never blindly retry order placement after a timeout.
  • Assign a client order id per intent, before the first attempt.
  • Resolve unknown outcomes by querying with the client order id.
  • Block new orders on a symbol while an order is unknown.
  • Test with delayed and dropped responses.

References

Add a label

#idempotency #retries #binance-api #trading-bot #order-management
Written by
Ryan Pham @ryan_bugs

I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.