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

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