Files
opensquilla--opensquilla/tests/test_scheduler/test_exact_and_retry_classification.py
2026-07-13 13:12:33 +08:00

253 lines
8.4 KiB
Python

"""Per-job stagger override + retry transient/permanent classification.
* ``--exact`` / ``jitterSeconds`` lets a caller override the auto-jitter so
jobs can fire on the exact second (e.g. scheduled reports).
* Retry classification: permanent errors (auth/validation) disable the job
immediately instead of burning the full recurring-job backoff schedule.
"""
from __future__ import annotations
from datetime import UTC, datetime
from pathlib import Path
from opensquilla.gateway.rpc import RpcContext
from opensquilla.gateway.rpc_cron import _handle_cron_add
from opensquilla.scheduler.jobs import (
_apply_result_state,
classify_error,
)
from opensquilla.scheduler.ops import SchedulerOps
from opensquilla.scheduler.payloads import (
AGENT_TURN_KIND,
make_agent_turn_payload,
)
from opensquilla.scheduler.persistence import JobStore
from opensquilla.scheduler.types import (
CronJob,
DeliveryConfig,
JobExecution,
JobStatus,
ScheduleKind,
SessionTarget,
)
# --- exact / jitter override ---------------------------------------------
async def test_ops_add_with_jitter_zero_yields_no_jitter(tmp_path: Path) -> None:
db = tmp_path / "cron.db"
store = JobStore(str(db))
await store.open()
try:
ops = SchedulerOps(store)
job = await ops.add(
name="exact",
schedule_kind=ScheduleKind.CRON,
schedule_value="0 9 * * *",
handler_key="agent_run",
payload=make_agent_turn_payload("x"),
session_target=SessionTarget.ISOLATED,
jitter_seconds=0,
)
assert job.jitter_seconds == 0.0
finally:
await store.close()
async def test_ops_add_with_explicit_jitter_uses_that_value(tmp_path: Path) -> None:
db = tmp_path / "cron.db"
store = JobStore(str(db))
await store.open()
try:
ops = SchedulerOps(store)
job = await ops.add(
name="stag",
schedule_kind=ScheduleKind.CRON,
schedule_value="0 9 * * *",
handler_key="agent_run",
payload=make_agent_turn_payload("x"),
session_target=SessionTarget.ISOLATED,
jitter_seconds=15.5,
)
assert job.jitter_seconds == 15.5
finally:
await store.close()
async def test_ops_add_with_none_jitter_uses_auto_compute(tmp_path: Path) -> None:
"""Legacy behaviour preserved when caller does not pass jitter_seconds."""
db = tmp_path / "cron.db"
store = JobStore(str(db))
await store.open()
try:
ops = SchedulerOps(store)
job = await ops.add(
name="auto",
schedule_kind=ScheduleKind.CRON,
schedule_value="0 9 * * *",
handler_key="agent_run",
payload=make_agent_turn_payload("x"),
session_target=SessionTarget.ISOLATED,
)
# compute_jitter caps at max_jitter (30s by default).
assert 0.0 <= job.jitter_seconds <= 30.0
finally:
await store.close()
# --- RPC exposes exact/jitter ---------------------------------------------
class _FakeScheduler:
def __init__(self) -> None:
self.kwargs: dict | None = None
async def add_job(self, **kwargs):
self.kwargs = kwargs
return CronJob(
id="job-x",
name=kwargs["name"],
cron_expr=kwargs.get("schedule_value") or kwargs.get("schedule_raw", ""),
schedule_raw=kwargs.get("schedule_value") or kwargs.get("schedule_raw", ""),
jitter_seconds=kwargs.get("jitter_seconds") or 0.0,
handler_key=kwargs["handler_key"],
payload=kwargs["payload"],
session_target=kwargs["session_target"],
session_key=kwargs["session_key"],
origin_session_key=kwargs["origin_session_key"],
delivery=kwargs.get("delivery") or DeliveryConfig(),
)
async def update_job(self, job_id, **patch):
return None
async def test_rpc_cron_add_exact_flag_zeroes_jitter() -> None:
scheduler = _FakeScheduler()
await _handle_cron_add(
{
"name": "X",
"expression": "0 9 * * *",
"payloadKind": AGENT_TURN_KIND,
"text": "x",
"sessionTarget": "isolated",
"exact": True,
},
RpcContext(conn_id="t", cron_scheduler=scheduler),
)
assert scheduler.kwargs is not None
assert scheduler.kwargs["jitter_seconds"] == 0.0
async def test_rpc_cron_add_jitter_seconds_overrides_exact() -> None:
scheduler = _FakeScheduler()
await _handle_cron_add(
{
"name": "X",
"expression": "0 9 * * *",
"payloadKind": AGENT_TURN_KIND,
"text": "x",
"sessionTarget": "isolated",
"exact": True,
"jitterSeconds": 7,
},
RpcContext(conn_id="t", cron_scheduler=scheduler),
)
assert scheduler.kwargs["jitter_seconds"] == 7.0
async def test_rpc_cron_add_without_jitter_leaves_auto() -> None:
scheduler = _FakeScheduler()
await _handle_cron_add(
{
"name": "X",
"expression": "0 9 * * *",
"payloadKind": AGENT_TURN_KIND,
"text": "x",
"sessionTarget": "isolated",
},
RpcContext(conn_id="t", cron_scheduler=scheduler),
)
# When neither exact nor jitterSeconds is provided, jitter_seconds passes
# through as None so the scheduler auto-computes.
assert scheduler.kwargs["jitter_seconds"] is None
# --- retry classification -------------------------------------------------
def test_classify_error_transient_signatures() -> None:
assert classify_error("rate limit exceeded") == "transient"
assert classify_error("429 Too Many Requests") == "transient"
assert classify_error("anthropic 529 overloaded_error") == "transient"
assert classify_error("connection timeout") == "transient"
assert classify_error("ECONNRESET") == "transient"
assert classify_error("HTTP 503 service unavailable") == "transient"
def test_classify_error_permanent_signatures() -> None:
assert classify_error("401 Unauthorized") == "permanent"
assert classify_error("Invalid API key") == "permanent"
assert classify_error("validation error: payload missing") == "permanent"
assert classify_error("403 Forbidden") == "permanent"
assert classify_error("no handler registered for key 'agent_run'") == "permanent"
def test_classify_error_defaults_to_transient() -> None:
assert classify_error("") == "transient"
assert classify_error(None) == "transient"
assert classify_error("something obscure") == "transient"
def _recurring_failure(error: str) -> CronJob:
return CronJob(
id="job-1",
name="recurring",
cron_expr="*/5 * * * *",
handler_key="agent_run",
payload={"kind": "agent_turn", "task": "x", "agent_id": "main"},
session_target=SessionTarget.ISOLATED,
schedule_kind=ScheduleKind.EVERY,
enabled=True,
status=JobStatus.PENDING,
)
def test_permanent_error_disables_recurring_job_immediately() -> None:
job = _recurring_failure("Invalid API key")
execution = JobExecution(job_id=job.id, success=False, error="Invalid API key")
_apply_result_state(job, execution, datetime.now(UTC))
assert job.status == JobStatus.DISABLED
assert job.enabled is False
assert job.next_run_at is None
def test_transient_error_keeps_recurring_job_pending_with_backoff() -> None:
job = _recurring_failure("HTTP 503 service unavailable")
execution = JobExecution(
job_id=job.id, success=False, error="HTTP 503 service unavailable"
)
_apply_result_state(job, execution, datetime.now(UTC))
assert job.status == JobStatus.PENDING
assert job.enabled is True
assert job.backoff_until is not None
def test_permanent_error_disables_one_shot_at_job() -> None:
job = CronJob(
id="one-shot",
name="report",
cron_expr="2030-01-01T00:00:00Z",
handler_key="agent_run",
payload={"kind": "agent_turn", "task": "x", "agent_id": "main"},
session_target=SessionTarget.ISOLATED,
schedule_kind=ScheduleKind.AT,
enabled=True,
status=JobStatus.PENDING,
)
execution = JobExecution(job_id=job.id, success=False, error="Invalid API key")
_apply_result_state(job, execution, datetime.now(UTC))
assert job.status == JobStatus.DISABLED
assert job.enabled is False