Skip to content

Client Usage

samgerene edited this page Oct 11, 2026 · 7 revisions

Client Usage

The IArgusClient interface and its ArgusClient implementation send requests over a named pipe and return responses using the ARGUS/1.0 protocol.

Registration

Register the client with dependency injection:

services.AddArgusClient("mypipe");

With a custom default timeout:

services.AddArgusClient("mypipe", TimeSpan.FromSeconds(60));

With automatic retries (see Automatic Retries):

services.AddArgusClient("mypipe", TimeSpan.FromSeconds(60), retry => retry.MaxRetries = 3);

Configuring each created client directly (timeout, retry policy, compression, …):

services.AddArgusClient("mypipe", client =>
{
    client.DefaultTimeout = TimeSpan.FromSeconds(60);
    client.RetryPolicy = new ArgusRetryPolicy();
    client.Compression.Enabled = true;
});

The client is registered as transient. Each request creates a new named pipe connection. If an IArgusBodySerializerRegistry is registered (e.g., via AddArgusPipeHost or AddArgusPlainTextProtocol), the client uses it automatically.

Convenience Methods

The client provides typed methods for each verb:

Method Description
GetAsync(route) Send a GET request
PostAsync(route, body) Send a POST request with an optional string body
PutAsync(route, body) Send a PUT request with an optional string body
PatchAsync(route, body) Send a PATCH request with an optional string body
DeleteAsync(route) Send a DELETE request
HeadAsync(route) Send a HEAD request

All methods accept optional timeout and cancellationToken parameters.

Basic Usage

public class MyService : BackgroundService
{
    private readonly IArgusClient client;

    public MyService(IArgusClient client)
    {
        this.client = client;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // GET
        var response = await this.client.GetAsync("/items", cancellationToken: stoppingToken);
        Console.WriteLine($"Status: {response.StatusCode}");
        Console.WriteLine($"Body: {response.Body}");

        // POST
        var json = JsonSerializer.Serialize(new { name = "Widget" });
        response = await this.client.PostAsync("/items", json, cancellationToken: stoppingToken);

        // PUT
        var updateJson = JsonSerializer.Serialize(new { name = "Updated Widget" });
        response = await this.client.PutAsync("/items/some-id", updateJson, cancellationToken: stoppingToken);

        // DELETE
        response = await this.client.DeleteAsync("/items/some-id", cancellationToken: stoppingToken);
    }
}

Query Parameters

Overloads accepting IReadOnlyDictionary<string, string> append query parameters to the route:

var queryParams = new Dictionary<string, string>
{
    { "page", "1" },
    { "size", "25" }
};

var response = await client.GetAsync("/items", queryParams, cancellationToken: stoppingToken);
// Sends: GET /items?page=1&size=25 ARGUS/1.0

Query parameter overloads are available for GetAsync, PostAsync, PutAsync, PatchAsync, DeleteAsync, and HeadAsync.

Keys and values are percent-encoded. A null value is sent as an empty value: ["filter"] = null becomes ?filter=, and the server sees context.QueryValues["filter"] == "".

Streaming Bodies

For large payloads, send a Stream body using chunked transfer encoding:

using var fileStream = File.OpenRead("largefile.bin");

var response = await client.PostAsync(
    "/upload",
    fileStream,
    contentType: "application/octet-stream",
    cancellationToken: stoppingToken);

Stream body overloads are available for PostAsync, PutAsync, and PatchAsync.

Timeouts

The client has a DefaultTimeout property (30 seconds by default). You can override it per request:

// Set a default timeout for all requests
client.DefaultTimeout = TimeSpan.FromSeconds(60);

// Override for a specific request
var response = await client.GetAsync(
    "/slow-operation",
    timeout: TimeSpan.FromMinutes(5),
    cancellationToken: stoppingToken);

The timeout covers the whole call: connecting, sending, receiving and -- when retries are enabled -- every retry and the delays between them.

Response Size Limit

By default the client reads response bodies of any size. When the server is not fully trusted, set MaxResponseBodySize (in bytes) so a misbehaving server cannot exhaust the client's memory:

using var client = new ArgusClient("myservice")
{
    MaxResponseBodySize = 10 * 1024 * 1024 // 10 MB
};

