Handling errors¶
The server can fail, and so can the trip to it. Those are different exception types, because they call for different responses.
A failure the server reported¶
The library carries the server's exception back as data and raises it again on the client, with its message and its inner chain. You catch what you would catch locally, and where the client has the exception's type loaded you get that type.
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
// Someone else changed the row.
}
catch (DbUpdateException ex)
{
// A constraint, a trigger, a server-side validation.
}
So a SqlException, in a client that references no database driver, arrives as
InfoCarrierServerException with the thrown type's name in a property. No catch clause ever
needs to reference a database driver.
catch (DbUpdateException ex) when (ex.InnerException is InfoCarrierServerException server)
{
logger.LogError(
"server threw {Type}: {Message}",
server.ServerExceptionTypeName, // e.g. "Microsoft.Data.Sqlite.SqliteException"
server.Message);
}
A real chain from a foreign-key violation looks like this:
DbUpdateException the client's own, from EF
└── DbUpdateException the server's, rebuilt with its message
└── InfoCarrierServerException ServerExceptionTypeName = "Microsoft.Data.Sqlite.SqliteException"
The library keeps the server's stack trace and does not splice it into the client-side exception's
own stack, which would misreport where your client is. It travels in Exception.Data, and it is the
only view you get of what happened on the other side, so log it.
A failure of the journey¶
If the request never reached a server, or what came back was not a valid response, you get
InfoCarrierTransportException. This is not a database error and must not be handled as one: the
data is unknown, not wrong.
Retrying a read is safe. Retrying anything that writes is not, because the failure does not
tell you whether the server committed: the request may have died on the way out, or the answer on
the way back. That covers more than SaveChanges. ExecuteUpdate and ExecuteDelete look like
queries and are writes, and so is a transaction's Commit.
Knowing whether a write committed¶
Save a marker row with your changes, and look for it after a transport failure. The server
runs one SaveChanges as one unit, so the marker commits exactly when your changes do, and looking
for it is a read. EF Core documents the same pattern:
manually track the transaction.
SaveMarker is one entity with a Guid key, mapped in ShopContext on both halves.
var marker = new SaveMarker { Id = Guid.NewGuid() };
context.SaveMarkers.Add(marker);
context.Orders.Add(order);
try
{
await context.SaveChangesAsync();
}
catch (Exception ex) when (ex is InfoCarrierTransportException
|| ex.InnerException is InfoCarrierTransportException)
{
if (await context.SaveMarkers.AsNoTracking().AnyAsync(m => m.Id == marker.Id))
{
context.ChangeTracker.AcceptAllChanges(); // it committed; only the answer was lost
}
else
{
await context.SaveChangesAsync(); // it did not; send it again
}
}
Two racing attempts cannot both commit: the second insert of the marker fails on its key and rolls back the rest of that save.
This relies on SaveChanges being one unit, which it is on a relational database and on MongoDB.
On Cosmos DB, EF Core 10 writes each document separately, and a marker cannot tell you which changes
landed.
When the answer is lost, keys the database generated never reach the client: reload the rows, or generate keys on the client. Keep the marker's id where it survives a restart of your application, such as local storage, until you have looked for it. Delete old markers yourself.
For ExecuteUpdate, ExecuteDelete and anything else inside a transaction, save the marker inside
the transaction too. If the commit fails, look for the marker rather than calling Commit again.
If a transaction was open when the connection dropped, do not retry into it. The server holds it on the instance that began it, and a retry landing anywhere else cannot join it. See Transactions.
try
{
List<Customer> customers = await context.Customers.ToListAsync();
}
catch (InfoCarrierTransportException ex)
{
// Offline, DNS, TLS, a 502 from a proxy, a captive portal returning HTML.
logger.LogWarning(ex, "the application server could not be reached");
}
The underlying failure, an HttpRequestException or a serialization error, arrives as
InnerException. When the server answers with a non-success status, the message carries the status
code and the response body.
Depending on where the failure surfaces, EF may wrap the transport exception, so check both:
catch (Exception ex) when (ex is InfoCarrierTransportException
|| ex.InnerException is InfoCarrierTransportException)
Which is which¶
| Exception | Meaning | Sensible response |
|---|---|---|
DbUpdateException, DbUpdateConcurrencyException |
The server ran your work and the database refused it | Handle as you would locally |
InvalidOperationException |
The query could not be translated, or the model disagrees | Fix the query; do not retry |
InfoCarrierServerException |
The server's own exception type is not available here | Log ServerExceptionTypeName; treat as its outer type |
InfoCarrierTransportException |
The request did not complete | Retry a read. For a write, first find out whether it committed |
Catch the type, not the message: message text is not a supported contract on any EF Core provider, and a couple of messages here are worded differently from other providers'. See Limitations.
Cancellation¶
Every async method takes a CancellationToken, and it travels to the server, which stops the query
it is running. A cancelled request raises OperationCanceledException and is never reported as a
transport failure.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
List<Customer> customers = await context.Customers.ToListAsync(cts.Token);
What the server does not tell you¶
A fault carries the type name, the message, the inner chain and the stack trace. The library adds nothing to that: no configuration, no connection string, no server path beyond whatever the stack trace itself holds, which with symbols deployed is your build's source paths.
Your own messages travel verbatim, and a provider's exception is your own message here. A
SqlException names the server instance and the database in its text, and that text reaches the
client. Catch and rewrite at the server boundary anything a client should not read. See
Security.
Resolving a fault's type is also the boundary on the return direction: a fault cannot make the client load an assembly, or construct anything that is not an exception.