Repository navigation
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 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. |
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 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.
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 (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.
| 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"}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.
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 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();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,406and503responses used to be plain text (or empty for406) and carried a body forHEADrequests. 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)andproblem.Detail. The texts are unchanged and are now thedetailmember.
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.
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 generic500problem details response (detail=ArgusExceptionHandlerMiddleware.GenericErrorDetail,instance= correlation token). No exception details are sent. -
With
services.AddArgusExceptionHandler(...), the middleware (outermost in the pipeline) produces the500and can include the exception message, type and stack trace whenIncludeExceptionDetailsis 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
InvalidOperationExceptionthrown by a handler was answered with400 Bad Requestcontaining the raw exception message, and other exceptions closed the connection (which clients with a retry policy would resend). Both now produce the generic500.
- Client Usage -- the non-throwing verb methods, their overloads, and automatic retries
-
Middleware --
ArgusExceptionHandlerMiddleware -
Authentication --
401/403responses -
Serialization -- the
IArgusBodySerializercontract - Wire Format Reference -- status codes and reason phrases
- Troubleshooting -- pipe-level failures (timeouts, connection errors)
copyright @ Sam Gerené