Skip to content

Error Handling

samgerene edited this page Oct 4, 2026 · 6 revisions

Error Handling

ArgusTransfer surfaces failures in three complementary ways: a non-success status code on the response, an ArgusRequestException raised by the client when the caller opts in, and a structured RFC 7807 ArgusProblemDetails body when the handler wants to ship machine-readable error context. This page covers all three and how they fit together.

For pipe-level failures (timeouts, connection errors, disposed clients) see Troubleshooting.

Failure surface

Failure type Where it shows up When
Non-success ArgusStatusCode (4xx / 5xx) response.StatusCode Always -- a response is returned to the caller.
ArgusRequestException thrown from EnsureSuccessStatusCode() and the *EnsureSuccessAsync client overloads Only when the caller opts in to validation.
ArgusProblemDetails body response.Body (with Content-Type: application/problem+json) When a handler chooses to emit a structured error, for unhandled exceptions (500), and for rejected authentication (401 / 403).
TimeoutException / ObjectDisposedException / IOException thrown from ArgusClient Pipe-level concerns -- see Troubleshooting. Transient IOExceptions can be retried automatically -- see Automatic Retries.

EnsureSuccessStatusCode on ArgusResponse

ArgusResponse.EnsureSuccessStatusCode() returns this when (int)StatusCode is in the 2xx range, and throws ArgusRequestException otherwise. It enables fluent-style assertions on a response without manual status checks:

var body = (await client.GetAsync("/healthendpoint"))
    .EnsureSuccessStatusCode()
    .Body;

The exception is populated from the failing response: StatusCode, the canonical reason phrase from ArgusStatusCodeExtensions.ToReasonPhrase() (matching what was on the wire), and the response body for diagnostics.

ArgusRequestException

ArgusRequestException is the rich exception type carrying the failing response details:

Property Type Description
StatusCode ArgusStatusCode The numeric/enum status returned by the server.
ReasonPhrase string The canonical reason phrase, matching the response status line.
ResponseBody string The full response body (may be empty or null).

The exception's Message follows the form "Argus request failed with status code 404 (Not Found)." so it reads sensibly in logs without unpacking the typed properties.

try
{
    var response = (await client.GetAsync("/items/{id}"))
        .EnsureSuccessStatusCode();
}
catch (ArgusRequestException ex) when (ex.StatusCode == ArgusStatusCode.NotFound)
{
    logger.LogInformation("Item not found: {Reason}", ex.ReasonPhrase);
}
catch (ArgusRequestException ex)
{
    logger.LogError("Argus request failed: {Status} {Reason}\n{Body}",
        (int)ex.StatusCode, ex.ReasonPhrase, ex.ResponseBody);
}

Standard Exception(string) and Exception(string, Exception) constructors are also available for callers that want to wrap or rethrow.

Validating success on the client

Each ArgusClient verb method has an EnsureSuccessAsync-suffixed counterpart that performs the same request and chains EnsureSuccessStatusCode() on the result. The 16 overloads mirror the existing methods exactly:

Verb Overloads
Send SendEnsureSuccessAsync(request, ...)
Get GetEnsureSuccessAsync(route, ...) -- with or without query parameters
Post PostEnsureSuccessAsync(route, body?, ...) -- string body, query+string-body, or Stream body
Put PutEnsureSuccessAsync(route, body?, ...) -- string body, query+string-body, or Stream body
Patch PatchEnsureSuccessAsync(route, body?, ...) -- string body, query+string-body, or Stream body
Delete DeleteEnsureSuccessAsync(route, ...) -- with or without query parameters
Head HeadEnsureSuccessAsync(route, ...) -- with or without query parameters

Worked example:

public async Task<SampleItem> LoadAsync(Guid id, CancellationToken ct)
{
    // Throws ArgusRequestException on any non-2xx response.
    var response = await this.client.GetEnsureSuccessAsync(
        $"/sampleitems/{id}",
        cancellationToken: ct);

    return JsonSerializer.Deserialize<SampleItem>(response.Body);
}

The non-EnsureSuccess methods on ArgusClient never throw on non-2xx -- they always return the response and leave the decision to the caller.

ArgusProblemDetails (RFC 7807)

ArgusProblemDetails (namespace ArgusTransfer.Protocol) is a structured error body modelled after RFC 7807. It's the recommended way to ship machine-readable error context to clients.

Properties

Property Type Description
Type string A URI or identifier for the error category. The factories default it to "about:blank" (ArgusProblemDetails.DefaultType).
Title string Short, human-readable summary. The factories default it to the status code's reason phrase.
Status int Numeric status code (matches (int)ArgusStatusCode).
Detail string Human-readable explanation specific to this occurrence.
Instance string Identifier for this specific occurrence -- typically the request's correlation token.
Extensions Dictionary<string, object> Additional context, written as top-level members of the JSON object (as RFC 7807 specifies), not nested under an Extensions key.

Constants:

Constant Value
ArgusProblemDetails.ContentType "application/problem+json"
ArgusProblemDetails.DefaultType "about:blank"

Problem details are serialized with System.Text.Json. Standard members are written in lower case (type, title, status, detail, instance) and members whose value is null are omitted:

{"type":"about:blank","title":"Bad Request","status":400,"detail":"Validation failed","field":"name"}

Producing a problem-details response in a handler

The simplest option is ArgusContext.Problem(...). It assigns context.Response (and also returns it) and fills Instance with the request's correlation token:

app.MapGet("/sampleitems/{id:Guid}", context =>
{
    var id = context.RouteValues["id"];

    context.Problem(
        ArgusStatusCode.NotFound,
        $"no item with id {id} exists",
        new Dictionary<string, object> { ["id"] = id });

    return Task.CompletedTask;
});

Problem(statusCode, detail, extensions, title, type) -- only statusCode is required.

Alternatively, use one of the static factories. Each one returns a ready ArgusResponse with the status code, the JSON body and Content-Type: application/problem+json already set, and takes an optional detail and extensions:

context.Response = ArgusProblemDetails.BadRequest("name is required");
context.Response = ArgusProblemDetails.Unauthorized();
context.Response = ArgusProblemDetails.Forbidden();
context.Response = ArgusProblemDetails.NotFound("no item with id 7 exists");
context.Response = ArgusProblemDetails.Conflict("item already exists");
context.Response = ArgusProblemDetails.UnprocessableEntity("price must be positive", new Dictionary<string, object> { ["field"] = "price" });
context.Response = ArgusProblemDetails.InternalServerError();
context.Response = ArgusProblemDetails.ServiceUnavailable();

The factories leave Instance empty. For full control -- a custom type or title, an explicit instance, or a status code without a factory -- build the model with Create(...) and convert it with ToResponse():

var problem = ArgusProblemDetails.Create(
    ArgusStatusCode.UnprocessableEntity,
    detail: "price must be positive",
    title: "Validation failed",
    type: "urn:sampleitems:validation",
    instance: context.CorrelationToken.ToString());

context.Response = problem.ToResponse();

ToResponse() throws InvalidOperationException when Status is not a defined ArgusStatusCode.

Parsing a problem-details body on the client

ArgusProblemDetails.TryRead(response, out problem) returns true only when the response's Content-Type is application/problem+json and the body parses:

var response = await this.client.GetAsync($"/sampleitems/{id}");

if (ArgusProblemDetails.TryRead(response, out var problem))
{
    logger.LogInformation("{Title}: {Detail}", problem.Title, problem.Detail);
}

ArgusRequestException carries the body but not the headers, so in a catch block parse the body directly with TryParse:

