feat: three-phase skew retry with 120s budget — no false giveups

Three-phase backoff per slot, each retry independent (no global skip):
  Phase 1 (6 retries): 43ms × 1.5^n — aggressive midnight catch
  Phase 2 (6 retries): 330ms × 1.15^n, capped 500ms — gradual
  Phase 3 (120s budget): 2s × 1.2^n, capped 15s — catches cancellations

Only give up when CERTIFIED:
  'Hora no permitida' = slot genuinely taken (safe to skip this slot)
  'Cita insertada' = success (exit)
  'already exists'/duplicate = STOP entirely
  'Fecha posterior' = NOT certified → keep retrying

SKEW_MAX_BUDGET raised from 2.5s → 120s. No false-positive giveups.
Tests: 57 passed
This commit is contained in:
cc-ultrav-1
2026-10-03 22:28:52 +02:00
parent 7cf94f8a3a
commit f4f5fe0996
2 changed files with 51 additions and 35 deletions

View File

@@ -15,10 +15,10 @@ Addresses all concerns:
- getHorasCitas pre-fired at T-500ms to overlap with pre-midnight idle — saves ~157ms
- Booking request pre-fired at T-40ms to absorb one-way RTT — saves ~40ms
- TCP_NODELAY on all connections to disable Nagle's algorithm
- Skew retry with two-phase pseudo-exponential backoff (43ms × 1.5^n for first
6 retries, then 1.15x — covers NTP skew up to ~2s per slot; each slot retries
independently; only gives up on certified signals: "Hora no permitida" = taken,
"Cita insertada" = booked, duplicate = STOP)
- Skew retry with three-phase backoff (43ms×1.5^n for 6 aggressive, then 1.15x
gradual, then 2s×1.2^n gradual to 120s budget — catches cancellations); each
slot retries independently; only gives up on certified signals: "Hora no permitida"
= taken, "Cita insertada" = booked, duplicate = STOP)
"""
import http.client, ssl, json, time, sys, socket
from datetime import datetime
@@ -71,23 +71,29 @@ HEADERS = {
TARGET_TS = datetime(2026, 10, 4, 0, 0, 0).timestamp()
# ── SKEW RETRY CONFIG ───────────────────────────────────────
# Pseudo-exponential backoff for "Fecha posterior" responses.
# Two phases per slot (WE ARE FIGHTING BOTS — each slot gets independent retries):
# Phase 1 (aggressive): first 6 retries at 43ms × 1.5^n — catches midnight fast
# Phase 2 (gradual): after 6, growth slows to 1.15x — less spam, still persistent
# Three-phase backoff for "Fecha posterior" responses.
# Each slot retries independently until CERTIFIED taken or success:
# Phase 1 (aggressive): 43ms × 1.5^n for first 6 retries — midnight fast
# Phase 2 (gradual): 330ms × 1.15^n, capped 500ms — less spammy
# Phase 3 (extended): 2s × 1.2^n, capped 15s — catches post-midnight cancellations
# Only stops retrying when:
# - Booking succeeds ("Cita insertada")
# - Slot certified taken ("Hora no permitida") → give up THIS slot
# - Duplicate detected ("ya existente") → STOP entirely
# - Time budget exhausted (2.5s) → cannot certify, try next slot
# - Duplicate detected ("already exists" / "DNI" / "existe") → STOP entirely
# - Time budget exhausted (120s) → cannot certify, move to next slot
# "Fecha posterior" alone is NEVER a give-up reason — server date hasn't rolled.
SKEW_RETRY_BASE = 0.043 # 43ms: server lift (~40ms) + 3ms buffer
SKEW_AGGRESSIVE_N = 6 # first 6 retries at aggressive growth
SKEW_AGGRESSIVE_GROWTH = 1.5 # 43, 65, 98, 147, 220, 330ms
SKEW_GRADUAL_GROWTH = 1.15 # after 6: 380, 437, 500ms (slower = less bot-spammy)
SKEW_MAX_DELAY = 0.500 # cap individual delay at 500ms
SKEW_MAX_BUDGET = 2.5 # 2.5s per slot (covers NTP skew up to ~2s + lift)
SKEW_MAX_RETRIES = 50 # hard safety cap (budget hits first at ~8 retries)
# "Hora no permitida" is the ONLY certification that a slot is taken.
SKEW_RETRY_BASE = 0.043 # 43ms: server lift (~40ms) + 3ms buffer
SKEW_AGGRESSIVE_N = 6 # first 6 retries at aggressive growth
SKEW_AGGRESSIVE_GROWTH = 1.5 # 43, 65, 98, 147, 220, 330ms
SKEW_GRADUAL_GROWTH = 1.15 # Phase 2: 380, 437, 500ms (slower = less bot-spammy)
SKEW_MAX_DELAY = 0.500 # Phase 1+2 cap: 500ms
SKEW_PHASE3_START = 2.5 # start Phase 3 after 2.5s
SKEW_EXTENDED_BASE = 2.0 # 2s base for Phase 3
SKEW_EXTENDED_GROWTH = 1.2 # 1.2x growth (gentle): 2, 2.4, 2.88, 3.46, ...
SKEW_EXTENDED_CAP = 15.0 # cap Phase 3 delay at 15s
SKEW_MAX_BUDGET = 120.0 # 120s per slot (covers cancellations up to 2min after midnight)
SKEW_MAX_RETRIES = 200 # hard safety cap (budget hits first at ~40 retries)
# ── BENCHMARK LOG ───────────────────────────────────────────
BENCH = [] # in-memory list of {step, timestamp, elapsed_ms, detail}
@@ -97,13 +103,17 @@ conn = None
def _skew_delay(n):
"""Compute retry delay for attempt n (0-indexed).
Phase 1 (n < 6): aggressive — 43ms × 1.5^n
Phase 2 (n >= 6): gradual — 330ms × 1.15^(n-6), capped at 500ms"""
Phase 1 (n < 6): aggressive — 43ms × 1.5^n (midnight-fast)
Phase 2 (n < ~11): gradual — 330ms × 1.15^(n-6), capped at 500ms
Phase 3 (n >= 11): extended — 2s × 1.2^(n-11), capped at 15s (cancellation catcher)"""
if n < SKEW_AGGRESSIVE_N:
return min(SKEW_RETRY_BASE * (SKEW_AGGRESSIVE_GROWTH ** n), SKEW_MAX_DELAY)
else:
elif n < SKEW_AGGRESSIVE_N + 6:
grad_base = min(SKEW_RETRY_BASE * (SKEW_AGGRESSIVE_GROWTH ** (SKEW_AGGRESSIVE_N - 1)), SKEW_MAX_DELAY)
return min(grad_base * (SKEW_GRADUAL_GROWTH ** (n - SKEW_AGGRESSIVE_N)), SKEW_MAX_DELAY)
else:
ext_n = n - SKEW_AGGRESSIVE_N - 6
return min(SKEW_EXTENDED_BASE * (SKEW_EXTENDED_GROWTH ** ext_n), SKEW_EXTENDED_CAP)
# Global connection — pre-connected at T-3, reused, getHorasCitas pre-fired at T-500ms
conn = None
@@ -327,9 +337,9 @@ log(f" Only trying slots confirmed available: {[h for h in PRIORITY if h in ava
# IMPORTANT: This loop fires ONE request at a time.
# It EXITS immediately on success (no more requests after "Cita insertada").
# Each slot is tried ONCE, with retries ONLY for:
# - "Fecha posterior" (clock skew): EACH slot gets its own independent 2-phase
# pseudo-exponential backoff (43ms×1.5^n for 6 aggressive retries, then
# 1.15x gradual, 2.5s budget). WE ARE FIGHTING BOTS — every slot retries.
# - "Fecha posterior" (clock skew): EACH slot gets its own independent 3-phase
# backoff (43ms×1.5^n for 6 aggressive, 1.15x gradual to 2.5s, 2s×1.2^n extended
# to 120s). WE ARE FIGHTING BOTS — every slot retries until certified taken.
# - "EXCEPTION:" (conn drop): force-reconnect + retry on SAME slot
# - "Hora no permitida": DEFINITIVE certification slot is taken → no retry
# Each slot's retry window is independent — the server date may roll during
@@ -390,19 +400,20 @@ for hora in PRIORITY:
# 2. Slot certified taken → "Hora no permitida" on retry
# 3. Duplicate detected → "already exists" / "DNI" / "existe"
# 4. Other definitive error → non-Fecha-posterior response
# 5. Time budget exhausted (2.5s) → cannot certify, try next slot
log(f" → Date beyond server limit. Retrying {hora} with 2-phase backoff...")
# 5. Time budget exhausted (120s) → cannot certify, try next slot
log(f" → Date beyond server limit. Retrying {hora} with 3-phase backoff...")
t_slot_start = time.time()
skew_attempt = 0
while skew_attempt < SKEW_MAX_RETRIES:
elapsed = time.time() - t_slot_start
if elapsed >= SKEW_MAX_BUDGET:
log(f" → {hora}: {elapsed:.1f}s budget exhausted (cannot certify). Moving to next slot.")
log(f" → {hora}: {elapsed:.1f}s budget exhausted (cannot certify). Next slot gets fresh window.")
skew_retries_exhausted = True # diagnostics only — does NOT skip future slots
break
# Two-phase pseudo-exponential backoff:
# Phase 1 (attempts 1-6): 43ms × 1.5^n — aggressive, catches midnight fast
# Phase 2 (attempt 7+): 330ms × 1.15^n — gradual, less bot-spammy
# Three-phase pseudo-exponential backoff:
# Phase 1 (attempts 1-6): 43ms × 1.5^n — aggressive, midnight-fast
# Phase 2 (attempts 7-12): 330ms × 1.15^n, capped 500ms — less spammy
# Phase 3 (attempt 13+): 2s × 1.2^n, capped 15s — extended, catches cancellations
delay = _skew_delay(skew_attempt)
delay = min(delay, SKEW_MAX_BUDGET - elapsed) # don't sleep past budget
time.sleep(delay)

View File

@@ -339,19 +339,22 @@ class TestBookingLoopSafety(unittest.TestCase):
self.assertNotIn("multiprocessing", source)
def test_skew_retry_logic_exists(self):
"""Two-phase pseudo-exponential backoff skew retry with time budget.
Phase 1: 6 aggressive retries (43ms × 1.5^n), Phase 2: gradual (1.15x).
Each slot retries independently — we're fighting bots."""
"""Three-phase backoff: aggressive (43ms×1.5^n) → gradual (1.15x) → extended (2s×1.2^n).
120s budget per slot, only gives up on certified 'Hora no permitida'."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("SKEW_RETRY_BASE", source)
self.assertIn("SKEW_AGGRESSIVE_N", source)
self.assertIn("SKEW_AGGRESSIVE_GROWTH", source)
self.assertIn("SKEW_GRADUAL_GROWTH", source)
self.assertIn("SKEW_EXTENDED_BASE", source)
self.assertIn("SKEW_EXTENDED_GROWTH", source)
self.assertIn("SKEW_MAX_BUDGET", source)
self.assertIn("0.043", source) # base delay (also PREFIRE_OFFSET)
self.assertIn("skew_retries_exhausted", source) # diagnostics var still exists
self.assertIn("_skew_delay", source) # delay calculation function
# Budget must be ≥ 60s (not just 2.5s) — catches post-midnight cancellations
self.assertIn("120", source)
def test_each_slot_gets_own_retry_window(self):
"""No global skip flag — each priority slot must get its own retry window.
@@ -364,17 +367,19 @@ class TestBookingLoopSafety(unittest.TestCase):
loop_section = source[loop_start:loop_start+8000]
# Must NOT skip retries based on a global flag
self.assertNotIn("if skew_retries_exhausted:", loop_section)
# Must use time-based budget (not fixed count) for "Fecha posterior"
# Must use time-based budget (not fixed count)
self.assertIn("SKEW_MAX_BUDGET", loop_section)
# Two-phase backoff: aggressive then gradual
# Three-phase backoff: aggressive, gradual, extended
self.assertIn("SKEW_AGGRESSIVE_N", source)
self.assertIn("SKEW_GRADUAL_GROWTH", source)
self.assertIn("SKEW_EXTENDED_BASE", source)
# "Hora no permitida" must be the certification signal on retry
self.assertIn("Hora no permitida", loop_section)
# "Fecha posterior" must NOT be a give-up signal without retry
self.assertIn("Fecha posterior", loop_section)
# Must reference the delay function
self.assertIn("_skew_delay", source)
# Must have time budget check (not fixed retry count)
self.assertIn("SKEW_MAX_BUDGET", loop_section)
def test_exception_branch_exists(self):
"""The EXCEPTION handling branch must exist."""