Skip to main content

Fixed: EF Core Retry Places the Same Order Twice After a Lost COMMIT

· 6 min read
Jagdish Kumawat
Founder @ Dewiride

One request answered 201, and the database had two orders, with the stock taken twice. MySQL had committed the first attempt, the connection dropped before the reply arrived, and EF Core's retry, knowing only that the commit had failed, did it all again. Retrying is only safe when each attempt can tell that an earlier one already worked.

What I Was Doing​

My .NET C# Web API uses EF Core 10.0.14 and retries when a connection drops. Placing an order takes stock and creates the order in one transaction. Product 14 had 10 on hand:

Output
+----------------+----------------+--------------+
| QuantityOnHand | SumOfMovements | OrdersPlaced |
+----------------+----------------+--------------+
| 10 | 10 | 0 |
+----------------+----------------+--------------+

An order for 1 came in, and the connection dropped just after MySQL had committed it.

What I Expected​

One order, and 9 on hand.

What I Got​

Response
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/v1/orders/281

{
"id": 281,
"customerName": "Ana Costa",
"customerEmail": "ana@example.com",
"status": "Placed",
"totalAmount": 89.00,
"placedAtUtc": "2026-11-05T05:21:45.017979Z",
"fulfilledAtUtc": null,
"cancelledAtUtc": null,
"lines": [
{
"productId": 14,
"quantity": 1,
"unitPrice": 89.00
}
]
}
Output (the product's numbers)
+----------------+----------------+--------------+
| QuantityOnHand | SumOfMovements | OrdersPlaced |
+----------------+----------------+--------------+
| 8 | 8 | 2 |
+----------------+----------------+--------------+
Output (the orders)
+-----+--------------+--------+-------------+
| Id | CustomerName | Status | TotalAmount |
+-----+--------------+--------+-------------+
| 280 | Ana Costa | Placed | 89.00 |
| 281 | Ana Costa | Placed | 89.00 |
+-----+--------------+--------+-------------+

The orders table with two identical orders for Ana Costa, Ids 280 and 281, and the product showing 8 on hand where 9 was expected.

A 201 for one order, two orders in the table, and two units gone.

What I Checked​

The log. One retry, for a dropped connection:

API log
A transient exception occurred during execution. The operation will be retried after 0ms.
MySql.Data.MySqlClient.MySqlException (0x80004005): Fatal error encountered during command execution

The EF Core docs. This is a known case, under Transaction commit failure and the idempotency issue: "In general, when there is a connection failure the current transaction is rolled back. However, if the connection is dropped while the transaction is being committed the resulting state of the transaction is unknown."

The page's advice includes adding a way to check whether the work already happened.

Why It Happens​

From the driver's point of view, COMMIT went out and no answer came back. That looks the same whether MySQL committed or not. So EF Core treats it like any dropped connection, and runs the delegate again, and every statement in it is valid the second time too.

This doesn't only affect orders. Any retried unit with no natural unique key can do it. A stock receipt of 10 added 20 here, and a category with a unique name got a 409 for a row the same request had just created.

The Fix​

Give each request a key, and save it with the work. The first thing each attempt does is look for that key:

src/Dewiride.Inventory.Api/Features/Orders/OrderEndpoints.cs (excerpt)
// Every attempt below shares this key. With no key from the client, it still protects
// the request from itself when a retry repeats an attempt that really did commit.
var key = idempotencyKey ?? Guid.NewGuid().ToString("N");

var strategy = db.Database.CreateExecutionStrategy();

return await strategy.ExecuteAsync(
async Task<Results<Created<OrderResponse>, Ok<OrderResponse>, ProblemHttpResult>> (CancellationToken ct) =>
{
db.ChangeTracker.Clear();

// A repeat of a request that already worked returns the same order. This is what
// makes a retry safe when the connection dropped before the COMMIT's reply arrived.
if (await FindByIdempotencyKeyAsync(db, key, ct) is { } existing)
{
return TypedResults.Ok(existing);
}

The key is created once, outside the delegate, so every attempt sees the same one. A client can send its own in an Idempotency-Key header, which also protects it against repeating the request itself. The column has a unique index, so two attempts can't both save it.

stock_movements needed the same key for receipts, with a migration to add it.

After the Fix​

The same lost reply, with the same request:

Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
"id": 282,
"customerName": "Ana Costa",
"customerEmail": "ana@example.com",
"status": "Placed",
"totalAmount": 89.00,
"placedAtUtc": "2026-11-05T05:21:58.620388Z",
"fulfilledAtUtc": null,
"cancelledAtUtc": null,
"lines": [
{
"productId": 14,
"quantity": 1,
"unitPrice": 89.00
}
]
}
Output (the product's numbers)
+----------------+----------------+--------------+
| QuantityOnHand | SumOfMovements | OrdersPlaced |
+----------------+----------------+--------------+
| 7 | 7 | 3 |
+----------------+----------------+--------------+

The response answering 200 OK with one order, and the product's numbers showing one more unit taken and one more order.

Before this request there were 8 on hand and 2 orders. After it, there are 7 and 3: one order, one unit. The answer is 200 here, not 201, because the retry found the first attempt's order and returned it.

Common Mistakes​

MistakeWhat happensDo this instead
Creating the key inside the delegateEach attempt gets a new keyCreate it once, outside
Having no unique index on the keyTwo requests can both save itAdd one
Turning off retries to avoid thisEvery dropped connection fails the requestMake the retry safe
Relying on the client's key aloneClients that send none are unprotectedFall back to a new Guid

Troubleshooting​

SymptomLikely causeFix
Two rows, and the first attempt failedThe tracker kept the first entityClear it
409 already exists for a row you just createdA unique index caught the repeatAdd the key check
Duplicate entry on the key columnTwo requests raced with one keyCatch it, and return the first

FAQ​

Is there something built in?​

Yes: ExecuteInTransactionAsync takes a second delegate that checks whether the work succeeded. You still need something to check, so the key is the same idea.

Conclusion​

A dropped connection during COMMIT means the outcome is unknown, and a retry may repeat work that already happened. Save a per-request key with the work, and check for it at the start of each attempt.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.