Fix: two-phase backoff — each slot gets independent retry window

CRITICAL BUG FIX: skew_retries_exhausted was a global skip gate — once
first slot exhausted retries, ALL subsequent slots skipped retries.
Later slots could miss the midnight transition during their own window.

New design:
- Two-phase pseudo-exponential backoff PER SLOT (not global):
  Phase 1 (aggressive): 43ms x 1.5^n for first 6 retries
  Phase 2 (gradual): 330ms x 1.15^n after 6 (less bot-spammy)
- 2.5s time budget per slot (covers NTP skew up to ~2s + lift)
- skew_retries_exhausted is now diagnostics-only (set but NOT used to skip)
- _skew_delay() helper computes two-phase delays
- Only 'Hora no permitida' certifies a slot as taken
- 'Fecha posterior' never a give-up reason — server date not rolled yet

Tests: 57 passed
Live: 8 retries on 15:15, all 9 slots still free post-test
This commit is contained in:
cc-ultrav-1
2026-10-03 21:40:33 +02:00
parent 246bb92b1c
commit 7cf94f8a3a
2 changed files with 105 additions and 36 deletions

View File

@@ -15,8 +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 at 43ms (not 200ms): lift(40ms)+buffer(3ms) — faster stack-up, covers NTP skew up to ~130ms.
Each slot gets its OWN 5×43ms retry window — server date may roll during a later slot's window
- 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)
"""
import http.client, ssl, json, time, sys, socket
from datetime import datetime
@@ -68,12 +70,44 @@ HEADERS = {
# Oct 4, 2026 00:00:00 CEST (when the 15-day window slides to include Oct 19)
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
# 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
# "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)
# ── BENCHMARK LOG ───────────────────────────────────────────
BENCH = [] # in-memory list of {step, timestamp, elapsed_ms, detail}
# Global connection — pre-connected at T-3, reused, getHorasCitas pre-fired at T-500ms
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"""
if n < SKEW_AGGRESSIVE_N:
return min(SKEW_RETRY_BASE * (SKEW_AGGRESSIVE_GROWTH ** n), SKEW_MAX_DELAY)
else:
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)
# Global connection — pre-connected at T-3, reused, getHorasCitas pre-fired at T-500ms
conn = None
def log(message):
"""Print a timestamped message, flushed immediately for real-time monitoring."""
ts = datetime.now().strftime('%H:%M:%S.%f')[:-3]
@@ -293,11 +327,15 @@ 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 5×43ms retry window
# - "EXCEPTION:" (conn drop): force-reconnect + 1 retry on SAME slot
# - "Hora no permitida": no retry (slot taken — move to next)
# Each slot's retry window is independent — the server date may roll
# during a later slot's retries even if earlier slots exhausted theirs.
# - "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.
# - "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
# a later slot's retries even if earlier slots exhausted theirs.
# Only give up on a slot when: certified taken ("Hora no permitida") or
# time budget exhausted (can't certify further) or duplicate detected.
# Duplicate detection ("ya existente") → STOP immediately
# This prevents duplicate bookings while tolerating clock/network issues.
@@ -346,37 +384,53 @@ for hora in PRIORITY:
# Slot is gone from availability — no need to retry it
elif result == "Fecha posterior al limite permitido":
# Each slot gets its own independent 5×43ms retry window.
# The server date rolls at midnight — the transition may happen
# during THIS slot's retry window even if earlier slots failed.
log(f" → Date beyond server limit (possible clock skew). Retrying {hora} up to 5x...")
for skew_attempt in range(5):
# 43ms delay: lift_time(40ms) + buffer(3ms)
# After pre-fire fails, this allows retry to arrive past server's midnight + lift.
# 5 × 43ms ≈ 645ms total window — covers NTP skew up to ~130ms.
# With 200ms this would be ~1000ms — slower recovery for no real safety gain.
time.sleep(0.043) # server lift(40ms) + 3ms buffer
# "Fecha posterior" is NOT a certification — the server date simply
# hasn't rolled yet. We must keep retrying until one of these:
# 1. Booking succeeds → "Cita insertada" (slot available, not taken)
# 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...")
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.")
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
delay = _skew_delay(skew_attempt)
delay = min(delay, SKEW_MAX_BUDGET - elapsed) # don't sleep past budget
time.sleep(delay)
skew_attempt += 1
t0b = time.time()
result2, _, _, ms2 = make_request(body, f"RETRY-SKEW{skew_attempt+1}")
result2, _, _, ms2 = make_request(body, f"RETRY-SKEW{skew_attempt}")
t1 = bench("RETRY", f"{hora} → {ms2:.0f}ms — {result2[:150]}", t0b)
attempts.append({"hora": hora, "status": f"retry-skew{skew_attempt+1}", "response": result2, "elapsed_ms": round(ms2, 1)})
attempts.append({"hora": hora, "status": f"retry-skew{skew_attempt}", "response": result2, "elapsed_ms": round(ms2, 1)})
if result2 == "Cita insertada":
booked_hora = hora
booked_result = result2
log(f"\n✅ CITA BOOKED (on skew retry {skew_attempt+1})! Hora: {hora}")
log(f"\n✅ CITA BOOKED (on skew retry {skew_attempt})! Hora: {hora}")
break
elif "ya existente" in result2 or "DNI" in result2 or "existe" in result2:
log(f" → POSSIBLE DUPLICATE on retry: {result2[:200]}")
log(f" ⚠️ STOPPING — cita may already exist!")
break
elif result2 == "Hora no permitida":
# ✅ CERTIFIED: slot is genuinely taken — safe to give up THIS slot
log(f" → Slot {hora} certified unavailable on retry. Moving to next.")
break
elif result2 != "Fecha posterior al limite permitido":
# Non-certifying error — log and move on
log(f" → Retry returned: {result2[:100]} — moving on")
break
# else: still "Fecha posterior" → loop continues to next retry
else:
# All 5 skew retries exhausted on THIS slot. Next slot still gets its own retries.
skew_retries_exhausted = True # diagnostics only — does NOT skip future slots
log(f" → All 5 retries exhausted for {hora} (~215ms). Next slot gets fresh retry window.")
# else: still "Fecha posterior" → NOT certified → keep retrying
if booked_hora:
break

