How to Return 503 with a Retry-After Header in ASP.NET Core
When MySQL is down, or a row is locked for too long, a 500 tells the client that your code has a bug and there's no point trying again. 503 Service Unavailable with Retry-After says the truth: not now, come back in a few seconds. An exception handler does it in one place, for the whole API.
Prerequisites
- An ASP.NET Core API on .NET 8 or later. I used ASP.NET Core 10, EF Core 10.0.14 and MySQL 9.7.4.
Step 1: Write the Handler
using System.Globalization;
using Dewiride.Inventory.Data;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Storage;
namespace Dewiride.Inventory.Api.Infrastructure;
/// <summary>
/// Answers 503 Service Unavailable with a Retry-After header when the database can't do the work
/// right now, so a client knows to try again. Any other exception stays a 500.
/// </summary>
public sealed class DatabaseUnavailableExceptionHandler(IProblemDetailsService problemDetails)
: IExceptionHandler
{
private const int RetryAfterSeconds = 5;
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
if (!IsTemporary(exception))
{
return false;
}
httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
httpContext.Response.Headers.RetryAfter = RetryAfterSeconds.ToString(CultureInfo.InvariantCulture);
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Status = StatusCodes.Status503ServiceUnavailable,
Title = "The database can't do this right now.",
Detail = $"Try again in {RetryAfterSeconds} seconds. Send the same Idempotency-Key, so a change can't be applied twice.",
},
});
}
private static bool IsTemporary(Exception exception) =>
exception is RetryLimitExceededException
|| exception.FindMySqlException() is { Number: 0 or 1205 or 3024 };
}

IsTemporarydecides which errors these are: the retries ran out, a lock wait, a query over its time limit, or a full connection pool. It usesFindMySqlException.return falsepasses anything else along, so real bugs stay500s.RetryAfteris a typed property on the response headers, in seconds.
Step 2: Register It
builder.Services.AddExceptionHandler<DatabaseUnavailableExceptionHandler>();
builder.Services.AddProblemDetails();
// ...
app.UseExceptionHandler();
Since .NET 10, a handled exception isn't logged by the middleware, and you probably want an outage in your logs, so I turn that back on.
Step 3: See It for an Outage
With MySQL stopped:
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.4",
"title": "The database can't do this right now.",
"status": 503,
"detail": "Try again in 5 seconds. Send the same Idempotency-Key, so a change can't be applied twice.",
"traceId": "00-0d0b17fbe59f130e78f5555cbee67b94-a6ae5b85e2201e17-00"
}

That came after 3 retries, about 4 seconds later.
Step 4: See It for a Locked Row
HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.4",
"title": "The database can't do this right now.",
"status": 503,
"detail": "Try again in 5 seconds. Send the same Idempotency-Key, so a change can't be applied twice.",
"traceId": "00-e03f01ea1a20b7b2e5d51de678b6ef84-46a1971eb84f36e2-00"
}
Step 5: See It for a Full Pool
HTTP/1.1 503 Service Unavailable
Retry-After: 5
curl exit 0, 15.127497 s, status 503
Step 6: Know What the Header Means
RFC 9110 says of the status: "The 503 (Service Unavailable) status code indicates that the server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay. The server MAY send a Retry-After header field (Section 10.2.3) to suggest an appropriate amount of time for the client to wait before retrying the request."
It's a suggestion, so well-behaved clients follow it, and others won't. For a change that a client repeats, send a key, so a repeat can't apply it twice.
Common Mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
Answering 503 for every exception | Real bugs look temporary | Name the errors |
| Sending a stack trace in the body | It leaks details | Use problem details |
Setting a long Retry-After for a short outage | Clients wait needlessly | Match your recovery time |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
The client still gets 500 | The error isn't in IsTemporary | Add it, after reading its number |
| The handler doesn't run | No UseExceptionHandler() | Add it |
| Nothing in the log | That's the .NET 10 default | The fix |
Conclusion
An IExceptionHandler that recognises temporary database errors, and answers 503 with Retry-After, tells clients the truth about an outage, in one place for the whole API.
Additional Resources
- RFC 9110: 503 Service Unavailable — the status.
- RFC 9110: Retry-After — the header.
