Files
ai-bot/tests/test_retry_scheduler.py

150 lines
4.9 KiB
Python

"""Tests for orac.runtime.RetryScheduler: attempt progression, path reset, exhaustion."""
from __future__ import annotations
import itertools
import time
from orac.constants import (
PENDING_ACK_TTL_S,
REPLY_FLOOD_FIRST_ATTEMPT,
REPLY_MAX_ATTEMPTS,
RETRY_SCHEDULE_S,
)
from orac.reply_state import PendingAckTable, PendingReply, ReplyCache # noqa: F401
from orac.runtime import Metrics, RetryScheduler, TxQueue
def _entry(
ack: bytes,
attempt: int = 1,
retry_at_offset: float = 0.0,
max_attempts: int = 5,
) -> PendingReply:
now = time.monotonic()
return PendingReply(
expected_ack=ack,
peer_pk=b"\xaa" * 32,
peer_name="peer_aa",
text="hi",
first_ts=1234567890,
first_sent_at=now,
attempt=attempt,
max_attempts=max_attempts,
retry_at=now + retry_at_offset,
)
def _noop_rebuild(_entry: PendingReply, attempt: int, force_flood: bool) -> tuple[bytes, bytes]:
"""Rebuild stub: new ACK differs from old so replace_key is exercised."""
new_ack = bytes([attempt & 0xFF, 0xAA, 0xBB, 0xCC])
return (b"REBUILT-%d-%s" % (attempt, b"F" if force_flood else b"D"), new_ack)
def test_direct_retry_arms_next_attempt() -> None:
pending = PendingAckTable()
tx = TxQueue()
m = Metrics()
forgot: list[int] = []
sched = RetryScheduler(
pending=pending,
tx_queue=tx,
metrics=m,
rebuild_reply=_noop_rebuild,
forget_route_fn=lambda h: (forgot.append(h), True)[1],
)
# First attempt (entry.attempt=1) with retry_at already passed.
e = _entry(b"\x00\x00\x00\x01", attempt=1, retry_at_offset=-0.1)
pending.register(e)
sched.tick(time.monotonic())
# Next attempt (2) is a direct retry — route should NOT have been forgotten.
assert forgot == []
# The entry's expected_ack has been re-keyed.
assert pending.consume(b"\x00\x00\x00\x01") is None
# New key from _noop_rebuild is (attempt=2, ...).
item = tx.pop_ready(time.monotonic())
assert item is not None
assert item.attempt == 2
assert item.expected_ack == bytes([0x02, 0xAA, 0xBB, 0xCC])
def test_flood_retry_forgets_route() -> None:
pending = PendingAckTable()
tx = TxQueue()
m = Metrics()
forgot: list[int] = []
sched = RetryScheduler(
pending=pending,
tx_queue=tx,
metrics=m,
rebuild_reply=_noop_rebuild,
forget_route_fn=lambda h: (forgot.append(h), True)[1],
)
# Entry attempt = REPLY_FLOOD_FIRST_ATTEMPT - 1; next tick hits flood attempt.
e = _entry(
b"\x00\x00\x00\x02",
attempt=REPLY_FLOOD_FIRST_ATTEMPT - 1,
retry_at_offset=-0.1,
)
pending.register(e)
sched.tick(time.monotonic())
# route should have been forgotten for the peer's 1-byte hash.
assert forgot == [e.peer_pk[0]]
item = tx.pop_ready(time.monotonic())
assert item is not None
assert item.attempt == REPLY_FLOOD_FIRST_ATTEMPT
# The rebuilt packet's label tag for flood is 'F' in our stub.
assert item.packet.endswith(b"F")
def test_exhausted_drops_and_counts() -> None:
pending = PendingAckTable()
tx = TxQueue()
m = Metrics()
sched = RetryScheduler(
pending=pending,
tx_queue=tx,
metrics=m,
rebuild_reply=_noop_rebuild,
forget_route_fn=lambda _h: False,
)
e = _entry(b"\x00\x00\x00\x03", attempt=5, retry_at_offset=-0.1, max_attempts=5)
pending.register(e)
sched.tick(time.monotonic())
assert pending.depth() == 0
counters, _, _ = m.snapshot()
assert counters.get("reply_exhausted", 0) == 1
def test_retry_schedule_is_airtime_headroom() -> None:
# Guardrail: encourage authors to keep realistic spacing.
assert RETRY_SCHEDULE_S[0] >= 3.0, "attempt-2 must leave headroom for a 2-hop RTT"
for prev, cur in itertools.pairwise(RETRY_SCHEDULE_S):
assert cur >= prev, "retry schedule should be monotonically non-decreasing"
def test_schedule_covers_all_retries_for_max_attempts() -> None:
"""Each retry after the initial send needs an explicit delay entry."""
# REPLY_MAX_ATTEMPTS counts the initial send, so retries = MAX - 1.
assert len(RETRY_SCHEDULE_S) >= REPLY_MAX_ATTEMPTS - 1, (
"RETRY_SCHEDULE_S too short for REPLY_MAX_ATTEMPTS — the final retry clamps "
"back to the last entry rather than using a distinct delay"
)
def test_pending_ack_ttl_exceeds_final_retry_plus_headroom() -> None:
"""TTL sweep must not kill pending entries before the last retry's ACK window."""
total_retry_delay = sum(RETRY_SCHEDULE_S[: REPLY_MAX_ATTEMPTS - 1])
# At least 20 s past the final retry's send time for its ACK to arrive.
min_required = total_retry_delay + 20
assert min_required <= PENDING_ACK_TTL_S, (
f"PENDING_ACK_TTL_S={PENDING_ACK_TTL_S} cuts the last retry short; "
f"needs >= {min_required}s (sum of retry delays + 20s ACK window)"
)