---
title: "ADR-040: Without a total timeout, the cancellable send bounds silence"
manual: "nr-vault"
version: "1.1"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:adr-040-cancellable-send-bounds-silence@1.1"
source: "Developer/Adr/ADR-040-CancellableSendBoundsSilence.rst"
rendered: "2026-09-29T20:42:44+00:00"
---

# ADR-040: Without a total timeout, the cancellable send bounds silence {#adr-040-cancellable-send-bounds-silence}

**Table of contents**

-   [Status](https://docs.typo3.org/permalink/netresearch/nr-vault:status@1.1)
-   [Date](https://docs.typo3.org/permalink/netresearch/nr-vault:date@1.1)
-   [Context](https://docs.typo3.org/permalink/netresearch/nr-vault:context@1.1)
-   [Decision](https://docs.typo3.org/permalink/netresearch/nr-vault:decision@1.1)
-   [Consequences](https://docs.typo3.org/permalink/netresearch/nr-vault:consequences@1.1)

## Status {#status}

Accepted (amends [ADR-037: A cancellable send is a method, not an exported handle](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-037-cancellable-outbound-send@1.1))

## Date {#date}

2026-09-27

## Context {#context}

[ADR-037: A cancellable send is a method, not an exported handle](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-037-cancellable-outbound-send@1.1) gave the tick loop of `sendCancellable()` a defensive wall-clock bound of `timeout + connect_timeout + 5 s` and justified it with one sentence: it "sits strictly above libcurl's own deadlines", so it can only trip when the handler stopped settling its promise.

That sentence is true only when a total `timeout` is set.
`timeout = 0` is the default on TYPO3 13.4 and 14.3, and it gives libcurl no total deadline at all.
The bound is then `connect_timeout + 5 s` — 15 s with the default `connect_timeout` of 10 — and it sits above nothing: it ends every call that takes longer, however much the server is still sending, while `sendRequest()` on the same client completes the same call.

Measured in the review of [#392](https://github.com/netresearch/t3x-nr-vault/pull/392) with `timeout = 0`, `connect_timeout = 1` and a server sending 12 events 700 ms apart: `sendRequest()` completed after 7.72 s, `sendCancellable()` was aborted after 6.01 s ([#394](https://github.com/netresearch/t3x-nr-vault/issues/394)).
The functional test named under Consequences reproduces it against the unchanged code: the call fails with `Cancellable transfer exceeded its wall-clock budget and was aborted`, audited as `http_call` / `success = false`.
The same loop runs the OAuth token leg (`OAuthTokenManager::dispatchCancellable()`) inside a cancellable call, with the same bound and therefore the same defect.

[ADR-039: A streaming send drives the pinned curl transfer from read()](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-039-streaming-send-keeps-the-dns-pin@1.1) met the same problem on the streaming send and decided it there: with a total timeout keep the wall-clock budget, without one bound the server's silence — 60 s (`SecureHttpClientFactory::STREAMING_IDLE_BUDGET_SECONDS`), reset by every final head and every body byte after one.
The factory already puts that idle budget on every transport it builds without a total timeout; only the streaming send read it.

## Decision {#decision}

Every send on the cancellable transport applies the rule of ADR-039: `sendStreaming()`, `sendCancellable()` and the OAuth token leg.

-   **With a total timeout** (`timeout > 0`, the platform value or `withTimeout()`) nothing changes. libcurl enforces the timeout, and the wall-clock budget `timeout + connect_timeout + 5 s` sits above it, measured from the start of the transfer. Progress does not extend it (`withATotalTimeoutProgressDoesNotExtendTheWallClockBudget()`), and the factory still computes it as before (`theFactoryGivesTheCancellableSendAnIdleBoundOnlyWithoutATotalTimeout()`).
-   **Without a total timeout** the wall-clock budget is not applied. The call ends when nothing has arrived for the idle budget, with its own fixed literal and code, audited as a failure under `http_call` / `success = false` — the reasoning of ADR-037 for the wall-clock bound applies unchanged: nobody asked for it, so it is not a cancellation.

| Send | Literal | Code |
| --- | --- | --- |
| `sendCancellable()` | `Cancellable transfer received nothing within its idle limit and was aborted` | `1786579206` |
| OAuth token leg | `Cancellable OAuth token transfer received nothing within its idle limit and was aborted` | `1786579307` (`OAuthException`) |

### One mechanism, not a copy {#one-mechanism-not-a-copy}

The loop in `sendCancellable()` and the one in the token leg were private copies of the step `StreamingTransfer::advance()` already performs — poll the signal, check the bound, `tick()`, drain the promise queue, observe settlement through `then()` handlers.
Both now drive a `StreamingTransfer` instead of their own loop, so the idle bound, the rule that it is checked after the tick, and the rule that a settled transfer is never aborted are the ones ADR-039 records, not a third version of them.
What stays local is the classification of the outcome: the literals, the exception types and, for `sendCancellable()`, the audit ladder.
ADR-037's arguments for this loop's shape — no `wait()`, no `SYNCHRONOUS`, settlement through handlers, one teardown on every abnormal exit — hold for `StreamingTransfer` as they held for the copy.

### What counts as progress {#what-counts-as-progress}

The rule is ADR-039's, and it now lives in one class, `TransferProgress`, which all three sends use: a final response head (status 200 and above) counts, and so do the body bytes the sink took while a final head was the current one.
A `1xx` head resets the current head, so a server repeating `100 Continue` buys no time on any Guzzle version, and the raw bytes behind an unsolicited `101 Switching Protocols` count as nothing (`aRepeatedInterimHeadBuysNoTime()`, `bytesAfterAnUnsolicitedSwitchingProtocolsHeadBuyNoTime()`, token leg `aRepeatedInterimHeadBuysTheTokenLegNoTime()`).
A final head after an interim one counts, and so does a trickle after it (`aTrickleAfterAFinalHeadCompletesAlthoughAnInterimHeadCameFirst()`).

`sendCancellable()` buffers the whole body, so it has no streaming sink to count.
Guzzle's curl handler writes the body into a `php://temp` stream when no `sink` is given; the send now opens that stream itself and passes it as `sink`, together with an `on_headers` callback, and counts the stream's size.
The body returned is that stream, as before.
It is deliberately not the streaming send's `StreamingSink`: that one holds at most 16 MiB, and `sendCancellable()` has never limited a body (`aBodyLargerThanTheStreamingBufferIsReturnedWhole()`).
`sink` and `on_headers` are request options the send sets itself; the send still takes no options from its caller, and `stream` is still never set.

## Consequences {#consequences}

-   A cancellable call without a total timeout that keeps receiving completes, however long it takes (`withoutATotalTimeoutACallStillDeliveringOutlivesTheWallClockBudget()`; on the wire `StreamingSendTest::withoutATotalTimeoutACancellableCallStillDeliveringOutlivesTheOldBudget()`, the measurement from #394, red before this change with the wall-clock literal).
-   A server that accepts the connection and sends nothing ends the call after 60 s instead of `connect_timeout + 5 s` (`withoutATotalTimeoutASilentServerEndsAtTheIdleBound()`, `withoutATotalTimeoutACallThatGoesQuietAfterItsHeadEndsAtTheIdleBound()`; on the wire, with the bound shortened to 1 s, `withoutATotalTimeoutASilentServerEndsACancellableCallAtTheIdleBound()`; token leg `withoutATotalTimeoutASilentTokenEndpointEndsAtTheIdleBound()`, `withoutATotalTimeoutASlowTokenEndpointStillCompletes()`).
    That is later than before; the signal still ends such a call at any moment.
-   At `timeout = 0` the 60 s window starts when the transfer is built, so it also covers the connect, the TLS handshake and the upload of the request body: a slow upload to a server that sends nothing back until it has the whole body gets 60 s, where the wall-clock budget gave it `connect_timeout + 5 s` (15 s by default), and with a `connect_timeout` above 55 s the window is tighter than that budget was.
-   A server that trickles a byte every few seconds keeps the call open indefinitely, exactly as it keeps `sendRequest()` open at `timeout = 0`. An operator who needs a hard ceiling sets `timeout` or calls `withTimeout()`. ADR-039 gives the reason a low-speed limit is not added.
-   `SecureHttpClientFactory::STREAMING_IDLE_BUDGET_SECONDS` keeps its name although it now bounds non-streaming sends too; renaming a public constant is not worth the break.
-   A refresh-token round trip that ends at the idle bound throws `OAuthException` with its own code, like one that ends at the wall-clock bound; `fetchTokenWithFallback()` falls back to `client_credentials` only for a rejected refresh token, so neither bound triggers a second round trip.