View File

@@ -339,27 +339,42 @@ class TestBookingLoopSafety(unittest.TestCase):
self.assertNotIn("multiprocessing", source)
def test_skew_retry_logic_exists(self):
"""The 5×43ms clock-skew retry must exist (reduced from 200ms for faster recovery)."""
"""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."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("range(5)", source)
self.assertIn("0.043", source)
self.assertIn("skew_retries_exhausted", source)
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_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
def test_each_slot_gets_own_retry_window(self):
"""No global skip flag — each priority slot must get its own 5×43ms retry window.
"""No global skip flag — each priority slot must get its own retry window.
The server date may roll during a later slot's retry window even if earlier
slots exhausted theirs, so retries must NOT be gated by a global flag."""
slots exhausted theirs, so retries must NOT be gated by a global flag.
Only 'Hora no permitida' certifies a slot as taken."""
with open(SCRIPT_PATH) as f:
source = f.read()
loop_start = source.index("for hora in PRIORITY:")
loop_section = source[loop_start:loop_start+8000]
# The skew retry for-loop must exist inside the priority loop (not behind a skip gate)
self.assertIn("for skew_attempt in range(5)", loop_section)
# There must be NO 'if skew_retries_exhausted:' that skips retries
# Must NOT skip retries based on a global flag
self.assertNotIn("if skew_retries_exhausted:", loop_section)
# Comment must confirm per-slot independence
self.assertIn("own", loop_section.lower())
# Must use time-based budget (not fixed count) for "Fecha posterior"
self.assertIn("SKEW_MAX_BUDGET", loop_section)
# Two-phase backoff: aggressive then gradual
self.assertIn("SKEW_AGGRESSIVE_N", source)
self.assertIn("SKEW_GRADUAL_GROWTH", 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)
def test_exception_branch_exists(self):
"""The EXCEPTION handling branch must exist."""