Skip to content

feat: per-method [Retry] for the one flaky endpoint - #2

Merged
Katyara11 merged 1 commit into
mainfrom
feat/per-method-retry
Nov 7, 2026
Merged

Katyara11 merged 1 commit into
mainfrom
feat/per-method-retry

Conversation

@Katyara11

Copy link
Copy Markdown
Member

Summary

WithRetry is a client-wide policy. This adds the other granularity — a method declaring its own,
for the one flaky endpoint on an otherwise healthy API.

[Get("/reports/{id}")]
[Retry(5, DelayMilliseconds = 250)]
Task<Report> GetReportAsync(int id, CancellationToken ct = default);

It refines the client's registered settings rather than replacing them: backoff curve, jitter,
status-code set and Retry-After handling still come from the registration, and only what the
attribute names changes.

Closes the Per-method retry roadmap item.

Changes

  • RetryAttribute in Runax.Http.Abstractions — attempt count, DelayMilliseconds,
    AllowNonIdempotent. Method-level only.
  • RetryOverride carries what the generator found; RetryOptions.Resolve folds it into a copy
    of the client's options. RetryOptions.Suppressed is the new kill switch.
  • WithoutRetries() on IRunaxClientBuilder<T>.
  • ClientRequest.Retry, alongside the existing per-request Timeout.
  • Generator reads the attribute and emits a constructed RetryOverride — no reflection.
  • RXH020 (warning) and RXH021 (error), recorded under New Rules in
    AnalyzerReleases.Unshipped.md.
  • Docs: retries.md, diagnostics.md, src/Runax.Http/README.md, CHANGELOG, ROADMAP.

Type

  • Feature
  • Bug fix
  • Refactor
  • Chore (CI, deps, config)

Checklist

  • Code builds without errors
  • Tests added/updated

Notes for review

The precedence rule

A method's [Retry] wins over the client's attempt count. WithoutRetries() wins over
everything.

This inverts [Transport], which resolves scope → registration → source. The inversion is
deliberate:

  • A transport is a competing claim about one fact — which stack sends this request. Exactly
    one side can hold, so a tie-break is needed, and it favours the operator because they know the
    deployment (and must be able to decline a transport package they don't want installed).
  • A retry count is a refinement: "how should this endpoint behave" narrows "how should this
    client behave by default". There is no tie to break, so the more specific statement applies.

The library already resolves refinements this way in two places — a per-request Timeout wins over
the client's (literally the line above the one this PR touches in HttpContractExecutor), and a
method's [Header] wins over a client-wide default header. [Transport] is the odd one out, not
this.

The alternative was decisive against itself: if registration won, WithRetry(3) anywhere on the
client would silently flatten a [Retry(5)] an author put on one flaky endpoint — and since
retrying is off by default, enabling it at all requires WithRetry, so the attribute would be
useless precisely when anyone configured the client.

A declaration therefore also turns retrying on by itself, with no client-wide WithRetry first.
That is still "only retry when someone asked" — the attribute is someone asking, in source,
deliberately.

The kill switch

WithoutRetries() ignores every declaration, because an operator needs a client that provably
stops retrying during an incident. It is not the same as WithRetry(1), which only says the
client has no policy of its own and leaves a declaration free to supply one — collapsing the two
would remove the switch. There is a test asserting they differ.

Every precedence test would fail if the rule silently flipped, in both directions: declaration over
registration, [Retry(1)] under a WithRetry(5) client, and WithoutRetries() over both.

Attribute surface, and what was left out

Exposed: attempt count (ctor), DelayMilliseconds, AllowNonIdempotent.

Milliseconds because a TimeSpan cannot be an attribute argument. The inherit sentinel is -1
rather than 0, since zero is a legitimate delay and the tests rely on it.

Left to registration: Backoff, MaxDelay, UseJitter, RespectRetryAfter,
MaxRetryAfterDelay, RetryableStatusCodes. Two reasons — they describe how hard to lean on a
struggling service, which is a deployment property rather than an endpoint one; and an enum like
BackoffStrategy cannot express "inherit" without adding a sentinel member to a public type.
RetryableStatusCodes would additionally need inherit-vs-replace semantics for a rare need.

Method-level only, not interface-level. A client-wide declaration is already WithRetry, which
belongs to whoever runs the service. An interface author enabling retries for every consumer of
their client is exactly the "retries without being asked" liability the off-by-default stance
exists to prevent.

Safety defaults are not bypassable

A streamed body is still never retried whatever the attribute says — the first attempt consumed
the stream. That is physics, not policy, and there is a test pinning it.

A non-idempotent method still needs AllowNonIdempotent. That sits on the attribute as well as
at registration because the interface author is the one who knows whether a given endpoint carries
an idempotency key; the client-wide RetryNonIdempotentMethods opts in every method at once and
is the blunter of the two. RXH020 warns when a [Retry] on a POST/PATCH lacks it, because
the safety default would otherwise leave the attribute doing nothing and nothing else in the build
would mention it.

Conflict footprint

HttpContractExecutor.cs is +7/−3: the effective options are resolved once before the loop, so
the loop body is untouched. CHANGELOG.md adds one block at the top of [Unreleased]. Those are
the two files the parallel pipeline PR also touches.

Verification

1203 tests (from 1119) across three target frameworks. dotnet build --warnaserror clean,
dotnet format --verify-no-changes clean, native AOT publishes with no trim warnings and the
binary runs. All internal doc links and anchors resolve.

WithRetry is a client-wide policy. This is the other granularity: a method
declaring its own, by the person who knows the endpoint.

    [Get("/reports/{id}")]
    [Retry(5, DelayMilliseconds = 250)]
    Task<Report> GetReportAsync(int id, CancellationToken ct = default);

It refines the client's registered settings rather than replacing them —
backoff curve, jitter, status-code set and Retry-After handling still come
from the registration, and only what the attribute names changes.

The precedence rule is the part worth arguing about, because it inverts
[Transport], which resolves scope > registration > source. That inversion is
deliberate, not an oversight. A transport is a *competing* claim about one
fact — which stack sends this request — so exactly one side can hold and the
tie-break favours the operator, who knows the deployment. A retry count is a
*refinement*: "how should this endpoint behave" narrows "how should this
client behave by default". There is no tie to break, so the more specific
statement simply applies. The library already resolves refinements this way
in two places: a per-request Timeout wins over the client's (the line
immediately above the one this change touches), and a method's [Header] wins
over a client-wide default header.

The alternative was decisive against itself. If registration won, then
WithRetry(3) anywhere on the client would silently flatten a [Retry(5)] the
author put on one flaky endpoint — and since retrying is off by default,
enabling it at all requires WithRetry, so the attribute would be useless
exactly when it was configured.

A declaration therefore also turns retrying on by itself, with no client-wide
WithRetry first. That is still "only retry when someone asked": the attribute
is someone asking, in source, deliberately.

WithoutRetries() keeps the last word for whoever runs it, ignoring every
declaration. A client that cannot be stopped from retrying is a liability in
the middle of an incident. It is distinct from WithRetry(1), which only says
the client has no policy of its own and leaves a declaration free to supply
one — collapsing the two would remove the kill switch.

The safety defaults are not bypassable. A streamed body is still never
retried, since the first attempt consumed it; that is physics, not policy.
A non-idempotent method still needs AllowNonIdempotent, which is on the
attribute as well as at registration because the interface author is the one
who knows whether a given endpoint carries an idempotency key — the
client-wide RetryNonIdempotentMethods opts in every method at once and is the
blunter tool of the two.

Attribute surface is deliberately small: attempt count, DelayMilliseconds
(milliseconds because a TimeSpan cannot be an attribute argument, with -1
rather than 0 as the inherit sentinel since zero is a legitimate delay), and
AllowNonIdempotent. Backoff, MaxDelay, jitter, Retry-After handling and the
status-code set are left to registration — they describe how hard to lean on
a struggling service, which is a deployment property, and an enum like
BackoffStrategy cannot express "inherit" without a sentinel member polluting
a public type.

Method-level only, not interface-level. A client-wide declaration is already
WithRetry, which belongs to whoever runs the thing; an interface author
turning retries on for every consumer of their client is precisely the
"retries without being asked" liability the off-by-default stance exists to
prevent.

Two diagnostics. RXH020 (warning) catches [Retry] on a POST or PATCH without
AllowNonIdempotent, where the safety default would leave the attribute doing
nothing at all and nothing else in the build would mention it. RXH021 catches
an attempt count below one.

The executor change is 7 lines: the effective options are resolved once
before the loop, so the loop body is untouched and knows nothing about where
its settings came from.

1203 tests; build, format and native AOT clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Katyara11
Katyara11 merged commit d425c3d into main Nov 7, 2026
2 checks passed
@Katyara11 Katyara11 mentioned this pull request Nov 7, 2026
3 of 6 tasks
@Katyara11
Katyara11 deleted the feat/per-method-retry branch November 7, 2026 09:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant