Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,8 @@ apm_modules/

# Claude Code personal/local settings
.claude/settings.local.json

# Agent worktrees. Each is a live git worktree of this repository, so a `git add -A` from the
# root otherwise commits them as embedded repositories — which is what happened once before
# this line existed.
.claude/worktrees/
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ All packages in the repository are versioned together.

### Added

- **`WithTotalTimeout`** — a budget for the whole call, retries and the waits between them
included. `Timeout` bounds a single *attempt*, which reads like a call budget and is not one: the
transport request carrying it is built once, before the retry loop, so every attempt gets the
same allowance and the caller waits for all of them plus the backoff. Five attempts at thirty
seconds is over two and a half minutes with nothing in the configuration looking wrong, and
nothing capped it.
- Exceeding the budget throws `TransportException` rather than surfacing as a cancellation,
matching how a per-attempt timeout is already reported. A caller's own cancellation still
surfaces as a cancellation.
- Implemented as a linked token rather than a timeout handed to the transport, because the budget
has to cover the waits *between* attempts, which happen in the executor rather than inside a
send. Translated outside the retry loop on purpose: thrown inside, a `TransportException` would
be caught by the loop's own retry guard and the budget would buy more attempts instead of
ending the call.
- Unbounded by default. Adding a budget nobody asked for would change the behaviour of every
existing client on upgrade.
- `Timeout`'s documentation now says "per attempt", which it never did.

- **`[Retry]` on a method**, for the one flaky endpoint on an otherwise healthy API. Surfaces an
attempt count, `DelayMilliseconds` (milliseconds because a `TimeSpan` cannot be an attribute
argument) and `AllowNonIdempotent`. It refines the client's registered settings rather than
Expand Down Expand Up @@ -102,6 +120,24 @@ All packages in the repository are versioned together.

### Added

- **`WithTotalTimeout`** — a budget for the whole call, retries and the waits between them
included. `Timeout` bounds a single *attempt*, which reads like a call budget and is not one: the
transport request carrying it is built once, before the retry loop, so every attempt gets the
same allowance and the caller waits for all of them plus the backoff. Five attempts at thirty
seconds is over two and a half minutes with nothing in the configuration looking wrong, and
nothing capped it.
- Exceeding the budget throws `TransportException` rather than surfacing as a cancellation,
matching how a per-attempt timeout is already reported. A caller's own cancellation still
surfaces as a cancellation.
- Implemented as a linked token rather than a timeout handed to the transport, because the budget
has to cover the waits *between* attempts, which happen in the executor rather than inside a
send. Translated outside the retry loop on purpose: thrown inside, a `TransportException` would
be caught by the loop's own retry guard and the budget would buy more attempts instead of
ending the call.
- Unbounded by default. Adding a budget nobody asked for would change the behaviour of every
existing client on upgrade.
- `Timeout`'s documentation now says "per attempt", which it never did.

- **`Runax.Http.TestKit`** — `RunaxClientHarness.For<TContract>()` stands up a real container, the
real generated client and the real runtime over a `TestTransport` that records instead of sending.
- A transport rather than a mock, because that is where the interesting state is. Mocking the
Expand Down
6 changes: 4 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ earns one. See [Serialization](serialization.md).
services.AddRunaxClient<IRepositoryClient>()
.WithBaseAddress("https://api.example.com/v3") // or a Uri
.UseTransport(RestSharpTransport.TransportName)
.WithTimeout(TimeSpan.FromSeconds(10))
.WithTimeout(TimeSpan.FromSeconds(10)) // per attempt
.WithTotalTimeout(TimeSpan.FromSeconds(30)) // the whole call, retries included
.WithDefaultHeader("User-Agent", "my-service/1.0")
.WithRetry(3) // or WithRetry(options => …)
.UseSerializer(legacySerializer)
Expand All @@ -45,7 +46,8 @@ Everything `Configure(...)` can reach.
| --- | --- | --- |
| `BaseAddress` | from `[BaseAddress]` | the absolute address request paths resolve against |
| `TransportName` | from `[Transport]` | which transport this client's requests go over |
| `Timeout` | none | how long a request may take when the method does not say |
| `Timeout` | none | how long a single **attempt** may take when the method does not say |
| `TotalTimeout` | none | how long the whole **call** may take, retries and backoff included |
| `Serializer` | the shared one | this client's serializer |
| `DefaultHeaders` | from interface `[Header]`s | headers sent with every request |
| `Retry` | off | see [Retries](retries.md) |
Expand Down
20 changes: 20 additions & 0 deletions docs/retries.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ ignored and the backoff curve decides.
Not retried: a capability gap, a cancelled request, or anything else thrown before the request
reached the transport.

