Skip to main content

Fixed: EF Core EnableRetryOnFailure Doesn't Retry MySQL Errors with Oracle's MySql.EntityFrameworkCore

· 7 min read
Jagdish Kumawat
Founder @ Dewiride

I had EnableRetryOnFailure turned on, so I assumed a deadlock or a short outage would be retried. With Oracle's MySQL provider, nothing was retried, ever, and no message says so. The provider asks each error whether it's temporary, and MySqlException always answers no. The fix is one argument.

What I Was Doing​

My .NET C# Web API uses EF Core 10.0.14 and MySql.EntityFrameworkCore 10.0.12, with retries turned on like this:

src/Dewiride.Inventory.Data/InventoryDataServiceCollectionExtensions.cs (excerpt)
mySql.EnableRetryOnFailure(
maxRetryCount: database.MaxRetryCount,
maxRetryDelay: TimeSpan.FromSeconds(database.MaxRetryDelaySeconds),
errorNumbersToAdd: []);

Two requests updated the same two products in opposite orders, which is a real deadlock. Here's one of them, inside CreateExecutionStrategy().ExecuteAsync:

An endpoint (excerpt)
await strategy.ExecuteAsync(
async ct =>
{
attempt++;
log.LogInformation("Attempt {Attempt}", attempt);

await using var transaction = await db.Database.BeginTransactionAsync(ct);

await db.Products.Where(p => p.Id == a)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ReorderLevel, p => p.ReorderLevel + 1), ct);

await Task.Delay(500, ct);

await db.Products.Where(p => p.Id == b)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ReorderLevel, p => p.ReorderLevel + 1), ct);

await transaction.CommitAsync(ct);
},
cancellationToken);

What I Expected​

One request would hit a deadlock, and EF Core would run it again.

What I Got​

Output (the two requests' status codes)
a 500
b 200
API log
Attempt 1
Attempt 1
MySql.Data.MySqlClient.MySqlException (0x80004005): Deadlock found when trying to get lock; try restarting transaction

Two requests, one answering 500 with a deadlock in the log and only Attempt 1 written, with no line about a retry.

One attempt each, no retry, and a 500. Stopping MySQL gave the same result: the request failed in under a tenth of a second, with no retry at all:

Output (status, and how long it took)
500 0.047864s

What I Checked​

What the error says about itself. .NET's DbException.IsTransient "Indicates whether the error represented by this DbException could be a transient error, i.e. if retrying the triggering operation may succeed without any other change." So I had an endpoint catch the exception and report it, first for a real deadlock, then with MySQL stopped:

Output (the deadlock)
[{"type":"MySqlException","number":1213,"isTransient":false,"message":"Deadlock found when trying to get lock; try restarting transaction"}]
Output (MySQL stopped)
[{"type":"MySqlException","number":1042,"isTransient":false,"message":"Unable to connect to any of the specified MySQL hosts"},{"type":"SocketException","number":null,"isTransient":null,"message":"Connection refused"}]

isTransient is false for both, and these are the two errors you'd most want retried.

The provider's source. It's on GitHub, and it's short:

MySQLTransientExceptionDetector.cs (Oracle's source, tag 26.10.0)
public static bool ShouldRetryOn([NotNull] Exception ex)
=> ex is MySqlException mySqlException
? mySqlException.IsTransient
: ex is TimeoutException;

And the MySqlException class never overrides IsTransient, so it keeps the base class's false for every error. The retrying strategy does look at one more thing:

MySQLRetryingExecutionStrategy.cs (Oracle's source, tag 26.10.0)
protected override bool ShouldRetryOn(Exception exception)
=> exception is MySqlException mySqlException &&
_additionalErrorNumbers?.Contains(mySqlException.Number) == true
|| MySQLTransientExceptionDetector.ShouldRetryOn(exception);

That's errorNumbersToAdd.

Why It Happens​

EF Core's connection resiliency page says providers "can supply execution strategies tailored to their specific database failure conditions and optimal retry policies." With this provider, the part that knows which failures are temporary always says "not this one". So the only errors retried are the ones you name yourself, and an empty list names none.

The Fix​

List the error numbers you want retried:

src/Dewiride.Inventory.Data/InventoryDataServiceCollectionExtensions.cs (excerpt)
mySql.EnableRetryOnFailure(
maxRetryCount: database.MaxRetryCount,
maxRetryDelay: TimeSpan.FromSeconds(database.MaxRetryDelaySeconds),
errorNumbersToAdd: [1213, 1042]);
NumberError
1213Deadlock found when trying to get lock
1042Unable to connect to any of the specified MySQL hosts
1053Server shutdown in progress
1040Too many connections
0MySql.Data's number for a dropped connection, and for a full connection pool

During a restart, this list still lets some requests fail with a NullReferenceException, and it can't tell the two meanings of 0 apart. For both, a small custom execution strategy does the same job and more.

After the Fix​

Output (the two requests)
{"attempts":1}
200
{"attempts":2}
200
API log
Attempt 1
Attempt 1
A transient exception occurred during execution. The operation will be retried after 0ms.
Attempt 2

Both requests answering 200, one after 2 attempts, with the log line A transient exception occurred during execution. The operation will be retried after 0ms.

Both answered 200, and one took two attempts. With MySQL stopped, it now tries for a while before giving up:

Output (status, and how long it took)
500 4.734215s
API log
A transient exception occurred during execution. The operation will be retried after 0ms.
A transient exception occurred during execution. The operation will be retried after 1045ms.
A transient exception occurred during execution. The operation will be retried after 3124ms.

Common Mistakes​

MistakeWhat happensDo this instead
Assuming EnableRetryOnFailure() is enoughNothing is retriedList the numbers
Checking only that the code compilesA wrong list is silentLook for will be retried in the log
Adding 1205 (lock wait timeout)Every retry waits the whole limit againAnswer 503 instead
Reusing a scoped DbContext in a retryA row can be saved twiceCall ChangeTracker.Clear() first

Troubleshooting​

SymptomLikely causeFix
No will be retried line in the logThe error's number isn't listed, or the log level hides itCheck both. It's Information, in Microsoft.EntityFrameworkCore.Infrastructure
The maximum number of retries (3) was exceededIt retried, and kept failingThe fix
does not support user-initiated transactionsA transaction outside the strategyThe fix

FAQ​

Will this stay true in later versions?​

I checked MySql.Data 26.10.0 and MySql.EntityFrameworkCore 10.0.12. Check again after an upgrade: look for the will be retried line while something fails.

Conclusion​

With Oracle's provider, EnableRetryOnFailure retries no MySQL error by itself, because MySqlException.IsTransient is always false. Pass the numbers in errorNumbersToAdd, and check the log to see it working.

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.