engine: a refused refresh with a Retry-After escalates to a fresh registration at once

RegRecovery retried a refused refresh once over the same registration
before escalating, on the reading that one 503 may be a hiccup. A
refusal whose Retry-After is at least a second is the network's
considered answer: on O2 UK (2026-10-08, imsd 0.3.10) the keepalive was
refused 503 with Retry-After 414 and the refresh after that wait 503
with Retry-After 772, so 0.3.10 scheduled the fresh registration no
earlier than 414 + 772 s after the keepalive's refusal. Refusals
without such a Retry-After (none, 0, unparsable) keep the two-refusal
rule, so KPN's bare 403 is unchanged.

Tested with the Engine suite, whose checks of a refusal carrying a
Retry-After now expect the fresh step.
This commit is contained in:
Jorijn van der Graaf 2026-10-10 17:18:21 +02:00
commit a00b14a5a9
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
4 changed files with 37 additions and 21 deletions

View file

@ -246,18 +246,23 @@ Rules:
pre-fix build that renewed that closure; the retry on this code, the pre-fix build that renewed that closure; the retry on this code, the
renewal firing, Timer F, the doubling, a SUBSCRIBE `Retry-After`, the renewal firing, Timer F, the doubling, a SUBSCRIBE `Retry-After`, the
renew arm and the back-off's survival across a refresh have run nowhere — renew arm and the back-off's survival across a refresh have run nowhere —
§3, §7.4); a `Retry-After` on a REGISTER and a `Dial` bringing a recovery §3, §7.4); a `Retry-After` on a REGISTER has run in the field (one
step forward (KPN's refusals, all 403, carry none; imsd-ofonod refuses the report, a screenshot of 2026-10-08, O2, 0.3.10: a refresh's Retry-After
`Dial` itself and imsd-dialerd forwards it — pinned in `tests/Engine` set the next step's time — `in 7 min`, then `in 13 min` — and the
only); a refused initial or fresh refusals counted; the wait's length went unmeasured, the journal lines
on the screenshot carry no timestamps; KPN's refusals, all 403, carry
none) and a `Dial` bringing a recovery step
forward (imsd-ofonod refuses the `Dial` itself and imsd-dialerd forwards
it) is pinned in `tests/Engine` only; the escalation by one refused
refresh that carries a `Retry-After` is pinned in `tests/Engine` only; a
refused initial or fresh
REGISTER (403, 5xx) at bring-up or in recovery (KPN has only throttled or REGISTER (403, 5xx) at bring-up or in recovery (KPN has only throttled or
stayed silent — pinned in `tests/Engine` only); the write-off of the old stayed silent — pinned in `tests/Engine` only); the write-off of the old
registration by a recovery's fresh attempt that fails past the SA setup registration by a recovery's fresh attempt that fails past the SA setup
(such attempts are on record only as a bring-up's fatal exit, which does (such attempts are on record only as a bring-up's fatal exit, which does
not pass that arm — pinned in `tests/Engine` only); a refused not pass that arm — pinned in `tests/Engine` only); the escalation by
refresh counted two refused refreshes that carry no `Retry-After` (KPN refuses a refresh
towards the escalation, and the escalation by two of them (KPN refuses a only with the 403 that says the registration is gone — pinned in
refresh only with the 403 that says the registration is gone — pinned in
`tests/Engine` only); of the throttled-attempt chain in recovery (a `tests/Engine` only); of the throttled-attempt chain in recovery (a
throttled fresh attempt, the old SA retried once meanwhile with the stored throttled fresh attempt, the old SA retried once meanwhile with the stored
binding's Contact and refused 403, the deferred attempt accepted at the binding's Contact and refused 403, the deferred attempt accepted at the
@ -497,7 +502,7 @@ The policy, with its constants:
| Step | Rule | | Step | Rule |
|---|---| |---|---|
| Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). | | Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). |
| Escalate to a fresh registration | After two refused refreshes (`RefusalsBeforeFresh`) or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`). | | Escalate to a fresh registration | After one refused refresh whose `Retry-After` is at least a second (the network's considered answer; a `Retry-After` of 0 or one that does not parse counts as none; O2 2026-10-08: the refresh refused 503 with Retry-After 414 s, the refresh after that wait 503 with Retry-After 772 s), after two refused refreshes without one (`RefusalsBeforeFresh`), or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`). |
| A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. | | A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. |
| A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). | | A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). |
| `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). | | `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). |
@ -1001,7 +1006,10 @@ the related code.
registration's address the attempt is refused and costs the registration. registration's address the attempt is refused and costs the registration.
- **O2 UK, 2026-09-24:** a refresh refused 21 × 503 over 70 min, then 33 - **O2 UK, 2026-09-24:** a refresh refused 21 × 503 over 70 min, then 33
unanswered reconnect cycles; a fresh registration was accepted in one unanswered reconnect cycles; a fresh registration was accepted in one
second. The origin of `RegRecovery`. second. The origin of `RegRecovery`. On 2026-10-08, under 0.3.10 (the
first release to log a Retry-After), a refresh was refused 503 with
`Retry-After` 414 s and the refresh after that wait 503 with `Retry-After`
772 s: the origin of the `Retry-After` escalation.
- **KPN closes a registration's previous reg-event subscription with - **KPN closes a registration's previous reg-event subscription with
`noresource`** when a new one replaces it: that end is not renewed. `noresource`** when a new one replaces it: that end is not renewed.
- **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string - **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string

