Skip to content

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.

var serverStack = ex.InnerException?.Data["InfoCarrier.ServerStackTrace"] as string;

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.