Skip to content

feat: bound the whole call with WithTotalTimeout - #5

Merged
Katyara11 merged 1 commit into
mainfrom
fix/total-call-deadline
Nov 7, 2026
Merged

Katyara11 merged 1 commit into
mainfrom
fix/total-call-deadline

Conversation

@Katyara11

Copy link
Copy Markdown
Member

Summary

WithTimeout bounds a single attempt, not the call. With retries on, the caller waits that
allowance times the attempt count plus the backoff — five attempts at thirty seconds is over two
and a half minutes, and nothing capped it.

Changes

  • RunaxClientOptions.TotalTimeout and WithTotalTimeout(TimeSpan).
  • Timeout's documentation now says "per attempt", which it never did.
  • Docs: retries.md, configuration.md, package README, CHANGELOG.

Type

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

Checklist

  • Code builds without errors
  • Tests added/updated

Notes for review

A linked token, not a transport timeout. The budget has to cover the waits between attempts,
and those happen in the executor rather than inside a send.

The translation sits outside the retry loop deliberately. Thrown inside, a TransportException
would be caught by the loop's own retry guard and the budget would buy further attempts instead
of ending the call — the exact opposite of its job.

It throws rather than cancelling. Consistent with how a per-attempt timeout is already
reported: "the deadline passed" and "the caller changed their mind" lead to different retry and
alerting decisions. A caller's own cancellation still surfaces as a cancellation, and a test pins
that the budget does not relabel it.

Unbounded by default, because a budget nobody asked for would change the behaviour of every
existing client on upgrade.

The retry-count test asserts "bounded", not a precise elapsed time — CI timing is not a precision
instrument.

Verification

1386 tests; --warnaserror clean, dotnet format clean, native AOT publishes and runs.

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 between. Five attempts at thirty seconds is over two and a
half minutes, nothing capped it, and nothing in the configuration looked
wrong. Timeout's own documentation never said "per attempt" either; it does
now.

WithTotalTimeout is the budget a caller actually experiences. Implemented as a
linked token rather than a timeout handed to the transport, because the budget
has to cover the waits between attempts and those happen in the executor
rather than inside a send.

The translation sits 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 further attempts instead of ending the call — the exact
opposite of its job.

Exceeding the budget throws TransportException rather than surfacing as a
cancellation, matching how a per-attempt timeout is already reported: "the
deadline passed" and "the caller changed their mind" lead to different retry
and alerting decisions. A caller's own cancellation still surfaces as a
cancellation, and a test pins that the budget does not relabel it.

Unbounded by default, since adding a budget nobody asked for would change the
behaviour of every existing client on upgrade.

1386 tests; build clean with warnings-as-errors, format clean, native AOT
publishes and runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Katyara11
Katyara11 force-pushed the fix/total-call-deadline branch from a8f108f to 6ab7f8b Compare November 7, 2026 11:03
@Katyara11
Katyara11 merged commit 29a116e into main Nov 7, 2026
2 checks passed
@Katyara11
Katyara11 deleted the fix/total-call-deadline branch November 7, 2026 11:08
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