Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Bounded retry policy for transient venue refusals.

A venue refuses an order for many reasons. Some are transient (rate limits,
request timeouts) and worth one bounded wait-and-retry. Others are terminal
(insufficient balance, invalid precision) and must stop immediately. Retrying a
submission is only safe when the executor first proves that the earlier attempt
produced no effect, so every decision helper here takes that observation.

The module is deliberately provider-neutral: it classifies a bounded set of
public refusal signals, computes the next backoff, and refuses to retry when the
operation deadline passed, the attempt budget is spent or a venue effect is
already visible. It performs no IO and grants no execution authority.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
import re


RETRY_POLICY_SCHEMA_VERSION = "finance_transient_retry_policy_v0"

#: Fixed patterns for transient venue refusals. Anything not matched here is
#: treated as terminal: an unknown refusal is never retried.
RETRYABLE_PATTERNS = (
r"请求过于频繁",
r"rate.?limit",
r"too many requests",
r"too frequent",
r"timeout|timed out",
r"超时",
r"稍后重试",
r"try again",
)

RETRYABLE = "retryable"
TERMINAL = "terminal"


@dataclass(frozen=True)
class RetryPolicy:
"""Attempt budget, backoff schedule and wall-clock ceiling."""

max_attempts: int = 4
waits_seconds: tuple[float, ...] = (12.0, 25.0, 35.0)
max_total_seconds: float = 150.0

def __post_init__(self) -> None:
if self.max_attempts < 1:
raise ValueError("max_attempts must be positive")
if not self.waits_seconds or any(wait < 0 for wait in self.waits_seconds):
raise ValueError("waits_seconds must be non-negative")
if self.max_total_seconds <= 0:
raise ValueError("max_total_seconds must be positive")


def classify_venue_refusal(message: object) -> str:
"""Return RETRYABLE or TERMINAL for one venue refusal message."""

text = str(message or "").strip()
if not text:
return TERMINAL
for pattern in RETRYABLE_PATTERNS:
if re.search(pattern, text, re.IGNORECASE):
return RETRYABLE
return TERMINAL


def next_wait_seconds(policy: RetryPolicy, attempt: int) -> float:
"""Backoff before ``attempt`` (1-based). The last entry repeats."""

if attempt < 2:
return 0.0
index = min(attempt - 2, len(policy.waits_seconds) - 1)
return float(policy.waits_seconds[index])


def may_retry(
*,
policy: RetryPolicy,
attempt: int,
elapsed_seconds: float,
venue_effect_observed: bool,
refusal: object,
deadline: datetime | None = None,
now: datetime | None = None,
) -> tuple[bool, str]:
"""Decide whether one more submission attempt is admissible.

Returns ``(allowed, reason)``. ``reason`` is a stable public token, never a
venue payload.
"""

if venue_effect_observed:
return False, "venue_effect_observed"
if attempt >= policy.max_attempts:
return False, "attempt_budget_spent"
if elapsed_seconds + next_wait_seconds(policy, attempt + 1) > policy.max_total_seconds:
return False, "time_budget_spent"
if classify_venue_refusal(refusal) != RETRYABLE:
return False, "refusal_not_retryable"
if deadline is not None and now is not None:
clock = now.astimezone(timezone.utc)
expiry = deadline if deadline.tzinfo else deadline.replace(tzinfo=timezone.utc)
if clock >= expiry.astimezone(timezone.utc):
return False, "operation_expired"
return True, "retry_admissible"
94 changes: 94 additions & 0 deletions tests/extensions/test_finance_transient_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Synthetic coverage for the bounded venue-refusal retry policy."""

from __future__ import annotations

from datetime import datetime, timedelta, timezone
from pathlib import Path
import sys


ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "packages" / "loopx-finance-execution" / "src"))

from loopx_finance_execution.transient_retry import ( # noqa: E402
RETRYABLE,
TERMINAL,
RetryPolicy,
classify_venue_refusal,
may_retry,
next_wait_seconds,
)


NOW = datetime(2026, 10, 17, 4, 0, tzinfo=timezone.utc)
POLICY = RetryPolicy()


def _decide(**overrides):
arguments = {
"policy": POLICY,
"attempt": 1,
"elapsed_seconds": 0.0,
"venue_effect_observed": False,
"refusal": "Hyperliquid 请求过于频繁,请等待 10 秒后重试。",
}
arguments.update(overrides)
return may_retry(**arguments)


def test_rate_limit_is_retryable() -> None:
assert classify_venue_refusal("Hyperliquid 请求过于频繁,请等待 10 秒后重试。") == RETRYABLE
assert classify_venue_refusal("rate limit exceeded") == RETRYABLE
assert classify_venue_refusal("Request timeout after 30000ms") == RETRYABLE


def test_terminal_refusals_are_not_retried() -> None:
for message in ("余额不足", "invalid price precision", "", None, "order rejected"):
assert classify_venue_refusal(message) == TERMINAL
allowed, reason = _decide(refusal=message)
assert allowed is False and reason == "refusal_not_retryable"


def test_visible_effect_never_retries() -> None:
allowed, reason = _decide(venue_effect_observed=True)
assert allowed is False and reason == "venue_effect_observed"


def test_attempt_budget_is_enforced() -> None:
allowed, reason = _decide(attempt=POLICY.max_attempts)
assert allowed is False and reason == "attempt_budget_spent"


def test_time_budget_is_enforced() -> None:
allowed, reason = _decide(elapsed_seconds=POLICY.max_total_seconds - 1)
assert allowed is False and reason == "time_budget_spent"


def test_deadline_stops_retries() -> None:
allowed, reason = _decide(deadline=NOW, now=NOW)
assert allowed is False and reason == "operation_expired"
allowed, reason = _decide(deadline=NOW + timedelta(minutes=1), now=NOW)
assert allowed is True and reason == "retry_admissible"


def test_backoff_schedule() -> None:
assert next_wait_seconds(POLICY, 1) == 0.0
assert next_wait_seconds(POLICY, 2) == 12.0
assert next_wait_seconds(POLICY, 3) == 25.0
assert next_wait_seconds(POLICY, 4) == 35.0
# The last entry repeats instead of wrapping to zero.
assert next_wait_seconds(POLICY, 9) == 35.0


def test_invalid_policy_is_rejected() -> None:
for kwargs in (
{"max_attempts": 0},
{"waits_seconds": ()},
{"waits_seconds": (-1.0,)},
{"max_total_seconds": 0},
):
try:
RetryPolicy(**kwargs)
except ValueError:
continue
raise AssertionError(f"expected RetryPolicy to reject {kwargs}")
Loading