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:
104
book_final.py
104
book_final.py
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user