## Bound the call, not just the attempt

`WithTimeout` bounds a **single attempt**. Retries multiply it and the backoff is on top, so five
attempts at thirty seconds is over two and a half minutes — with nothing in the configuration
looking wrong. Set a budget for the call:

```csharp
services.AddRunaxClient<IRepositoryClient>()
.WithRetry(5)
.WithTimeout(TimeSpan.FromSeconds(10)) // per attempt
.WithTotalTimeout(TimeSpan.FromSeconds(20)); // the whole call, retries and waits included
```

Exceeding the budget throws `TransportException`, not a cancellation — "the deadline passed" and
"the caller changed their mind" lead to different retry and alerting decisions. Your own
`CancellationToken` still surfaces as a cancellation.

Worth setting on any client with retries on. It is unbounded by default, because adding a budget
nobody asked for would change the behaviour of every existing client on upgrade.

## Where it sits

Above the transport boundary. Every transport gets the same retry behaviour, none of them
Expand Down
21 changes: 19 additions & 2 deletions src/Runax.Http/DependencyInjection/IRunaxClientBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,28 @@ public interface IRunaxClientBuilder<TContract>
/// </remarks>
IRunaxClientBuilder<TContract> UseTransport(string name);

/// <summary>Sets how long a request may take when the method does not say.</summary>
/// <param name="timeout">The timeout.</param>
/// <summary>Sets how long a single attempt may take when the method does not say.</summary>
/// <param name="timeout">The per-attempt timeout.</param>
/// <returns>This builder.</returns>
/// <remarks>
/// Per attempt. With retries on, the caller waits this multiplied by the attempt count plus
/// the backoff — see <see cref="WithTotalTimeout"/> for the budget that bounds the call.
/// </remarks>
IRunaxClientBuilder<TContract> WithTimeout(TimeSpan timeout);

/// <summary>
/// Sets how long the whole call may take, including every retry and the waits between them.
/// </summary>
/// <param name="timeout">The total budget.</param>
/// <returns>This builder.</returns>
/// <remarks>
/// The budget a caller actually experiences. Worth setting on any client with retries on: a
/// per-attempt timeout bounds an attempt, and retries multiply it with backoff on top, so a
/// client can block for minutes with nothing in its configuration looking wrong. Exceeding it
/// throws <see cref="TransportException"/>, not a cancellation.
/// </remarks>
IRunaxClientBuilder<TContract> WithTotalTimeout(TimeSpan timeout);

/// <summary>Adds a header sent with every request from this client.</summary>
/// <param name="name">The header name.</param>
/// <param name="value">The header value.</param>
Expand Down
10 changes: 10 additions & 0 deletions src/Runax.Http/DependencyInjection/RunaxClientBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ public IRunaxClientBuilder<TContract> WithTimeout(TimeSpan timeout)
return this;
}

/// <inheritdoc />
public IRunaxClientBuilder<TContract> WithTotalTimeout(TimeSpan timeout)
{
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeout, TimeSpan.Zero);

_options.TotalTimeout = timeout;

return this;
}