The limit is applied like the host's MaxRequestBodySize:

  • A Content-Length above the limit is rejected before the body is read, so a header such as Content-Length: 2147483647 does not allocate anything.
  • A chunked body is rejected as soon as it exceeds the limit, so an endless stream ends.
  • A compressed body is rejected when its decompressed size exceeds the limit, so a small gzip bomb cannot expand into gigabytes.

A response above the limit throws an ArgusProtocolException (an InvalidOperationException) from SendAsync, GetAsync and the other methods. The default retry policy does not retry it. 0 (the default) means no limit, and negative values throw ArgumentOutOfRangeException. With dependency injection, set it through the configure overload: services.AddArgusClient("myservice", client => client.MaxResponseBodySize = 10 * 1024 * 1024).

Automatic Retries

Named pipes can fail transiently, most commonly when the pipe breaks because the server restarts mid-request. ArgusClient can retry such failures automatically. Retries are disabled by default (client.RetryPolicy is null).

Waiting for a server that hasn't started yet, or for a free pipe instance when all are busy, does not need retries: ArgusClient already keeps waiting to connect until the timeout expires.

Enable retries through dependency injection:

services.AddArgusClient("mypipe", TimeSpan.FromSeconds(30), retry =>
{
    retry.MaxRetries = 3;
    retry.InitialDelay = TimeSpan.FromMilliseconds(100);
    retry.Strategy = RetryBackoffStrategy.Exponential;
});

or directly on a client instance:

using var client = new ArgusClient("mypipe")
{
    RetryPolicy = new ArgusRetryPolicy { MaxRetries = 5 },
    Logger = loggerFactory.CreateLogger<ArgusClient>()
};

ArgusRetryPolicy

Property Type Default Description
MaxRetries int 3 Maximum number of retries after the first attempt. 0 disables retries.
InitialDelay TimeSpan 100 ms Delay before the first retry; later delays derive from it according to Strategy.
MaxDelay TimeSpan 10 s Upper bound for any single delay.
Strategy RetryBackoffStrategy Exponential How the delay grows (see below).
RetryNonIdempotentRequests bool false Also retry POST and PATCH after the request was sent.
ShouldRetry Func<Exception, bool> ArgusRetryPolicy.IsTransient Decides whether an exception is transient. The default accepts any IOException, including a broken pipe and an EndOfStreamException for a connection closed before the response arrived.

Negative values and undefined strategies are rejected with ArgumentOutOfRangeException.

RetryBackoffStrategy Delay before retry n With the defaults (retries 1, 2, 3)
Fixed InitialDelay 100, 100, 100 ms
Linear n × InitialDelay 100, 200, 300 ms
Exponential 2^(n-1) × InitialDelay 100, 200, 400 ms

policy.GetDelay(n) returns the delay for retry n, capped at MaxDelay.

What gets retried

Situation Retried?
Failure while connecting, before anything was sent Yes
Failure after sending a GET, HEAD, PUT or DELETE Yes
Failure after sending a POST or PATCH Only with RetryNonIdempotentRequests = true -- the server may already have processed the request
Failure after sending a streamed body (BodyStream) Only if the stream is seekable; it is rewound to its original position before the retry
TimeoutException or OperationCanceledException Never, regardless of ShouldRetry
Non-2xx responses (404, 500, ...) Never -- they are valid responses, not transport failures

Cancelling the CancellationToken also stops the client from waiting between retries. When the timeout expires during a delay, the call throws TimeoutException. When all retries are used up, the last exception is rethrown.

Logging

Each retry is logged as a warning, including the exception, verb, route, pipe name, retry number and delay. AddArgusClient sets ILogger<ArgusClient> automatically when logging is registered. Without logging, the client uses NullLogger.

Custom retry conditions

services.AddArgusClient("mypipe", null, retry =>
{
    // Only retry when the server closed the connection before sending a response
    retry.ShouldRetry = ex => ex is EndOfStreamException;
});

ArgusTransfer implements retries itself rather than depending on Polly, so the package adds no third-party dependency. If you need circuit breakers, hedging or rate limiting, wrap your IArgusClient calls in your own Polly ResiliencePipeline.

