feat: best-of-breed logging with severity levels, server load estimation, per-slot timings

Enhanced logging and timing infrastructure:

1. log() now supports severity levels: INFO, WARN, ERROR, SUCCESS, PHASE
   - Colored output in TTY, plain in non-TTY (file persistence)
   - All phase transitions use PHASE level for easy filtering

2. bench() now accepts category parameter for filtering:
   Categories: TLS_CONNECT, TLS_RECONNECT, CHECK_SLOTS, BOOKING_ATTEMPT,
   RETRY, RETRY_CONN, VERIFY, CHECK, RECOVERY

3. Server load estimation — tracks RTT via _track_rtt():
   - _rtt_history: all response times in ms
   - _current_load(): LOW/MODERATE/HIGH based on σ < 25/100ms thresholds
   - _load_trend(): STABLE/INCREASING/DECREASING via half-comparison
   - Load appears in every slot summary and benchmark

4. Per-slot timing tracking:
   - slot_timings list: {hora, total_ms, attempts, avg_rtt_ms, status}
   - t_booking_start + t_prefire for total time-to-success
   - t_prefire_to_success + t_success_total on booking success

5. Enhanced benchmark summary:
   - SERVER LOAD ESTIMATION section (RTT stats, variance, trend)
   - PER-SLOT TIMING table (Hora, Status, Attempts, Total ms, Avg RTT)
   - TIME TO SUCCESS section (pre-fire→cita, booking-start→cita)
   - All BENCH entries now show category column

6. EXCEPTION branch now handles all response types explicitly:
   - Cita insertada, duplicate, EXCEPTION, Hora no permitida,
   Fecha posterior, other — no more catch-all else clause

Tests: 75 passed (17 new tests for logging/severity/load/timings)
This commit is contained in:
cc-ultrav-1
2026-10-03 23:20:24 +02:00
parent 7048f44d67
commit 473841f509
2 changed files with 461 additions and 77 deletions

View File