View file

@ -1670,9 +1670,11 @@ private:
OnRegistered(); OnRegistered();
} catch (const RefusedError& e) { } catch (const RefusedError& e) {
// The network refused the refresh: the recovery policy counts it // The network refused the refresh: the recovery policy counts it
// (a second refusal means a fresh registration) and the flow is // — a refusal with a Retry-After of at least a second makes the
// dropped so the reconnect step retries the refresh after the // fresh registration the next step, behind the network's wait;
// wait (Retry-After when given). // one without gets one more refresh first, a second refusal then
// escalates — and the flow is dropped so the next step runs
// after the wait.
Log(std::format("keepalive re-REGISTER failed: {}", e.what())); Log(std::format("keepalive re-REGISTER failed: {}", e.what()));
CloseFlowAfterWait(); CloseFlowAfterWait();
RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status); RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status);

View file

@ -74,9 +74,13 @@ export namespace imsd::engine {
// fresh registration the network accepted within two seconds (ims // fresh registration the network accepted within two seconds (ims
// ledger F16). // ledger F16).
// //
// Pacing. Reconnect cycles back off 5 s → 5 min. A refusal escalates // Pacing. Reconnect cycles back off 5 s → 5 min. A refusal without a
// to a fresh registration after two (one 503 may be a momentary // Retry-After of at least a second (none, 0, unparsable) escalates to
// hiccup a retried refresh clears), silence after two reconnect cycles // a fresh registration after two (one 503 may be a momentary hiccup a
// retried refresh clears); one with such a Retry-After escalates at
// once — it is the network's considered answer (O2 2026-10-08: the
// refresh after the first wait was refused again, 414 s then 772 s).
// Silence escalates after two reconnect cycles
// that end without an answer — a TCP connect series whose last attempt // that end without an answer — a TCP connect series whose last attempt
// was not a local refusal, a bind() the kernel refused, or a refresh // was not a local refusal, a bind() the kernel refused, or a refresh
// that fails short of a refusal: unanswered, an unusable challenge, a // that fails short of a refusal: unanswered, an unusable challenge, a
@ -185,8 +189,9 @@ export namespace imsd::engine {
// A refused keepalive refresh, or a failed reconnect + refresh // A refused keepalive refresh, or a failed reconnect + refresh
// cycle; `status` is the refusal's. The backoff paces the next step; // cycle; `status` is the refusal's. The backoff paces the next step;
// a Retry-After holds every attempt for its own length (up to // a Retry-After of at least a second holds every attempt for its own
// RetryAfterMax), whichever is longer. A 403 once a fresh // length (up to RetryAfterMax), whichever is longer, and makes the
// next step the fresh registration. A 403 once a fresh
// registration is due says the old registration is gone (KPN drops // registration is due says the old registration is gone (KPN drops
// it the moment an initial REGISTER arrives, throttled or not). // it the moment an initial REGISTER arrives, throttled or not).
void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) { void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) {
@ -199,6 +204,7 @@ export namespace imsd::engine {
if (f == Failure::Refused) refusals++; if (f == Failure::Refused) refusals++;
else silentCycles++; else silentCycles++;
if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true; if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true;
if (f == Failure::Refused && retryAfter > 0) fresh = true;
if (status == 403 && fresh) oldGone = true; if (status == 403 && fresh) oldGone = true;
backoffUntil = now + reconnectDelay; backoffUntil = now + reconnectDelay;
reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax); reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax);

View file

@ -882,9 +882,9 @@ int main() {
// Retry-After is the network's word: it holds every attempt for its // Retry-After is the network's word: it holds every attempt for its
// own length, next to the backoff, and a Dial cuts only the backoff // own length, next to the backoff, and a Dial cuts only the backoff
r.OnReconnectFailure(1000, F::Refused, 90); r.OnReconnectFailure(1000, F::Refused, 90);
Check(reconnectAt(r, 1000, 1090), "Retry-After 90 beats the 5-s backoff"); Check(r.Fresh() && r.Reconnectable() && freshAt(r, 1000, 1090), "a refusal with a Retry-After escalates at once: the fresh attempt behind the network's wait (O2 2026-10-08), the old SA kept");
r.OnDial(); r.OnDial();
Check(reconnectAt(r, 1000, 1090), "…and a Dial does not cut it"); Check(freshAt(r, 1000, 1090), "…and a Dial does not cut it");
r.OnReconnectFailure(1090, F::Refused, 3); r.OnReconnectFailure(1090, F::Refused, 3);
Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it"); Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it");
r.OnDial(); r.OnDial();
@ -957,7 +957,7 @@ int main() {
Check(ra.freshHoldUntil == 5000 + 3600, "…a day's for an hour: the refusal is heard again then"); Check(ra.freshHoldUntil == 5000 + 3600, "…a day's for an hour: the refusal is heard again then");
RegRecovery rb; RegRecovery rb;
rb.OnReconnectFailure(1000, F::Refused, 86400); rb.OnReconnectFailure(1000, F::Refused, 86400);
Check(reconnectAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same"); Check(freshAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same, the fresh attempt behind it");
RegRecovery h; RegRecovery h;
h.OnFreshFailure(1000, F::Refused, 60); h.OnFreshFailure(1000, F::Refused, 60);
Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule"); Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule");