Broker API HubMenu

Stage 4 of 4, lesson 24 of 31. Improver

Idempotency: do not send an order twice

See a retry open the same position twice on a mock broker, then prevent it by sending one reference with every attempt. It introduces idempotency, client references and a mock you can break on purpose.

Independent and unofficialCode run

Risk warning: CFDs and spread bets are complex instruments and come with a high risk of losing money rapidly due to leverage. You can lose money on these products, and each provider publishes its own loss figure under FCA rules. You should consider whether you understand how they work and whether you can afford to take the high risk of losing your money. Automated and API trading can amplify losses through bugs, connectivity failures and runaway orders.

You need: The lesson on retry and backoff. No broker account.

The lesson on retrying said that some failures are safe to retry. An order is the case where that needs most care. When a request times out you know only that no answer arrived. You do not know whether the order was acted on.

If it was, and your code sends it again, you now hold twice the position you meant to. Nothing looks wrong in the code. It retried, as it was written to.

An operation is idempotent when doing it twice has the same effect as doing it once. Reading your positions is idempotent. Placing an order is not, unless you make it so. The usual way is to make up a reference for each order you intend, once, before the first attempt, and to send the same reference with every retry, so that the other side can recognise a repeat and answer it without acting again.

The script shows both outcomes on a mock broker written inside the file, with a reply that gets lost on purpose. Nothing in it can reach a real broker. Whether a broker's API accepts a reference of this kind, and what it calls it, differs by broker: read its order endpoint before you rely on it. Where there is none, the safe pattern after a timeout is to read your positions or your activity first and resend only if the order is not there.

The code

# Idempotency: why sending the same order twice is dangerous, and how a reference prevents it.
# No broker, no account, no internet. The broker here is a mock that lives in this file.
# Nothing in this script can place a real order.

LOST_REPLIES = 1     # how many times the answer goes missing on its way back


class ReplyLost(Exception):
    """The request reached the broker and was acted on, but the answer never came back."""


class MockBroker:
    def __init__(self, lost_replies):
        self.positions = []
        self.answers = {}             # reference -> the answer already given for it
        self.replies_to_lose = lost_replies

    def place_order(self, market, direction, size, reference=None):
        if reference is not None and reference in self.answers:
            answer = self.answers[reference]      # seen before: same answer, no new position
        else:
            answer = {"dealId": "MOCKDEAL" + str(len(self.positions) + 1), "market": market, "direction": direction, "size": size}
            self.positions.append(answer)
            if reference is not None:
                self.answers[reference] = answer
        if self.replies_to_lose > 0:
            self.replies_to_lose -= 1
            raise ReplyLost("timed out waiting for the answer")
        return answer


def send_with_retry(broker, reference=None, max_attempts=4):
    for attempt in range(1, max_attempts + 1):
        try:
            return broker.place_order("FTSE 100 (mock)", "BUY", 1, reference)
        except ReplyLost as problem:
            print("  Attempt", attempt, "failed:", problem)
    raise RuntimeError("gave up")


print("Retrying with no reference:")
broker = MockBroker(LOST_REPLIES)
answer = send_with_retry(broker)
print("  Answer:", answer["dealId"])
print("  Positions now open on the mock:", len(broker.positions))

print("Retrying with the same reference each time:")
broker = MockBroker(LOST_REPLIES)
answer = send_with_retry(broker, reference="my-order-0001")
print("  Answer:", answer["dealId"])
print("  Positions now open on the mock:", len(broker.positions))

print("A new reference is a new order:")
send_with_retry(broker, reference="my-order-0002")
print("  Positions now open on the mock:", len(broker.positions))

What you should see

Retrying with no reference:
  Attempt 1 failed: timed out waiting for the answer
  Answer: MOCKDEAL2
  Positions now open on the mock: 2
Retrying with the same reference each time:
  Attempt 1 failed: timed out waiting for the answer
  Answer: MOCKDEAL1
  Positions now open on the mock: 1
A new reference is a new order:
  Positions now open on the mock: 2

Run with Python 3.12 on Windows. No network and no account.

Check yourself

Answer each question from the code above before you open it. The answers were written from the script on this page and what it printed.

Without a reference, why does the mock end up with two positions after one intended order?

The first attempt was acted on: place_order added the position and only then lost the reply. The retry looks like a new order to the mock, so it adds another and answers MOCKDEAL2.

With a reference, why is the answer MOCKDEAL1 even though it took two attempts?

The mock stored its first answer under my-order-0001. The second attempt carries the same reference, so the mock returns the stored answer and adds nothing.

Why does the last block open a second position?

It sends a different reference, my-order-0002, and a new reference means a new order. That is why the reference has to be made once for each order you intend and reused on every retry of it, never made fresh inside the retry loop.

Exercise

Make the reply get lost twice instead of once, by changing LOST_REPLIES to 2. How many positions will the retry with no reference open?

What the changed script prints
Retrying with no reference:
  Attempt 1 failed: timed out waiting for the answer
  Attempt 2 failed: timed out waiting for the answer
  Answer: MOCKDEAL3
  Positions now open on the mock: 3
Retrying with the same reference each time:
  Attempt 1 failed: timed out waiting for the answer
  Attempt 2 failed: timed out waiting for the answer
  Answer: MOCKDEAL1
  Positions now open on the mock: 1
A new reference is a new order:
  Positions now open on the mock: 2

Captured from a run of the solution on . Run with Python 3.12 on Windows. No network and no account.

One solution
# Exercise solution: the lesson script with the one change the exercise asks for.
# Idempotency: why sending the same order twice is dangerous, and how a reference prevents it.
# No broker, no account, no internet. The broker here is a mock that lives in this file.
# Nothing in this script can place a real order.

LOST_REPLIES = 2     # how many times the answer goes missing on its way back


class ReplyLost(Exception):
    """The request reached the broker and was acted on, but the answer never came back."""


class MockBroker:
    def __init__(self, lost_replies):
        self.positions = []
        self.answers = {}             # reference -> the answer already given for it
        self.replies_to_lose = lost_replies

    def place_order(self, market, direction, size, reference=None):
        if reference is not None and reference in self.answers:
            answer = self.answers[reference]      # seen before: same answer, no new position
        else:
            answer = {"dealId": "MOCKDEAL" + str(len(self.positions) + 1), "market": market, "direction": direction, "size": size}
            self.positions.append(answer)
            if reference is not None:
                self.answers[reference] = answer
        if self.replies_to_lose > 0:
            self.replies_to_lose -= 1
            raise ReplyLost("timed out waiting for the answer")
        return answer


def send_with_retry(broker, reference=None, max_attempts=4):
    for attempt in range(1, max_attempts + 1):
        try:
            return broker.place_order("FTSE 100 (mock)", "BUY", 1, reference)
        except ReplyLost as problem:
            print("  Attempt", attempt, "failed:", problem)
    raise RuntimeError("gave up")


print("Retrying with no reference:")
broker = MockBroker(LOST_REPLIES)
answer = send_with_retry(broker)
print("  Answer:", answer["dealId"])
print("  Positions now open on the mock:", len(broker.positions))

print("Retrying with the same reference each time:")
broker = MockBroker(LOST_REPLIES)
answer = send_with_retry(broker, reference="my-order-0001")
print("  Answer:", answer["dealId"])
print("  Positions now open on the mock:", len(broker.positions))

print("A new reference is a new order:")
send_with_retry(broker, reference="my-order-0002")
print("  Positions now open on the mock:", len(broker.positions))

Your progress

Next: Log what happened

Your marks are kept in this browser only, in its local storage. They are not sent anywhere, there is no account, and they will not follow you to another browser or device. To remove them, use Clear my progress on the learning track page, or clear this site's data in your browser's settings. Marking needs JavaScript. With scripts off the track reads the same, without the ticks.

Where this sits in the path

Stage 4 of 4: Building things that survive. Failure, limits, repeated orders, logs, settings, tests and backtests that do not fool you: what separates a script from a tool.

  1. Retry and backoff
  2. Stay under a rate limit
  3. Handle an error reply
  4. Idempotent ordersYou are here
  5. Log what happened
  6. Log your API calls
  7. Backtest an idea
  8. The look ahead mistake
  9. Settings from environment
  10. Test with saved replies
  11. A command line tool
  12. The capstone project

The whole path, every stage

Back: Handle an error replyNext: Log what happened