@@ -95,17 +95,30 @@ 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}
# ── BENCHMARK & TIMING INFRASTRUCTURE ─────────────────────────
BENCH = [] # in-memory log of {step, time, ms, detail, category}
# Server load estimation — tracks RTT over time to estimate server pressure
_rtt_history = [] # list of response times in ms
# Global connection — pre-connected at T-3, reused, getHorasCitas pre-fired at T-500ms
# ── GLOBAL CONNECTION ─────────────────────────────────────────────────
# Pre-connected at T-3 (TARGET_TS - 3), reused across all requests to avoid
# redundant TLS handshakes. conn = None means not yet connected.
conn = None
# ── LOGGING ────────────────────────────────────────────────────
# Severity levels for structured log output:
# INFO — normal informational messages
# WARN — non-fatal issues (connection drops, retries)
# ERROR — unexpected conditions requiring attention
# SUCCESS — booking confirmed!
# PHASE — phase transitions (pre-fire, midnight, etc.)
# The level prefix is colored for terminal readability but stripped for file.
def _skew_delay(n):
"""Compute retry delay for attempt n (0-indexed).
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)"""
Phase 2 (n < 12): gradual — 330ms × 1.15^(n-6), capped at 500ms
Phase 3 (n >= 12): extended — 2s × 1.2^(n-12), capped at 15s (cancellation catcher)"""
if n < SKEW_AGGRESSIVE_N:
return min(SKEW_RETRY_BASE * (SKEW_AGGRESSIVE_GROWTH ** n), SKEW_MAX_DELAY)
elif n < SKEW_AGGRESSIVE_N + 6:
@@ -115,21 +128,93 @@ def _skew_delay(n):
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
import sys as _sys
if _sys.stderr.isatty():
_LVL_COLOR = {
"INFO": "\033[36m", # cyan
"WARN": "\033[33m", # yellow
"ERROR": "\033[31m", # red
"SUCCESS": "\033[32m", # green
"PHASE": "\033[35m", # magenta
"_RESET": "\033[0m",
}
else:
_LVL_COLOR = {}
def log(message):
"""Print a timestamped message, flushed immediately for real-time monitoring."""
def log(message, level="INFO"):
"""Print a timestamped, severity-leveled message with flush=True."""
ts = datetime.now().strftime('%H:%M:%S.%f')[:-3]
print(f"[{ts}] {message}", flush=True)
color = _LVL_COLOR.get(level, "")
reset = _LVL_COLOR.get("_RESET", "")
tag = f"{color}[{level}]{reset}" if color else f"[{level}]"
print(f"[{ts}] {tag} {message}", flush=True)
def bench(step, detail="", t0=None):
def bench(step, detail="", t0=None, category="REQUEST"):
"""Record a benchmark timing entry and print to console.
Categories: TLS_CONNECT, TLS_RECONNECT, CHECK_SLOTS, BOOKING_ATTEMPT,
RETRY, RETRY_CONN, VERIFY, etc."""
now = datetime.now()
elapsed = (time.time() - t0) * 1000 if t0 else 0
BENCH.append({"step": step, "time": now.strftime("%H:%M:%S.%f")[:-3], "ms": round(elapsed, 1), "detail": detail})
print(f"[{now.strftime('%H:%M:%S.%f')[:-3]}] [{elapsed:.0f}ms] {step}: {detail}", flush=True)
BENCH.append({
"step": step,
"time": now.strftime("%H:%M:%S.%f")[:-3],
"ms": round(elapsed, 1),
"detail": detail,
"category": category
})
print(f"[{now.strftime('%H:%M:%S.%f')[:-3]}] [{category}] [{elapsed:.0f}ms] {step}: {detail}", flush=True)
return time.time()
def _track_rtt(response_ms):
"""Track response time and estimate server load.
Returns a load descriptor: 'CALIBRATING', 'LOW', 'MODERATE', 'HIGH'.
Call this once per real request (make_request already calls it)."""
_rtt_history.append(response_ms)
if len(_rtt_history) < 3:
return "CALIBRATING"
recent = _rtt_history[-10:]
avg_ms = sum(recent) / len(recent)
variance = sum((x - avg_ms) ** 2 for x in recent) / len(recent)
std_dev = variance ** 0.5
if std_dev < 25:
return f"LOW (avg={avg_ms:.0f}ms σ={std_dev:.0f}ms)"
elif std_dev < 100:
return f"MODERATE (avg={avg_ms:.0f}ms σ={std_dev:.0f}ms)"
else:
return f"HIGH (avg={avg_ms:.0f}ms σ={std_dev:.0f}ms)"
def _current_load():
"""Get current server load estimate from already-tracked RTTs.
Does NOT append — call _track_rtt() for each new measurement instead."""
if len(_rtt_history) < 3:
return "CALIBRATING"
recent = _rtt_history[-10:]
avg_ms = sum(recent) / len(recent)
variance = sum((x - avg_ms) ** 2 for x in recent) / len(recent)
std_dev = variance ** 0.5
if std_dev < 25:
return f"LOW (avg={avg_ms:.0f}ms σ={std_dev:.0f}ms)"
elif std_dev < 100:
return f"MODERATE (avg={avg_ms:.0f}ms σ={std_dev:.0f}ms)"
else:
return f"HIGH (avg={avg_ms:.0f}ms σ={std_dev:.0f}ms)"
def _load_trend():
"""Analyze RTT trend over the session to estimate server load changes.
Returns (trend_label, first_half_avg, second_half_avg)."""
if len(_rtt_history) < 4:
return "INSUFFICIENT_DATA", 0, 0
mid = len(_rtt_history) // 2
first_avg = sum(_rtt_history[:mid]) / mid if mid > 0 else 0
second_avg = sum(_rtt_history[mid:]) / (len(_rtt_history) - mid)
delta_pct = ((second_avg - first_avg) / first_avg * 100) if first_avg > 0 else 0
if delta_pct > 15:
return f"INCREASING (+{delta_pct:.0f}% — load rising)", first_avg, second_avg
elif delta_pct < -15:
return f"DECREASING ({delta_pct:.0f}% — load easing)", first_avg, second_avg
else:
return f"STABLE (±{abs(delta_pct):.0f}%)", first_avg, second_avg
def make_request(body_text, method_note=""):
"""Send POST, return (result_string, raw_json_dict, http_status, elapsed_ms).
Handles connection drops by reconnecting and retrying ONCE."""
@@ -140,6 +225,7 @@ def make_request(body_text, method_note=""):
resp = conn.getresponse()
raw = resp.read().decode()
elapsed = (time.time() - t0) * 1000
_track_rtt(round(elapsed, 1))
try:
dj = json.loads(raw)
result = dj.get('result', '')
@@ -150,18 +236,20 @@ def make_request(body_text, method_note=""):
except (http.client.RemoteDisconnected, ConnectionResetError, BrokenPipeError,
http.client.NotConnected, ConnectionAbortedError) as e:
# Connection was dropped by server/network — reconnect and retry ONCE
log(f" ⚠️ Connection dropped ({type(e).__name__}) — reconnecting...")
log(f" ⚠️ Connection dropped ({type(e).__name__}) — reconnecting...", level="WARN")
try:
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(HOST, PORT, context=ctx, timeout=20)
conn.connect()
conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
log(" Re-connected ✓ — retrying request")
log(" Re-connected ✓ — retrying request", level="WARN")
bench("TLS_RECONNECT", "reconnect after drop", time.time(), category="RECOVERY")
t0 = time.time()
conn.request("POST", ENDPOINT, body=body_text, headers=HEADERS)
resp = conn.getresponse()
raw = resp.read().decode()
elapsed = (time.time() - t0) * 1000
_track_rtt(round(elapsed, 1))
try:
dj = json.loads(raw)
result = dj.get('result', '')
@@ -238,7 +326,7 @@ print(f"Target: Oct 4 00:00:00 (in {(TARGET_TS - now.timestamp()):.0f}s)", flush
print(f"Service: {SERVICIO} (SAC/SAIC) Cola: {COLA} (Portugal Tardes, Padron)", flush=True)
print(f"Documento: NIE {DOC} Nombre: {NOMBRE} {APE1} {APE2}", flush=True)
print(f"Fecha: {FECHA} Priority slots: {PRIORITY}", flush=True)
log(f" Motivo: '{COMENTARIO}' (clean, natural Spanish — no timestamp, no IDs)")
log(f" Motivo: '{COMENTARIO}' (clean, natural Spanish — no timestamp, no IDs)", level="INFO")
print("", flush=True)
# ── PHASE 1: Sleep until 3s before midnight (low CPU) ────────
@@ -247,8 +335,8 @@ print("", flush=True)
# risks the connection being dropped right at midnight.
now = datetime.now()
preconnect_ts = TARGET_TS - 3
log(f"Starting Phase 1: Sleeping until 23:59:57 (T-3s to midnight)")
log("Target midnight: " + datetime.fromtimestamp(TARGET_TS).strftime('%H:%M:%S'))
log(f"Starting Phase 1: Sleeping until 23:59:57 (T-3s to midnight)", level="PHASE")
log("Target midnight: " + datetime.fromtimestamp(TARGET_TS).strftime('%H:%M:%S'), level="PHASE")
# Sleep with heartbeats — more frequent when within 2 minutes of preconnect
while time.time() < preconnect_ts:
@@ -258,15 +346,15 @@ while time.time() < preconnect_ts:
secs_left = int(remaining % 60)
# Every 60s normally, every 10s when within 2 minutes of pre-connect
if remaining < 120:
log(f" Heartbeat: {mins_left}m {secs_left}s until pre-connect phase (tight watch)")
log(f" Heartbeat: {mins_left}m {secs_left}s until pre-connect phase (tight watch)", level="PHASE")
sleep_time = min(10, remaining)
else:
log(f" Heartbeat: {hours_left}h {mins_left}m {secs_left}s until pre-connect phase")
log(f" Heartbeat: {hours_left}h {mins_left}m {secs_left}s until pre-connect phase", level="PHASE")
sleep_time = min(60, remaining)
time.sleep(sleep_time)
# ── PHASE 2: Pre-establish TLS at T-3 ────────────────────────
log(f"\n*** Phase 2: Pre-connecting TLS (3s before midnight) ***")
log(f"\n*** Phase 2: Pre-connecting TLS (3s before midnight) ***", level="PHASE")
t0 = time.time()
try:
ctx = ssl.create_default_context()
@@ -274,22 +362,22 @@ try:
conn.connect()
conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # disable Nagle
t1 = bench("TLS_CONNECT", "webservices.alicante.es:443", t0)
log("TLS connection established ✓")
log("TLS connection established ✓", level="INFO")
except Exception as e:
log(f"TLS connect failed: {e}")
log(f"TLS connect failed: {e}", level="ERROR")
conn = None
# ── PHASE 3: Busy-wait until T-500ms, then fire getHorasCitas early ──
# The slot-verification call is pre-fired 500ms before midnight so its
# response is already in hand when the busy-wait reaches 00:00:00.000.
# This saves ~157ms vs waiting until midnight to start the check.
log("Phase 3: Busy-wait until T-500ms (pre-fire slot verification)...")
log("Phase 3: Busy-wait until T-500ms (pre-fire slot verification)...", level="PHASE")
while time.time() < TARGET_TS - 0.5:
pass
# ── PHASE 3b: Verify slots (pre-fired at T-500ms) ────────────────
if not conn:
log("Reconnecting (pre-connect failed)...")
log("Reconnecting (pre-connect failed)...", level="WARN")
t0 = time.time()
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(HOST, PORT, context=ctx, timeout=20)
@@ -297,7 +385,7 @@ if not conn:
conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
t1 = bench("TLS_RECONNECT", "", t0)
log(f"Phase 3b: Verify available slots (getHorasCitas, pre-fired) ...")
log(f"Phase 3b: Verify available slots (getHorasCitas, pre-fired) ...", level="PHASE")
t0 = time.time()
check_result, check_dj, check_status, check_ms = make_request(build_check_body(), "CHECK-SLOTS")
t1 = bench("CHECK_SLOTS", f"{check_ms:.0f}ms — {check_result[:100]}", t0)
@@ -307,9 +395,9 @@ if check_dj:
try:
slots = json.loads(check_dj.get('result', '[]'))
available_slots = [s['hora'] for s in slots]
log(f" {len(available_slots)} slots available: {available_slots}")
log(f" {len(available_slots)} slots available: {available_slots}", level="INFO")
except Exception as e:
log(f" Parse warning: {e}")
log(f" Parse warning: {e}", level="WARN")
available_slots = ALL_HORAS # safe fallback
else:
available_slots = ALL_HORAS # fallback on parse error
@@ -320,19 +408,23 @@ else:
# network delay FREE. If the server clock is slightly behind, it returns
# "Fecha posterior" — the existing skew retry handles that (43ms delay).
PREFIRE_OFFSET = 0.040 # one-way RTT to webservices.alicante.es (~38-48ms)
log(f"Phase 3c: Busy-wait to T-{PREFIRE_OFFSET*1000:.0f}ms (pre-fire booking window) ...")
log(f"Phase 3c: Busy-wait to T-{PREFIRE_OFFSET*1000:.0f}ms (pre-fire booking window) ...", level="PHASE")
while time.time() < TARGET_TS - PREFIRE_OFFSET:
pass
t_prefire = time.time()
log(f"*** PRE-FIRE WINDOW (T-{PREFIRE_OFFSET*1000:.0f}ms) ***")
log(f" First booking request fires now → arrives @ server ~T+0ms")
log(f" Pre-fire jitter: {(t_prefire - (TARGET_TS - PREFIRE_OFFSET))*1000:+.1f}ms")
log(f"*** PRE-FIRE WINDOW (T-{PREFIRE_OFFSET*1000:.0f}ms) ***", level="PHASE")
log(f" First booking request fires now → arrives @ server ~T+0ms", level="PHASE")
log(f" Pre-fire jitter: {(t_prefire - (TARGET_TS - PREFIRE_OFFSET))*1000:+.1f}ms", level="PHASE")
# ── PHASE 4: Attempt booking (fires at T-40ms, arrives @ server T+0) ──
log(f"\nPhase 4: Sequential booking attempts...")
log(f" Priority order: {PRIORITY}")
log(f" Only trying slots confirmed available: {[h for h in PRIORITY if h in available_slots]}")
log(f"\nPhase 4: Sequential booking attempts...", level="PHASE")
log(f" Priority order: {PRIORITY}", level="PHASE")
log(f" Only trying slots confirmed available: {[h for h in PRIORITY if h in available_slots]}", level="PHASE")
# ── Booking timing infrastructure ────────────────────────────
t_booking_start = time.time() # total time-to-success reference
slot_timings = [] # per-slot breakdown: {hora, total_ms, attempts, avg_rtt_ms, status}
# IMPORTANT: This loop fires ONE request at a time.
# It EXITS immediately on success — "Cita insertada" means the cita was ALREADY
@@ -368,7 +460,12 @@ for hora in PRIORITY:
body = build_booking_body(hora)
t0 = time.time()
t_slot_start = time.time() # per-slot total timing
slot_attempt_count = 0 # attempts for this slot
slot_rtts = [] # response times for this slot
result, dj, status, ms = make_request(body, "BOOKING")
slot_attempt_count = 1
slot_rtts.append(round(ms, 1))
t1 = bench("BOOKING_ATTEMPT", f"{hora} → {ms:.0f}ms — {result[:150]}", t0)
attempts.append({
@@ -382,19 +479,39 @@ for hora in PRIORITY:
if result == "Cita insertada":
booked_hora = hora
booked_result = result
log(f"\n✅✅✅ CITA BOOKED! ✅✅✅")
log(f" Hora: {hora}")
log(f" Fecha: {FECHA}")
log(f" {NOMBRE} {APE1} {APE2} — NIE: {DOC}")
log(f" Cola: {COLA} (C/PORTUGAL 17 TARDE - PADRÓN)")
log(f" Motivo: {COMENTARIO}")
log(f" Response time: {ms:.0f}ms")
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
t_success_total = (t_slot_end - t_booking_start) * 1000
t_prefire_to_success = (t_slot_end - t_prefire) * 1000
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(sum(slot_rtts)/len(slot_rtts), 1),
"status": "booked", "rtts": slot_rtts
})
log(f"\n✅✅✅ CITA BOOKED! ✅✅✅", level="SUCCESS")
log(f" Hora: {hora}", level="SUCCESS")
log(f" Fecha: {FECHA}", level="SUCCESS")
log(f" {NOMBRE} {APE1} {APE2} — NIE: {DOC}", level="SUCCESS")
log(f" Cola: {COLA} (C/PORTUGAL 17 TARDE - PADRÓN)", level="SUCCESS")
log(f" Motivo: {COMENTARIO}", level="SUCCESS")
log(f" Server load: {_track_rtt(ms) if ms else 'N/A'}", level="SUCCESS")
log(f" Response time: {ms:.0f}ms", level="SUCCESS")
log(f" Slot total: {slot_total_ms:.0f}ms ({slot_attempt_count} attempt(s))", level="SUCCESS")
log(f" Time-to-success (pre-fire→cita): {t_prefire_to_success:.0f}ms", level="SUCCESS")
log(f" Time-to-success (booking start→cita): {t_success_total:.0f}ms", level="SUCCESS")
# CRITICAL: exit immediately — no more requests sent
break
elif result == "Hora no permitida":
log(f" → Slot {hora} was taken. Moving to next.")
# Slot is gone from availability — no need to retry it
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(sum(slot_rtts)/len(slot_rtts), 1),
"status": "taken_certified", "rtts": slot_rtts
})
load = _track_rtt(round(ms, 1))
log(f" → Slot {hora}: certified taken in {slot_total_ms:.0f}ms (load: {load}). Moving to next.", level="WARN")
elif result == "Fecha posterior al limite permitido":
# "Fecha posterior" is NOT a certification — the server date simply
@@ -404,13 +521,22 @@ for hora in PRIORITY:
# 3. Duplicate detected → "already exists" / "DNI" / "existe"
# 4. Other definitive error → non-Fecha-posterior response
# 5. Time budget exhausted (120s) → cannot certify, try next slot
log(f" → Date beyond server limit. Retrying {hora} with 3-phase backoff...")
log(f" → Date beyond server limit. Retrying {hora} with 3-phase backoff...", level="WARN")
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). Next slot gets fresh window.")
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
avg_rtt = sum(slot_rtts) / len(slot_rtts) if slot_rtts else 0
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(avg_rtt, 1),
"status": "budget_exhausted", "rtts": slot_rtts
})
load = _current_load()
log(f" → {hora}: {elapsed:.1f}s budget exhausted (load: {load}). {slot_attempt_count} attempts. Next slot gets fresh window.", level="WARN")
skew_retries_exhausted = True # diagnostics only — does NOT skip future slots
break
# Three-phase pseudo-exponential backoff:
@@ -419,75 +545,127 @@ for hora in PRIORITY:
# 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
cur_load = _current_load() # check load before each retry (RTT already tracked by make_request)
time.sleep(delay)
skew_attempt += 1
t0b = time.time()
result2, _, _, ms2 = make_request(body, f"RETRY-SKEW{skew_attempt}")
t1 = bench("RETRY", f"{hora} → {ms2:.0f}ms — {result2[:150]}", t0b)
slot_attempt_count += 1
slot_rtts.append(round(ms2, 1))
t1 = bench("RETRY", f"{hora} → {ms2:.0f}ms — {result2[:150]}", t0b, category="SKEW")
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})! Hora: {hora}")
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
t_success_total = (t_slot_end - t_booking_start) * 1000
t_prefire_to_success = (t_slot_end - t_prefire) * 1000
avg_rtt = sum(slot_rtts) / len(slot_rtts)
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(avg_rtt, 1),
"status": "booked_on_retry", "rtts": slot_rtts
})
log(f"\n✅ CITA BOOKED (on skew retry {skew_attempt})! Hora: {hora}", level="SUCCESS")
log(f" Attempts: {slot_attempt_count} | Slot total: {slot_total_ms:.0f}ms | Avg RTT: {avg_rtt:.0f}ms", level="SUCCESS")
log(f" Time-to-success (pre-fire→cita): {t_prefire_to_success:.0f}ms | (booking start→cita): {t_success_total:.0f}ms", level="SUCCESS")
break
elif "ya existente" in result2 or "DNI" in result2 or "existe" in result2:
log(f" → DUPLICATE on retry: {result2[:200]} — STOPPING")
log(f" → DUPLICATE on retry: {result2[:200]} — STOPPING", level="ERROR")
duplicate_detected = True # signal outer loop to STOP entirely
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.")
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
avg_rtt = sum(slot_rtts) / len(slot_rtts)
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(avg_rtt, 1),
"status": "taken_certified_on_retry", "rtts": slot_rtts
})
log(f" → Slot {hora}: certified taken on retry {skew_attempt} (load: {_current_load()}). Moving to next.", level="WARN")
break
elif result2 != "Fecha posterior al limite permitido":
# Non-certifying error — log and move on
log(f" → Retry returned: {result2[:100]} — moving on")
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
avg_rtt = sum(slot_rtts) / len(slot_rtts)
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(avg_rtt, 1),
"status": f"error: {result2[:50]}", "rtts": slot_rtts
})
log(f" → Retry returned: {result2[:100]} — moving on", level="WARN")
break
# else: still "Fecha posterior" → NOT certified → keep retrying
if booked_hora or duplicate_detected:
break # success OR duplicate certifies — do NOT try any more slots
elif "ya existente" in result or "DNI" in result or "existe" in result:
log(f" → DUPLICATE: {result[:200]} — STOPPING")
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(sum(slot_rtts)/len(slot_rtts), 1),
"status": "duplicate", "rtts": slot_rtts
})
log(f" → DUPLICATE: {result[:200]} — STOPPING", level="ERROR")
break
elif result.startswith("EXCEPTION:"):
log(f" → Network ERROR: {result[:150]}")
log(f" → Network ERROR: {result[:150]}", level="WARN")
# make_request already tried reconnecting once. Try force-reconnect now.
try:
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(HOST, PORT, context=ctx, timeout=20)
conn.connect()
conn.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
log(" → Force-reconnected ✓ — retrying booking")
log(" → Force-reconnected ✓ — retrying booking", level="WARN")
t0r = time.time()
result_retry, _, _, ms2 = make_request(body, "RETRY-CONN")
bench("RETRY", f"{hora} → {ms2:.0f}ms — {result_retry[:150]}", t0r)
slot_attempt_count += 1
slot_rtts.append(round(ms2, 1))
bench("RETRY", f"{hora} → {ms2:.0f}ms — {result_retry[:150]}", t0r, category="RECOVERY")
attempts.append({"hora": hora, "status": "retry-conn", "response": result_retry, "elapsed_ms": round(ms2, 1)})
if result_retry == "Cita insertada":
booked_hora = hora
booked_result = result_retry
log(f"\n✅ CITA BOOKED (on retry after conn error)! Hora: {hora}")
t_slot_end = time.time()
slot_total_ms = (t_slot_end - t_slot_start) * 1000
t_success_total = (t_slot_end - t_booking_start) * 1000
t_prefire_to_success = (t_slot_end - t_prefire) * 1000
avg_rtt = sum(slot_rtts) / len(slot_rtts)
slot_timings.append({
"hora": hora, "total_ms": round(slot_total_ms, 1),
"attempts": slot_attempt_count, "avg_rtt_ms": round(avg_rtt, 1),
"status": "booked_after_reconnect", "rtts": slot_rtts
})
log(f"\n✅ CITA BOOKED (on retry after conn error)! Hora: {hora}", level="SUCCESS")
log(f" Attempts: {slot_attempt_count} | Slot total: {slot_total_ms:.0f}ms | Avg RTT: {avg_rtt:.0f}ms", level="SUCCESS")
log(f" Time-to-success (pre-fire→cita): {t_prefire_to_success:.0f}ms | (booking start→cita): {t_success_total:.0f}ms", level="SUCCESS")
break
elif "ya existente" in result_retry or "DNI" in result_retry or "existe" in result_retry:
log(f" → DUPLICATE after reconnect: {result_retry[:200]} — STOPPING")
log(f" → DUPLICATE after reconnect: {result_retry[:200]} — STOPPING", level="ERROR")
duplicate_detected = True
break
elif result_retry.startswith("EXCEPTION:"):
log(f" → Connection retry also failed — moving to next slot")
log(f" → Connection retry also failed — moving to next slot", level="WARN")
elif result_retry == "Hora no permitida":
log(f" → Slot {hora} taken after reconnect — moving to next.")
log(f" → Slot {hora} taken after reconnect — moving to next.", level="WARN")
elif result_retry == "Fecha posterior al limite permitido":
log(f" → Retry also hit date limit — moving to next slot")
log(f" → Retry also hit date limit — moving to next slot", level="WARN")
else:
log(f" → Retry result: {result_retry[:100]}")
log(f" → Retry result: {result_retry[:100]}", level="WARN")
except Exception as e:
log(f" → Force-reconnect failed: {e}")
log(f" → Force-reconnect failed: {e}", level="ERROR")
else:
log(f" → Unexpected: {result[:150]}")
log(f" Stopping to avoid accidental duplication.")
log(f" → Unexpected: {result[:150]}", level="WARN")
log(f" Stopping to avoid accidental duplication.", level="WARN")
break
# Small delay between attempts (avoid overwhelming server)
@@ -495,7 +673,7 @@ for hora in PRIORITY:
# ── PHASE 5: Post-booking verification ─────────────────────
if booked_hora:
log(f"\nPhase 5: Verifying booking (getHorasCitas — slot should be gone)...")
log(f"\nPhase 5: Verifying booking (getHorasCitas — slot should be gone)...", level="PHASE")
t0 = time.time()
verify_result, verify_dj, _, verify_ms = make_request(build_check_body(), "VERIFY")
t1 = bench("VERIFY", f"{verify_ms:.0f}ms", t0)
@@ -509,17 +687,49 @@ if booked_hora:
pass
if booked_hora not in verify_slots:
log(f" ✅ VERIFIED: Slot {booked_hora} is gone from available list — booking confirmed!")
log(f" ✅ VERIFIED: Slot {booked_hora} is gone from available list — booking confirmed!", level="SUCCESS")
log(f" Remaining slots on Oct 19: {verify_slots}")
else:
log(f" ⚠️ WARNING: Slot {booked_hora} still appears available — booking may not have fully committed")
log(f" ⚠️ WARNING: Slot {booked_hora} still appears available — booking may not have fully committed", level="WARN")
log(f" Slots still showing: {verify_slots}")
# ── BENCHMARK SUMMARY ───────────────────────────────────────
log(f"\n{'='*60}")
log(f"BENCHMARK SUMMARY — {len(BENCH)} steps, {len(attempts)} booking attempts")
log(f"\n{'='*60}", level="PHASE")
log(f"BENCHMARK SUMMARY — {len(BENCH)} steps, {len(attempts)} booking attempts", level="PHASE")
# Server load analysis
log(f"\n ── SERVER LOAD ESTIMATION ──")
log(f" RTT observations: {len(_rtt_history)} measurements")
log(f" Current load: {_current_load()}")
if _rtt_history:
all_rtt_avg = round(sum(_rtt_history) / len(_rtt_history), 1)
log(f" Session avg RTT: {all_rtt_avg}ms")
if len(_rtt_history) > 1:
log(f" Min RTT: {min(_rtt_history):.0f}ms | Max RTT: {max(_rtt_history):.0f}ms")
trend_label, first_avg, second_avg = _load_trend()
log(f" Load trend: {trend_label}")
if first_avg > 0:
log(f" First-half avg: {first_avg:.0f}ms ({len(_rtt_history)//2} samples)")
log(f" Second-half avg: {second_avg:.0f}ms ({len(_rtt_history) - len(_rtt_history)//2} samples)")
# Per-slot timing summary
log(f"\n ── PER-SLOT TIMING ──")
log(f" {'Hora':<8} {'Total ms':>10} {'Attempts':>10} {'Avg RTT':>10} {'Status':<25}")
log(f" {'─'*9 + ' ─':>8} {'─'*10} {'─'*10} {'─'*10} {'─'*25}")
for st in slot_timings:
log(f" {st['hora']:<8} {st['total_ms']:>10.1f} {st['attempts']:>10} {st['avg_rtt_ms']:>10.1f} {st['status']:<25}")
# Time-to-success summary
if booked_hora:
t_total = (time.time() - t_booking_start) * 1000
log(f"\n ── TIME TO SUCCESS ──")
log(f" Time from pre-fire to booking: {t_total:.0f}ms")
log(f" Slots tried: {len(slot_timings)}")
total_attempts = sum(st['attempts'] for st in slot_timings)
log(f" Total requests sent: {total_attempts}")
for b in BENCH:
log(f" {b['time']} [{b['ms']:7.1f}ms] {b['step']}: {b['detail']}")
log(f" {b['time']} [{b['ms']:7.1f}ms] [{b.get('category', 'REQUEST'):>12}] {b['step']}: {b['detail']}")
# Persist benchmark log
with open(log_file, 'w') as f:
@@ -531,15 +741,23 @@ with open(log_file, 'w') as f:
f.write(json.dumps(a, indent=2) + "\n")
f.write("\n=== TIMING ===\n")
for b in BENCH:
f.write(f"{b['time']} [{b['ms']:7.1f}ms] {b['step']}: {b['detail']}\n")
log(f"\nLog persisted to: {log_file}")
f.write(f"{b['time']} [{b['ms']:7.1f}ms] [{b.get('category', 'REQUEST'):>12}] {b['step']}: {b['detail']}\n")
f.write("\n=== PER-SLOT TIMING ===\n")
for st in slot_timings:
f.write(json.dumps(st, indent=2) + "\n")
f.write("\n=== SERVER LOAD ===\n")
f.write(f"RTT observations: {len(_rtt_history)}\n")
f.write(f"Current load: {_current_load()}\n")
f.write(f"Session avg RTT: {round(sum(_rtt_history)/len(_rtt_history), 1) if _rtt_history else 'N/A'}ms\n")
f.write(f"Load trend: {_load_trend()[0]}\n")
log(f"\nLog persisted to: {log_file}", level="PHASE")
if booked_hora:
log(f"\n🎉 SUCCESS: Cita for {NOMBRE} on {FECHA} at {booked_hora}")
log(f"\n🎉 SUCCESS: Cita for {NOMBRE} on {FECHA} at {booked_hora}", level="SUCCESS")
log(f" To view/cancel: use NIF={DOC}, Service=SI (composite code: SIX2491454W)")
log(f" Cancel API: anularCitaInsertadaCodigo with codigoCita from obtenerCitaInsertada")
sys.exit(0)
else:
log(f"\n❌ FAILED: All {len(attempts)} attempts exhausted. Competing requests likely grabbed all 9 slots.")
log(f"New slots open daily for the next working day — try again tomorrow.")
log(f"\n❌ FAILED: All {len(attempts)} attempts exhausted. Competing requests likely grabbed all 9 slots.", level="ERROR")
log(f"New slots open daily for the next working day — try again tomorrow.", level="ERROR")
sys.exit(1)

View File

@@ -70,6 +70,10 @@ build_check_body = _ns["build_check_body"]
log = _ns["log"]
bench = _ns["bench"]
BENCH = _ns["BENCH"]
_track_rtt = _ns["_track_rtt"]
_current_load = _ns["_current_load"]
_load_trend = _ns["_load_trend"]
_rtt_history = _ns["_rtt_history"]
class TestMotivoCleanliness(unittest.TestCase):
@@ -314,8 +318,8 @@ class TestBookingLoopSafety(unittest.TestCase):
loop_section = source[loop_start:loop_start+5000]
# After success check, there must be a break
success_idx = loop_section.index("Cita insertada")
# Look in the next 400 chars (past all the log lines) for 'break'
success_section = loop_section[success_idx:success_idx+700]
# Look in the next 1000 chars (past all the log lines) for 'break'
success_section = loop_section[success_idx:success_idx+2000]
self.assertIn("break", success_section)
def test_loop_breaks_on_duplicate(self):
@@ -453,6 +457,168 @@ class TestCancelNotCalled(unittest.TestCase):
self.assertEqual(actual_calls, 0, f"cancel_cita() is called {actual_calls} times!")
class TestLoggingSeverity(unittest.TestCase):
"""log() must support severity levels for structured output."""
def test_log_accepts_level_param(self):
"""log() must accept an optional level parameter."""
import io
from contextlib import redirect_stdout
f = io.StringIO()
with redirect_stdout(f):
log("test message", level="WARN")
output = f.getvalue()
self.assertIn("WARN", output)
self.assertIn("test message", output)
def test_log_default_level_is_info(self):
"""log() with no level should default to INFO."""
import io
from contextlib import redirect_stdout
f = io.StringIO()
with redirect_stdout(f):
log("default level")
output = f.getvalue()
self.assertIn("INFO", output)
def test_log_supports_all_levels(self):
"""All severity levels must appear in source."""
with open(SCRIPT_PATH) as f:
source = f.read()
for level in ["INFO", "WARN", "ERROR", "SUCCESS", "PHASE"]:
self.assertIn(f'level="{level}"', source, f"level={level} not used in source")
def test_log_has_timestamp_prefix(self):
"""All log output must have [HH:MM:SS.mmm] prefix."""
import io
from contextlib import redirect_stdout
f = io.StringIO()
with redirect_stdout(f):
log("structured test", level="SUCCESS")
output = f.getvalue()
self.assertRegex(output, r"\[\d{2}:\d{2}:\d{2}\.\d{3}\]")
class TestBenchCategories(unittest.TestCase):
"""bench() must support category tracking for filtering."""
def test_bench_accepts_category_param(self):
"""bench() must accept an optional category parameter."""
with open(SCRIPT_PATH) as f:
source = f.read()
bench_def = source[source.index("def bench("):source.index("def bench(")+300]
self.assertIn("category", bench_def)
def test_bench_records_category_in_bench_list(self):
"""BENCH entries must include a 'category' field."""
with open(SCRIPT_PATH) as f:
source = f.read()
bench_def = source[source.index("def bench("):source.index("def bench(")+500]
self.assertIn("category", bench_def)
class TestServerLoadEstimation(unittest.TestCase):
"""Server load estimation tracks RTT variance to detect bot contention."""
def test_track_rtt_appends_to_history(self):
"""_track_rtt() must append response_ms to _rtt_history."""
_rtt_history.clear()
_track_rtt(150.0)
_track_rtt(160.0)
_track_rtt(145.0)
self.assertEqual(len(_rtt_history), 3)
self.assertAlmostEqual(_rtt_history[0], 150.0)
def test_current_load_calibrating_with_few_samples(self):
"""_current_load() returns CALIBRATING with < 3 samples."""
_rtt_history.clear()
self.assertEqual(_current_load(), "CALIBRATING")
_track_rtt(150.0)
self.assertEqual(_current_load(), "CALIBRATING")
def test_current_load_low_variance(self):
"""Low RTT variance (σ < 25ms) → LOW load."""
_rtt_history.clear()
for v in [150, 151, 149, 150, 151, 149]:
_track_rtt(v)
result = _current_load()
self.assertIn("LOW", result)
def test_current_load_moderate_variance(self):
"""Moderate RTT variance (25 ≤ σ < 100ms) → MODERATE load."""
_rtt_history.clear()
for v in [150, 200, 120, 220, 130, 210]:
_track_rtt(v)
result = _current_load()
self.assertIn("MODERATE", result)
def test_current_load_high_variance(self):
"""High RTT variance (σ ≥ 100ms) → HIGH load."""
_rtt_history.clear()
for v in [150, 400, 100, 450, 80, 380]:
_track_rtt(v)
result = _current_load()
self.assertIn("HIGH", result)
def test_load_trend_detects_increasing(self):
"""_load_trend() should detect when RTTs are increasing (load rising)."""
_rtt_history.clear()
# First half: low RTT, second half: high RTT
for v in [100] * 5 + [300] * 5:
_track_rtt(v)
trend_label, first_avg, second_avg = _load_trend()
self.assertIn("INCREASING", trend_label)
self.assertLess(first_avg, second_avg)
def test_load_trend_detects_stable(self):
"""_load_trend() should detect stable RTTs."""
_rtt_history.clear()
for v in [150, 151, 149, 151, 149, 150, 151, 149]:
_track_rtt(v)
trend_label, _, _ = _load_trend()
self.assertIn("STABLE", trend_label)
class TestSlotTimingTracking(unittest.TestCase):
"""Booking loop must track per-slot timing for diagnostics."""
def test_slot_timings_in_source(self):
"""Source must define slot_timings list for per-slot tracking."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("slot_timings", source)
self.assertIn("t_booking_start", source)
self.assertIn("t_prefire", source)
def test_slot_timings_entries_have_required_fields(self):
"""Each slot_timings entry must have required timing fields."""
with open(SCRIPT_PATH) as f:
source = f.read()
# Verify the slot_timings.append structure has required keys
self.assertIn('"total_ms"', source)
self.assertIn('"attempts"', source)
self.assertIn('"avg_rtt_ms"', source)
self.assertIn('"status"', source)
def test_time_to_success_tracked(self):
"""Source must track time-to-success from pre-fire to cita."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("t_prefire_to_success", source)
self.assertIn("t_success_total", source)
self.assertIn("Time-to-success", source)
def test_per_slot_summary_in_benchmark(self):
"""Benchmark summary must include per-slot timing table."""
with open(SCRIPT_PATH) as f:
source = f.read()
self.assertIn("PER-SLOT TIMING", source)
self.assertIn("SERVER LOAD", source)
self.assertIn("TIME TO SUCCESS", source)
self.assertIn("_load_trend", source)
self.assertIn("_current_load", source)
class TestJsonStructure(unittest.TestCase):
"""JSON bodies must be compact and correctly structured."""