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
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

View file

@ -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);

View file

@ -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);

View file

@ -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");