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:
parent
7583ee3606
commit
a00b14a5a9
4 changed files with 37 additions and 21 deletions
28
AGENTS.md
28
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue