Skip to main content

How to Return 503 with a Retry-After Header in ASP.NET Core

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

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​

src/Dewiride.Inventory.Api/Infrastructure/DatabaseUnavailableExceptionHandler.cs
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 };
}

The DatabaseUnavailableExceptionHandler class in a macOS-style code window, setting status 503 and the RetryAfter header, and writing a problem details body.

  • IsTemporary decides which errors these are: the retries ran out, a lock wait, a query over its time limit, or a full connection pool. It uses FindMySqlException.
  • return false passes anything else along, so real bugs stay 500s.
  • RetryAfter is a typed property on the response headers, in seconds.

Step 2: Register It​

src/Dewiride.Inventory.Api/Program.cs (excerpt)
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:

Response
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"
}

A response of 503 Service Unavailable with Retry-After: 5 and a problem details body saying to try again in 5 seconds.

That came after 3 retries, about 4 seconds later.

Step 4: See It for a Locked Row​

Response
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​

Output
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​

MistakeWhat happensDo this instead
Answering 503 for every exceptionReal bugs look temporaryName the errors
Sending a stack trace in the bodyIt leaks detailsUse problem details
Setting a long Retry-After for a short outageClients wait needlesslyMatch your recovery time

Troubleshooting​

SymptomLikely causeFix
The client still gets 500The error isn't in IsTemporaryAdd it, after reading its number
The handler doesn't runNo UseExceptionHandler()Add it
Nothing in the logThat's the .NET 10 defaultThe 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​

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.