Fixed: EF Core Retry Places the Same Order Twice After a Lost COMMIT
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:
+----------------+----------------+--------------+
| 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
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
}
]
}
+----------------+----------------+--------------+
| QuantityOnHand | SumOfMovements | OrdersPlaced |
+----------------+----------------+--------------+
| 8 | 8 | 2 |
+----------------+----------------+--------------+
+-----+--------------+--------+-------------+
| Id | CustomerName | Status | TotalAmount |
+-----+--------------+--------+-------------+
| 280 | Ana Costa | Placed | 89.00 |
| 281 | Ana Costa | Placed | 89.00 |
+-----+--------------+--------+-------------+

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:
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:
// 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:
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
}
]
}
+----------------+----------------+--------------+
| QuantityOnHand | SumOfMovements | OrdersPlaced |
+----------------+----------------+--------------+
| 7 | 7 | 3 |
+----------------+----------------+--------------+

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
| Mistake | What happens | Do this instead |
|---|---|---|
| Creating the key inside the delegate | Each attempt gets a new key | Create it once, outside |
| Having no unique index on the key | Two requests can both save it | Add one |
| Turning off retries to avoid this | Every dropped connection fails the request | Make the retry safe |
| Relying on the client's key alone | Clients that send none are unprotected | Fall back to a new Guid |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Two rows, and the first attempt failed | The tracker kept the first entity | Clear it |
409 already exists for a row you just created | A unique index caught the repeat | Add the key check |
Duplicate entry on the key column | Two requests raced with one key | Catch 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
- Transaction commit failure and the idempotency issue — the options.
- The Idempotency-Key HTTP Header Field — the header.