Authorization

Send credentials with the standard Authorization header:

var request = new ArgusRequest { Verb = ArgusVerb.GET, Route = "/items" };
request.SetAuthorization("Bearer", token);   // Authorization: Bearer <token>

var response = await client.SendAsync(request);

if (response.StatusCode == ArgusStatusCode.Unauthorized)
{
    // missing or invalid credentials
}

Values containing line breaks are rejected. See Authentication for the server side.

Compression

With client.Compression.Enabled = true, the client accepts gzip-compressed responses and compresses request bodies of at least 1 KB. Compressed responses are decoded transparently. Only enable it against a host that supports compression; see Compression.

Low-Level SendAsync

For full control, build an ArgusRequest manually and send it with SendAsync:

var request = new ArgusRequest
{
    Verb = ArgusVerb.POST,
    Route = "/items",
    Body = jsonBody
};

request.Headers["X-Custom-Header"] = "custom-value";

var response = await client.SendAsync(request, cancellationToken: stoppingToken);

Validating Success Status

Each verb method has an EnsureSuccessAsync-suffixed counterpart that throws ArgusRequestException on a non-2xx response, eliminating an explicit status check at every call site:

// Throws on 4xx / 5xx; returns the ArgusResponse on 2xx.
var response = await this.client.GetEnsureSuccessAsync("/items/123");
var item = JsonSerializer.Deserialize<SampleItem>(response.Body);

The 16 overloads mirror the existing methods exactly (including query-parameter and Stream-body variants):

  • SendEnsureSuccessAsync
  • GetEnsureSuccessAsync / PostEnsureSuccessAsync / PutEnsureSuccessAsync / PatchEnsureSuccessAsync / DeleteEnsureSuccessAsync / HeadEnsureSuccessAsync

Equivalently, for a single request, you can call EnsureSuccessStatusCode() on the response yourself:

var response = (await this.client.GetAsync("/items/123"))
    .EnsureSuccessStatusCode();

The non-EnsureSuccess methods never throw on non-2xx -- they always return the response and leave the decision to the caller. See Error Handling for the full overload list, the ArgusRequestException shape, and structured error bodies via ArgusProblemDetails.

Reading Problem Details

When a handler returns an RFC 7807 problem-details body (Content-Type: application/problem+json), read it with ArgusProblemDetails.TryRead:

var response = await client.GetAsync("/items/123");

if (ArgusProblemDetails.TryRead(response, out var problem))
{
    Console.WriteLine($"{problem.Status} {problem.Title}: {problem.Detail}");
}

See Error Handling for producing problem details on the server and reading extension members.

Reading Responses

The ArgusResponse provides:

Property Type Description
StatusCode ArgusStatusCode The response status code
Body string The response body (if any)
Headers Dictionary<string, string> Response headers
CorrelationToken Guid Matches the request's correlation token
Timestamp DateTime When the response was created
var response = await client.GetAsync("/items/123");

if (response.StatusCode == ArgusStatusCode.Ok)
{
    var item = JsonSerializer.Deserialize<SampleItem>(response.Body);
}
else if (response.StatusCode == ArgusStatusCode.NotFound)
{
    Console.WriteLine("Item not found");
}

Disposal

IArgusClient derives from IDisposable, but ArgusClient holds no resources between requests: every request creates and disposes its own pipe connection. Dispose therefore releases nothing. It only marks the client as disposed, after which SendAsync and the other methods throw ObjectDisposedException; a request that is already in progress is not interrupted.

IDisposable stays on the interface so that an implementation that does hold resources, such as pooled pipe connections, can be added without a breaking change. Dispose a client you create yourself, so that your code remains correct for such an implementation:

using var client = new ArgusClient("mypipe");
var response = await client.GetAsync("/items");

Because nothing is held, a client can also be kept and reused for the lifetime of the application; there is no connection that goes stale.

With dependency injection the container disposes the clients it creates. A container keeps a reference to every transient IDisposable it resolves until the scope (or, for the root provider, the application) ends. Resolve IArgusClient from a scope or inject it into a service once, rather than resolving it repeatedly from the root provider in a loop, where the instances accumulate until shutdown.

Clone this wiki locally