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