From a00b14a5a9a9950ff16effcf270f3c310fbb0465 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 10 Oct 2026 17:18:21 +0200 Subject: [PATCH] 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. --- AGENTS.md | 28 ++++++++++++++++++---------- implementations/main.cpp | 8 +++++--- interfaces/Imsd-Engine.cppm | 16 +++++++++++----- tests/Engine/main.cpp | 6 +++--- 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2a8715b..d1db474 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -246,18 +246,23 @@ Rules: pre-fix build that renewed that closure; the retry on this code, 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 — - §3, §7.4); a `Retry-After` on a REGISTER and a `Dial` bringing a recovery - step forward (KPN's refusals, all 403, carry none; imsd-ofonod refuses the - `Dial` itself and imsd-dialerd forwards it — pinned in `tests/Engine` - only); a refused initial or fresh + §3, §7.4); a `Retry-After` on a REGISTER has run in the field (one + report, a screenshot of 2026-10-08, O2, 0.3.10: a refresh's Retry-After + set the next step's time — `in 7 min`, then `in 13 min` — and the + 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 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 (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 - refresh counted - towards the escalation, and the escalation by two of them (KPN refuses a - refresh only with the 403 that says the registration is gone — pinned in + not pass that arm — pinned in `tests/Engine` only); the escalation by + two refused refreshes that carry no `Retry-After` (KPN refuses a refresh + only with the 403 that says the registration is gone — pinned in `tests/Engine` only); of the throttled-attempt chain in recovery (a throttled fresh attempt, the old SA retried once meanwhile with the stored binding's Contact and refused 403, the deferred attempt accepted at the @@ -497,7 +502,7 @@ The policy, with its constants: | Step | Rule | |---|---| | 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 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`). | @@ -1001,7 +1006,10 @@ the related code. 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 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 `noresource`** when a new one replaces it: that end is not renewed. - **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string diff --git a/implementations/main.cpp b/implementations/main.cpp index 4ef4ff3..67cb950 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -1670,9 +1670,11 @@ private: OnRegistered(); } catch (const RefusedError& e) { // The network refused the refresh: the recovery policy counts it - // (a second refusal means a fresh registration) and the flow is - // dropped so the reconnect step retries the refresh after the - // wait (Retry-After when given). + // — a refusal with a Retry-After of at least a second makes the + // fresh registration the next step, behind the network's wait; + // 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())); CloseFlowAfterWait(); RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status); diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index efb4ada..59a177b 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -74,9 +74,13 @@ export namespace imsd::engine { // fresh registration the network accepted within two seconds (ims // ledger F16). // - // Pacing. Reconnect cycles back off 5 s → 5 min. A refusal escalates - // to a fresh registration after two (one 503 may be a momentary - // hiccup a retried refresh clears), silence after two reconnect cycles + // Pacing. Reconnect cycles back off 5 s → 5 min. A refusal without a + // Retry-After of at least a second (none, 0, unparsable) escalates to + // 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 // was not a local refusal, a bind() the kernel refused, or a refresh // 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 // cycle; `status` is the refusal's. The backoff paces the next step; - // a Retry-After holds every attempt for its own length (up to - // RetryAfterMax), whichever is longer. A 403 once a fresh + // a Retry-After of at least a second holds every attempt for its own + // 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 // it the moment an initial REGISTER arrives, throttled or not). 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++; else silentCycles++; if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true; + if (f == Failure::Refused && retryAfter > 0) fresh = true; if (status == 403 && fresh) oldGone = true; backoffUntil = now + reconnectDelay; reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax); diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 9ab77dd..57de8c5 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -882,9 +882,9 @@ int main() { // 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 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(); - 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); Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it"); 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"); RegRecovery rb; 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; h.OnFreshFailure(1000, F::Refused, 60); Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule");