ASP.NET Core Response Guide

Same endpoint, two styles. Flip the toggle to see each scenario as a classic MVC controller or a Minimal API using TypedResults, and search by status code or by what went wrong.

Showing 11 of 11 entries

Get a resource by id

The default read endpoint: return the resource, or 404 when it does not exist.

200 404
app.MapGet("/products/{id:int}", async Task<Results<Ok<ProductDto>, NotFound>> (
    int id, IProductRepo repo, CancellationToken ct) =>
{
    var product = await repo.FindAsync(id, ct);
    return product is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(product.ToDto());
});

Create a resource

201 with a Location header pointing at the resource you just made.

201
app.MapPost("/products", async Task<Results<Created<ProductDto>, ValidationProblem>> (
    CreateProductRequest req, IProductRepo repo, CancellationToken ct) =>
{
    var product = await repo.AddAsync(req.ToEntity(), ct);
    return TypedResults.Created($"/products/{product.Id}", product.ToDto());
});

Validation failure

Reject malformed input with a ProblemDetails body describing each field.

400 422
app.MapPost("/products", async Task<Results<Created, ValidationProblem>> (
    CreateProductRequest req, IValidator<CreateProductRequest> validator, IProductRepo repo) =>
{
    var result = await validator.ValidateAsync(req);
    if (!result.IsValid)
        return TypedResults.ValidationProblem(result.ToDictionary());

    var product = await repo.AddAsync(req.ToEntity());
    return TypedResults.Created($"/products/{product.Id}");
});

No content on update

A successful write that has nothing useful to return.

204 404
app.MapPut("/products/{id:int}", async Task<Results<NoContent, NotFound>> (
    int id, UpdateProductRequest req, IProductRepo repo, CancellationToken ct) =>
{
    var updated = await repo.UpdateAsync(id, req, ct);
    return updated ? TypedResults.NoContent() : TypedResults.NotFound();
});

Unauthorized vs. forbidden

401 means "I do not know who you are". 403 means "I do, and you may not".

401 403
app.MapDelete("/products/{id:int}", async Task<Results<NoContent, ForbidHttpResult>> (
    int id, ClaimsPrincipal user, IProductRepo repo, CancellationToken ct) =>
{
    if (!user.HasClaim("scope", "products.write"))
        return TypedResults.Forbid();

    await repo.DeleteAsync(id, ct);
    return TypedResults.NoContent();
}).RequireAuthorization("CanDeleteProducts");

Multiple possible outcomes

The strongest argument for TypedResults: the signature lists every response.

200 404 409
app.MapPost("/products/{id:int}/publish",
    async Task<Results<Ok<ProductDto>, NotFound, Conflict<string>>> (
        int id, IProductService svc, CancellationToken ct) =>
{
    var result = await svc.PublishAsync(id, ct);
    return result switch
    {
        { NotFound: true } => TypedResults.NotFound(),
        { Conflict: true } => TypedResults.Conflict(result.Reason),
        _ => TypedResults.Ok(result.Dto),
    };
});

Rate limited

The code matters less than the Retry-After header - that is what callers need to back off correctly.

429
app.MapGet("/reports", async (IReportService reports, CancellationToken ct)
    => TypedResults.Ok(await reports.ListAsync(ct)))
   .RequireRateLimiting("per-user");

// Returning 429 yourself - e.g. a business quota rather than a request rate:
app.MapPost("/reports/export",
    async Task<Results<Accepted, StatusCodeHttpResult>> (
        IQuotaService quota, HttpContext ctx) =>
{
    if (!quota.TryConsume(ctx.User, out var retryAfter))
    {
        ctx.Response.Headers.RetryAfter =
            ((int)retryAfter.TotalSeconds).ToString(CultureInfo.InvariantCulture);
        return TypedResults.StatusCode(StatusCodes.Status429TooManyRequests);
    }

    return TypedResults.Accepted("/reports/export/status");
});

Client took too long to send the request

Configured on Kestrel rather than returned by hand. Contrast with 499.

408
// Same Kestrel configuration - it is a host concern, not an endpoint one.
// Per-endpoint deadlines are a different feature (.NET 8+ request timeouts):
builder.Services.AddRequestTimeouts(o =>
{
    o.DefaultPolicy = new RequestTimeoutPolicy
    {
        Timeout = TimeSpan.FromSeconds(30),
        TimeoutStatusCode = StatusCodes.Status408RequestTimeout,
    };
});

app.UseRequestTimeouts();

app.MapGet("/slow-report", async (IReportService reports, CancellationToken ct)
    => TypedResults.Ok(await reports.BuildAsync(ct)))
   .WithRequestTimeout(TimeSpan.FromSeconds(10));

Downstream service did not respond in time

504 is "I gave up waiting". Use it when you are the gateway and your dependency stalled.

504
app.MapGet("/products/{id:int}/pricing",
    async Task<Results<Ok<PricingDto>, StatusCodeHttpResult>> (
        int id, IPricingClient pricing, ILogger<Program> logger, CancellationToken ct) =>
{
    try
    {
        return TypedResults.Ok(await pricing.GetAsync(id, ct));
    }
    catch (TaskCanceledException) when (!ct.IsCancellationRequested)
    {
        // 504, not 503: we are up, our dependency is the one that stalled.
        logger.LogWarning("Pricing service timed out for product {ProductId}", id);
        return TypedResults.StatusCode(StatusCodes.Status504GatewayTimeout);
    }
});

Downstream returned something invalid

The dependency answered, but not with anything you can use.

502
app.MapGet("/products/{id:int}/pricing",
    async Task<Results<Ok<PricingDto>, StatusCodeHttpResult>> (
        int id, IPricingClient pricing, ILogger<Program> logger, CancellationToken ct) =>
{
    try
    {
        return TypedResults.Ok(await pricing.GetAsync(id, ct));
    }
    catch (JsonException ex)
    {
        logger.LogError(ex, "Pricing service returned an unreadable payload");
        return TypedResults.StatusCode(StatusCodes.Status502BadGateway);
    }
});

Server overloaded or intentionally down

503 is "I know I am down". Carry Retry-After whenever you know the window.

503
app.MapGet("/orders", async Task<Results<Ok<IReadOnlyList<OrderDto>>, StatusCodeHttpResult>> (
    IOrderService orders, IMaintenanceState maintenance, HttpContext ctx, CancellationToken ct) =>
{
    if (maintenance.IsActive)
    {
        ctx.Response.Headers.RetryAfter = "300";
        return TypedResults.StatusCode(StatusCodes.Status503ServiceUnavailable);
    }

    return TypedResults.Ok(await orders.ListAsync(ct));
});