try
{
    var response = await this.client.PostEnsureSuccessAsync("/sampleitems", body);
}
catch (ArgusRequestException ex) when (ex.StatusCode == ArgusStatusCode.BadRequest)
{
    if (ArgusProblemDetails.TryParse(ex.ResponseBody, out var problem))
    {
        logger.LogInformation("Validation failed: {Detail}", problem.Detail);
    }
}
Method On invalid input
TryRead(ArgusResponse, out ArgusProblemDetails) returns false (wrong/missing content type, empty or invalid body)
TryParse(string, out ArgusProblemDetails) returns false
FromJson(string) throws ArgumentNullException (null) or JsonException (invalid JSON)
ToJson() -- serializes the instance

Extensions values

Extensions accepts any value System.Text.Json can serialize. After parsing, extension values are JsonElement instances, so read them with the JsonElement accessors:

var id = ((JsonElement)problem.Extensions["id"]).GetString();

Content type and body serializers

The problem details are serialized to a JSON string body up front, and the Content-Type: application/problem+json header travels with the response. Body serializers in ArgusTransfer transform strings, so the default PlainTextArgusBodySerializer passes the JSON through unchanged -- no JSON body serializer is needed, and none ships with the library.

Which framework responses carry problem details:

Response Source Body
500 Internal Server Error for an unhandled exception pipe host fallback or ArgusExceptionHandlerMiddleware problem details
401 Unauthorized / 403 Forbidden ArgusAuthenticationMiddleware problem details
400 BadRequest (malformed request, body too large, decompressed body too large, unsupported or corrupt Content-Encoding) pipe host problem details, detail = the reason, no instance (the request could not be read)
406 NotAcceptable pipe host problem details, detail names the Accept value and the supported media types
503 ServiceUnavailable (concurrency limit, request timeout) pipe host problem details, detail = ArgusPipeHostBackgroundService.ConcurrencyLimitDetail or RequestTimeoutDetail
404 NotFound / 501 NotImplemented router empty

Every pipe-host response for a request that was read carries the request's correlation token, both as the response's X-Correlation-Token and as the problem's instance. Responses to HEAD requests have no body; the status code and headers still identify the error.

Use TryRead rather than assuming every non-2xx response carries problem details; the router's 404 and 501 have no body.

Behavior change: the pipe host's 400, 406 and 503 responses used to be plain text (or empty for 406) and carried a body for HEAD requests. Clients that compared the body with a fixed string, such as "Server concurrency limit reached", should read the problem details instead: ArgusProblemDetails.TryRead(response, out var problem) and problem.Detail. The texts are unchanged and are now the detail member.

Only violations by the request itself are answered with 400. The serializers report them as ArgusProtocolException (namespace ArgusTransfer.Protocol, derived from InvalidOperationException) -- also on the client, for a response that exceeds a limit or has an unsupported or corrupt Content-Encoding -- and a malformed request line or chunk as FormatException. Every other failure on the server is a server fault: an exception in a handler, or a handler that sets a Content-Encoding the host cannot produce, results in the generic 500.

Unhandled exceptions

When a handler or middleware throws, the client always receives a response -- never a dropped connection:

  • Without ArgusExceptionHandlerMiddleware, the pipe host logs the exception and returns a generic 500 problem details response (detail = ArgusExceptionHandlerMiddleware.GenericErrorDetail, instance = correlation token). No exception details are sent.
  • With services.AddArgusExceptionHandler(...), the middleware (outermost in the pipeline) produces the 500 and can include the exception message, type and stack trace when IncludeExceptionDetails is enabled -- for development only. See Middleware.

The correlation token in instance also appears in the server's error log entry, so a client can report it to find the matching log line.

The same generic 500 is returned when a handler or middleware completes without setting context.Response -- a bug in the handler. The host logs an error naming the verb, route and correlation token. Previously this closed the connection, which clients with a retry policy would resend.

A request that exceeds RequestTimeout is cancelled through context.RequestAborted and answered with 503 Service Unavailable, not 500.

Before this behavior, an InvalidOperationException thrown by a handler was answered with 400 Bad Request containing the raw exception message, and other exceptions closed the connection (which clients with a retry policy would resend). Both now produce the generic 500.

See also

Clone this wiki locally