/// <inheritdoc />
public IRunaxClientBuilder<TContract> WithDefaultHeader(string name, string value)
{
Expand Down
27 changes: 26 additions & 1 deletion src/Runax.Http/HttpContractExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,32 @@ private async Task<Exchange> SendCoreAsync(ClientRequest request, CancellationTo
// token on a retry while a correlation id stays put. See RequestContext.
var context = new RequestContext(request, transportRequest, transport);

return await SendWithRetriesAsync(context, retry, cancellationToken).ConfigureAwait(false);
if (_options.TotalTimeout is not { } budget)
return await SendWithRetriesAsync(context, retry, cancellationToken).ConfigureAwait(false);

// A linked source rather than a timeout handed to the transport: the budget has to cover
// the waits between attempts too, and those happen here rather than inside a send.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(budget);

try
{
return await SendWithRetriesAsync(context, retry, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested
&& deadline.IsCancellationRequested)
{
// Translated outside the retry loop on purpose. Thrown inside, a TransportException
// would be caught by the loop's retry guard and the budget would buy more attempts
// instead of ending the call.
throw new TransportException(
$"{request.Method.ToString().ToUpperInvariant()} {transportRequest.RequestUri} "
+ $"exceeded its total budget of {budget}.")
{
TransportName = transport.Name,
RequestUri = transportRequest.RequestUri
};
}
}

/// <summary>
Expand Down
5 changes: 5 additions & 0 deletions src/Runax.Http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ without it every client that failed at the same moment retries at the same momen
service gets hit by a synchronized wave. A capability gap is never retried — waiting will not give
a transport an ability it lacks.

`WithTimeout` bounds one **attempt**; `WithTotalTimeout` bounds the **call**, retries and backoff
included. Worth setting whenever retries are on — otherwise five attempts at thirty seconds is over
two and a half minutes, and nothing in the configuration looks wrong. Exceeding the budget throws
`TransportException`, not a cancellation.

Retries live above the transport boundary, so every transport gets the same behaviour and none of
them implements it. Swapping a transport does not change your retry semantics.

Expand Down
30 changes: 28 additions & 2 deletions src/Runax.Http/RunaxClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,37 @@ public sealed class RunaxClientOptions
public string? TransportName { get; set; }

/// <summary>
/// How long a request may take when the method does not say. <see langword="null"/> leaves it
/// to the transport.
/// How long a single attempt may take when the method does not say. <see langword="null"/>
/// leaves it to the transport.
/// </summary>
/// <remarks>
/// Per <em>attempt</em>, not per call. With retries on, the wall-clock a caller waits is this
/// multiplied by the attempt count, plus the backoff between them — so five attempts at thirty
/// seconds is over two and a half minutes. Use <see cref="TotalTimeout"/> to bound the call
/// itself.
/// </remarks>
public TimeSpan? Timeout { get; set; }

/// <summary>
/// How long the whole call may take, including every retry and the waits between them.
/// <see langword="null"/> means unbounded.
/// </summary>
/// <remarks>
/// <para>
/// The budget a caller actually experiences, and the one worth setting. <see cref="Timeout"/>
/// bounds an attempt, which is not the same thing: retries multiply it, and the backoff is on
/// top. A client with retries on and no total budget can block for minutes with nothing in its
/// configuration looking wrong.
/// </para>
/// <para>
/// Exceeding it surfaces as <see cref="TransportException"/> rather than a cancellation,
/// matching how a per-attempt timeout is reported: "the deadline passed" and "the caller
/// changed their mind" lead to different handling, and conflating them sends retry logic and
/// alerting down the wrong path. A caller's own cancellation still surfaces as a cancellation.
/// </para>
/// </remarks>
public TimeSpan? TotalTimeout { get; set; }

/// <summary>
/// The serializer for request and response bodies. <see langword="null"/> uses the one
/// registered for the application.
Expand Down
Loading
Loading