2026-03-07 15:33:29 +08:00
|
|
|
|
from __future__ import annotations
|
2026-02-02 21:16:28 +08:00
|
|
|
|
|
|
|
|
|
|
from types import SimpleNamespace
|
2026-03-07 15:33:29 +08:00
|
|
|
|
from typing import Any, AsyncIterator, cast
|
2026-02-02 21:16:28 +08:00
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
2026-03-07 15:33:29 +08:00
|
|
|
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
2026-02-02 21:16:28 +08:00
|
|
|
|
from src.services.candidate.failover import FailoverEngine
|
|
|
|
|
|
from src.services.candidate.policy import RetryMode, RetryPolicy, SkipPolicy
|
2026-03-07 15:33:29 +08:00
|
|
|
|
from src.services.orchestration.error_classifier import ErrorAction, ErrorClassifier
|
|
|
|
|
|
from src.services.scheduling.schemas import PoolCandidate, ProviderCandidate
|
|
|
|
|
|
from src.services.task.core.protocol import AttemptKind, AttemptResult
|
2026-02-02 21:16:28 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_candidate(
|
|
|
|
|
|
*,
|
|
|
|
|
|
provider_id: str = "p1",
|
|
|
|
|
|
provider_name: str = "prov",
|
|
|
|
|
|
endpoint_id: str = "e1",
|
|
|
|
|
|
key_id: str = "k1",
|
|
|
|
|
|
key_name: str = "key",
|
|
|
|
|
|
auth_type: str = "api_key",
|
|
|
|
|
|
priority: int = 0,
|
|
|
|
|
|
is_cached: bool = False,
|
|
|
|
|
|
is_skipped: bool = False,
|
|
|
|
|
|
skip_reason: str | None = None,
|
|
|
|
|
|
needs_conversion: bool = False,
|
|
|
|
|
|
provider_max_retries: int | None = None,
|
2026-03-01 00:21:16 +08:00
|
|
|
|
provider_config: dict[str, Any] | None = None,
|
2026-03-07 15:33:29 +08:00
|
|
|
|
) -> ProviderCandidate:
|
|
|
|
|
|
provider = cast(
|
|
|
|
|
|
Provider,
|
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
|
id=provider_id,
|
|
|
|
|
|
name=provider_name,
|
|
|
|
|
|
max_retries=provider_max_retries,
|
|
|
|
|
|
config=provider_config,
|
|
|
|
|
|
),
|
2026-03-01 00:21:16 +08:00
|
|
|
|
)
|
2026-03-07 15:33:29 +08:00
|
|
|
|
endpoint = cast(ProviderEndpoint, SimpleNamespace(id=endpoint_id))
|
|
|
|
|
|
key = cast(
|
|
|
|
|
|
ProviderAPIKey,
|
|
|
|
|
|
SimpleNamespace(id=key_id, name=key_name, auth_type=auth_type, priority=priority),
|
|
|
|
|
|
)
|
|
|
|
|
|
return ProviderCandidate(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
provider=provider,
|
|
|
|
|
|
endpoint=endpoint,
|
|
|
|
|
|
key=key,
|
|
|
|
|
|
is_cached=is_cached,
|
|
|
|
|
|
is_skipped=is_skipped,
|
|
|
|
|
|
skip_reason=skip_reason,
|
|
|
|
|
|
needs_conversion=needs_conversion,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _StubErrorClassifier:
|
|
|
|
|
|
def __init__(self, *, action: ErrorAction, client_error: bool = False) -> None:
|
|
|
|
|
|
self._action = action
|
|
|
|
|
|
self._client_error = client_error
|
|
|
|
|
|
|
|
|
|
|
|
def is_client_error(self, _text: str | None) -> bool:
|
|
|
|
|
|
return self._client_error
|
|
|
|
|
|
|
|
|
|
|
|
def classify(
|
|
|
|
|
|
self, _error: Exception, *, has_retry_left: bool = False
|
|
|
|
|
|
) -> ErrorAction: # noqa: ARG002
|
|
|
|
|
|
return self._action
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-07 15:33:29 +08:00
|
|
|
|
def _stub_classifier(*, action: ErrorAction, client_error: bool = False) -> ErrorClassifier:
|
|
|
|
|
|
return cast(ErrorClassifier, _StubErrorClassifier(action=action, client_error=client_error))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-02 21:16:28 +08:00
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failover_engine_success_first_candidate() -> None:
|
|
|
|
|
|
db = MagicMock()
|
2026-03-07 15:33:29 +08:00
|
|
|
|
engine = FailoverEngine(db, error_classifier=_stub_classifier(action=ErrorAction.BREAK))
|
2026-02-02 21:16:28 +08:00
|
|
|
|
|
|
|
|
|
|
candidates = [_make_candidate(provider_id="p1"), _make_candidate(provider_id="p2")]
|
|
|
|
|
|
|
|
|
|
|
|
attempt = AsyncMock(
|
|
|
|
|
|
return_value=AttemptResult(
|
|
|
|
|
|
kind=AttemptKind.SYNC_RESPONSE,
|
|
|
|
|
|
http_status=200,
|
|
|
|
|
|
http_headers={},
|
|
|
|
|
|
response_body={"ok": True},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=candidates,
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED, max_retries=1),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.success is True
|
|
|
|
|
|
assert result.candidate_index == 0
|
|
|
|
|
|
assert result.attempt_count == 1
|
|
|
|
|
|
assert result.provider_id == "p1"
|
|
|
|
|
|
assert result.response == {"ok": True}
|
|
|
|
|
|
assert attempt.await_count == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failover_engine_continue_to_next_candidate_on_error() -> None:
|
|
|
|
|
|
db = MagicMock()
|
2026-03-07 15:33:29 +08:00
|
|
|
|
engine = FailoverEngine(db, error_classifier=_stub_classifier(action=ErrorAction.BREAK))
|
2026-02-02 21:16:28 +08:00
|
|
|
|
|
|
|
|
|
|
candidates = [_make_candidate(provider_id="p1"), _make_candidate(provider_id="p2")]
|
|
|
|
|
|
|
|
|
|
|
|
attempt = AsyncMock(
|
|
|
|
|
|
side_effect=[
|
|
|
|
|
|
RuntimeError("boom"),
|
|
|
|
|
|
AttemptResult(
|
|
|
|
|
|
kind=AttemptKind.SYNC_RESPONSE,
|
|
|
|
|
|
http_status=200,
|
|
|
|
|
|
http_headers={},
|
|
|
|
|
|
response_body={"ok": True},
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=candidates,
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED, max_retries=1),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.success is True
|
|
|
|
|
|
assert result.candidate_index == 1
|
|
|
|
|
|
assert result.provider_id == "p2"
|
|
|
|
|
|
assert result.attempt_count == 2
|
|
|
|
|
|
assert attempt.await_count == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failover_engine_retry_same_candidate_when_classifier_says_continue() -> None:
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
|
# ErrorAction.CONTINUE => retry current candidate (mapped to FailoverAction.RETRY)
|
2026-03-07 15:33:29 +08:00
|
|
|
|
engine = FailoverEngine(db, error_classifier=_stub_classifier(action=ErrorAction.CONTINUE))
|
2026-02-02 21:16:28 +08:00
|
|
|
|
|
|
|
|
|
|
candidates = [_make_candidate(provider_id="p1", is_cached=True, provider_max_retries=2)]
|
|
|
|
|
|
|
|
|
|
|
|
attempt = AsyncMock(
|
|
|
|
|
|
side_effect=[
|
|
|
|
|
|
RuntimeError("transient"),
|
|
|
|
|
|
AttemptResult(
|
|
|
|
|
|
kind=AttemptKind.SYNC_RESPONSE,
|
|
|
|
|
|
http_status=200,
|
|
|
|
|
|
http_headers={},
|
|
|
|
|
|
response_body={"ok": True},
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=candidates,
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.ON_DEMAND, max_retries=2),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.success is True
|
|
|
|
|
|
assert result.candidate_index == 0
|
|
|
|
|
|
assert result.attempt_count == 2
|
|
|
|
|
|
assert attempt.await_count == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-03-01 00:21:16 +08:00
|
|
|
|
async def test_failover_engine_continues_when_classifier_raises() -> None:
|
|
|
|
|
|
"""After the 'default failover' change, RAISE no longer stops failover.
|
|
|
|
|
|
All candidates should be attempted."""
|
2026-02-02 21:16:28 +08:00
|
|
|
|
db = MagicMock()
|
2026-03-07 15:33:29 +08:00
|
|
|
|
engine = FailoverEngine(db, error_classifier=_stub_classifier(action=ErrorAction.RAISE))
|
2026-02-02 21:16:28 +08:00
|
|
|
|
|
|
|
|
|
|
candidates = [_make_candidate(provider_id="p1"), _make_candidate(provider_id="p2")]
|
|
|
|
|
|
attempt = AsyncMock(side_effect=RuntimeError("client-ish"))
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=candidates,
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED, max_retries=1),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.success is False
|
2026-03-01 00:21:16 +08:00
|
|
|
|
assert result.error_type == "AllCandidatesFailed"
|
|
|
|
|
|
# both candidates should be tried
|
|
|
|
|
|
assert attempt.await_count == 2
|
2026-02-02 21:16:28 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _stream_two_chunks() -> AsyncIterator[bytes]:
|
|
|
|
|
|
yield b"chunk1"
|
|
|
|
|
|
yield b"chunk2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _empty_stream() -> AsyncIterator[bytes]:
|
|
|
|
|
|
if False: # pragma: no cover
|
|
|
|
|
|
yield b""
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failover_engine_stream_probe_wraps_first_chunk() -> None:
|
|
|
|
|
|
db = MagicMock()
|
2026-03-07 15:33:29 +08:00
|
|
|
|
engine = FailoverEngine(db, error_classifier=_stub_classifier(action=ErrorAction.BREAK))
|
2026-02-02 21:16:28 +08:00
|
|
|
|
|
|
|
|
|
|
candidates = [_make_candidate(provider_id="p1")]
|
|
|
|
|
|
attempt = AsyncMock(
|
|
|
|
|
|
return_value=AttemptResult(
|
|
|
|
|
|
kind=AttemptKind.STREAM,
|
|
|
|
|
|
http_status=200,
|
|
|
|
|
|
http_headers={},
|
|
|
|
|
|
stream_iterator=_stream_two_chunks(),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=candidates,
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED, max_retries=1),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.success is True
|
|
|
|
|
|
assert result.attempt_result is not None
|
|
|
|
|
|
assert result.attempt_result.kind == AttemptKind.STREAM
|
|
|
|
|
|
|
|
|
|
|
|
collected: list[bytes] = []
|
|
|
|
|
|
assert result.response is not None
|
|
|
|
|
|
async for chunk in result.response: # type: ignore[union-attr]
|
|
|
|
|
|
collected.append(chunk)
|
|
|
|
|
|
assert collected == [b"chunk1", b"chunk2"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failover_engine_stream_probe_empty_triggers_failover() -> None:
|
|
|
|
|
|
db = MagicMock()
|
2026-03-07 15:33:29 +08:00
|
|
|
|
engine = FailoverEngine(db, error_classifier=_stub_classifier(action=ErrorAction.BREAK))
|
2026-02-02 21:16:28 +08:00
|
|
|
|
|
|
|
|
|
|
candidates = [_make_candidate(provider_id="p1"), _make_candidate(provider_id="p2")]
|
|
|
|
|
|
|
|
|
|
|
|
attempt = AsyncMock(
|
|
|
|
|
|
side_effect=[
|
|
|
|
|
|
AttemptResult(
|
|
|
|
|
|
kind=AttemptKind.STREAM,
|
|
|
|
|
|
http_status=200,
|
|
|
|
|
|
http_headers={},
|
|
|
|
|
|
stream_iterator=_empty_stream(),
|
|
|
|
|
|
),
|
|
|
|
|
|
AttemptResult(
|
|
|
|
|
|
kind=AttemptKind.SYNC_RESPONSE,
|
|
|
|
|
|
http_status=200,
|
|
|
|
|
|
http_headers={},
|
|
|
|
|
|
response_body={"ok": True},
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=candidates,
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED, max_retries=1),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.success is True
|
|
|
|
|
|
assert result.candidate_index == 1
|
|
|
|
|
|
assert result.attempt_count == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failover_engine_pre_expand_marks_unused_slots_on_success(
|
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
db = MagicMock()
|
2026-03-07 15:33:29 +08:00
|
|
|
|
engine = FailoverEngine(db, error_classifier=_stub_classifier(action=ErrorAction.BREAK))
|
2026-02-02 21:16:28 +08:00
|
|
|
|
|
|
|
|
|
|
# patch low-level record updater to observe unused marking
|
|
|
|
|
|
engine._update_record = MagicMock() # type: ignore[method-assign]
|
|
|
|
|
|
engine.db.commit = MagicMock() # type: ignore[method-assign]
|
|
|
|
|
|
engine._commit_before_await = MagicMock() # type: ignore[method-assign]
|
|
|
|
|
|
|
|
|
|
|
|
c0 = _make_candidate(provider_id="p1", is_cached=True, provider_max_retries=2)
|
|
|
|
|
|
c1 = _make_candidate(provider_id="p2", is_cached=False)
|
|
|
|
|
|
|
|
|
|
|
|
attempt = AsyncMock(
|
|
|
|
|
|
return_value=AttemptResult(
|
|
|
|
|
|
kind=AttemptKind.SYNC_RESPONSE,
|
|
|
|
|
|
http_status=200,
|
|
|
|
|
|
http_headers={},
|
|
|
|
|
|
response_body={"ok": True},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
record_map = {
|
|
|
|
|
|
(0, 0): "r00",
|
|
|
|
|
|
(0, 1): "r01",
|
|
|
|
|
|
(1, 0): "r10",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=[c0, c1],
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.PRE_EXPAND, max_retries=2),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
candidate_record_map=record_map,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.success is True
|
|
|
|
|
|
|
|
|
|
|
|
# Ensure we marked the remaining slots unused (r01 + r10)
|
|
|
|
|
|
unused_record_ids = {
|
|
|
|
|
|
call.args[0]
|
|
|
|
|
|
for call in engine._update_record.call_args_list # type: ignore[attr-defined]
|
|
|
|
|
|
if call.kwargs.get("status") == "unused"
|
|
|
|
|
|
}
|
|
|
|
|
|
assert unused_record_ids == {"r01", "r10"}
|
2026-03-01 00:21:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ========== error_stop_patterns with status_codes ==========
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _HttpError(Exception):
|
|
|
|
|
|
"""Stub exception with status_code and response text."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, status_code: int, text: str) -> None:
|
|
|
|
|
|
super().__init__(text)
|
|
|
|
|
|
self.status_code = status_code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_error_stop_pattern_with_matching_status_code_stops_failover() -> None:
|
|
|
|
|
|
"""When status_codes is set and matches, failover should stop."""
|
|
|
|
|
|
db = MagicMock()
|
2026-03-07 15:33:29 +08:00
|
|
|
|
engine = FailoverEngine(db, error_classifier=_stub_classifier(action=ErrorAction.BREAK))
|
2026-03-01 00:21:16 +08:00
|
|
|
|
|
|
|
|
|
|
config = {
|
|
|
|
|
|
"failover_rules": {
|
|
|
|
|
|
"error_stop_patterns": [
|
|
|
|
|
|
{"pattern": "content_policy", "status_codes": [403]},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
_make_candidate(provider_id="p1", provider_config=config),
|
|
|
|
|
|
_make_candidate(provider_id="p2"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
attempt = AsyncMock(
|
|
|
|
|
|
side_effect=[
|
|
|
|
|
|
_HttpError(403, "content_policy_violation"),
|
|
|
|
|
|
AttemptResult(
|
|
|
|
|
|
kind=AttemptKind.SYNC_RESPONSE,
|
|
|
|
|
|
http_status=200,
|
|
|
|
|
|
http_headers={},
|
|
|
|
|
|
response_body={"ok": True},
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=candidates,
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED, max_retries=1),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Should stop at first candidate, not try second
|
|
|
|
|
|
assert result.success is False
|
|
|
|
|
|
assert attempt.await_count == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_error_stop_pattern_with_non_matching_status_code_continues() -> None:
|
|
|
|
|
|
"""When status_codes is set but doesn't match, the rule is skipped and failover continues."""
|
|
|
|
|
|
db = MagicMock()
|
2026-03-07 15:33:29 +08:00
|
|
|
|
engine = FailoverEngine(db, error_classifier=_stub_classifier(action=ErrorAction.BREAK))
|
2026-03-01 00:21:16 +08:00
|
|
|
|
|
|
|
|
|
|
config = {
|
|
|
|
|
|
"failover_rules": {
|
|
|
|
|
|
"error_stop_patterns": [
|
|
|
|
|
|
{"pattern": "content_policy", "status_codes": [403]},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
_make_candidate(provider_id="p1", provider_config=config),
|
|
|
|
|
|
_make_candidate(provider_id="p2"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
attempt = AsyncMock(
|
|
|
|
|
|
side_effect=[
|
|
|
|
|
|
_HttpError(500, "content_policy_violation"),
|
|
|
|
|
|
AttemptResult(
|
|
|
|
|
|
kind=AttemptKind.SYNC_RESPONSE,
|
|
|
|
|
|
http_status=200,
|
|
|
|
|
|
http_headers={},
|
|
|
|
|
|
response_body={"ok": True},
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=candidates,
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED, max_retries=1),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# status_code 500 doesn't match [403], so rule is skipped; failover continues to p2
|
|
|
|
|
|
assert result.success is True
|
|
|
|
|
|
assert result.provider_id == "p2"
|
|
|
|
|
|
assert attempt.await_count == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_error_stop_pattern_without_status_codes_matches_any() -> None:
|
|
|
|
|
|
"""When status_codes is not set, the rule matches any status code (existing behavior)."""
|
|
|
|
|
|
db = MagicMock()
|
2026-03-07 15:33:29 +08:00
|
|
|
|
engine = FailoverEngine(db, error_classifier=_stub_classifier(action=ErrorAction.BREAK))
|
2026-03-01 00:21:16 +08:00
|
|
|
|
|
|
|
|
|
|
config = {
|
|
|
|
|
|
"failover_rules": {
|
|
|
|
|
|
"error_stop_patterns": [
|
|
|
|
|
|
{"pattern": "content_policy"},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
candidates = [
|
|
|
|
|
|
_make_candidate(provider_id="p1", provider_config=config),
|
|
|
|
|
|
_make_candidate(provider_id="p2"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
attempt = AsyncMock(
|
|
|
|
|
|
side_effect=[
|
|
|
|
|
|
_HttpError(500, "content_policy_violation"),
|
|
|
|
|
|
AttemptResult(
|
|
|
|
|
|
kind=AttemptKind.SYNC_RESPONSE,
|
|
|
|
|
|
http_status=200,
|
|
|
|
|
|
http_headers={},
|
|
|
|
|
|
response_body={"ok": True},
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=candidates,
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED, max_retries=1),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# No status_codes filter, pattern matches -> stop
|
|
|
|
|
|
assert result.success is False
|
|
|
|
|
|
assert attempt.await_count == 1
|
2026-03-06 17:55:14 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failover_engine_applies_backoff_every_tenth_failure(
|
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
|
engine = FailoverEngine(db, error_classifier=_StubErrorClassifier(action=ErrorAction.BREAK))
|
|
|
|
|
|
|
|
|
|
|
|
sleep_mock = AsyncMock()
|
|
|
|
|
|
rotate_mock = AsyncMock(return_value=False)
|
|
|
|
|
|
monkeypatch.setattr("src.services.candidate.failover.asyncio.sleep", sleep_mock)
|
|
|
|
|
|
monkeypatch.setattr(engine, "_rotate_upstream_client", rotate_mock)
|
|
|
|
|
|
|
|
|
|
|
|
await engine._apply_retry_pacing(
|
|
|
|
|
|
candidate=_make_candidate(),
|
|
|
|
|
|
consecutive_failures=10,
|
|
|
|
|
|
error=RuntimeError("boom"),
|
|
|
|
|
|
request_id="req-1",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
sleep_mock.assert_awaited_once()
|
|
|
|
|
|
rotate_mock.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failover_engine_rotates_client_on_stream_capacity_error(
|
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
|
engine = FailoverEngine(db, error_classifier=_StubErrorClassifier(action=ErrorAction.BREAK))
|
|
|
|
|
|
|
|
|
|
|
|
sleep_mock = AsyncMock()
|
|
|
|
|
|
rotate_mock = AsyncMock(return_value=True)
|
|
|
|
|
|
monkeypatch.setattr("src.services.candidate.failover.asyncio.sleep", sleep_mock)
|
|
|
|
|
|
monkeypatch.setattr(engine, "_rotate_upstream_client", rotate_mock)
|
|
|
|
|
|
|
|
|
|
|
|
await engine._apply_retry_pacing(
|
|
|
|
|
|
candidate=_make_candidate(),
|
|
|
|
|
|
consecutive_failures=3,
|
|
|
|
|
|
error=RuntimeError("LocalProtocolError: Max outbound streams is 100, 100 open"),
|
|
|
|
|
|
request_id="req-2",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rotate_mock.assert_awaited_once()
|
|
|
|
|
|
sleep_mock.assert_awaited_once()
|
2026-03-07 02:16:03 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failover_engine_stops_when_cancelled_before_attempt() -> None:
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
|
engine = FailoverEngine(db, error_classifier=_StubErrorClassifier(action=ErrorAction.BREAK))
|
|
|
|
|
|
|
|
|
|
|
|
candidates = [_make_candidate(provider_id="p1"), _make_candidate(provider_id="p2")]
|
|
|
|
|
|
attempt = AsyncMock()
|
|
|
|
|
|
|
|
|
|
|
|
async def _cancelled() -> bool:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=candidates,
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED, max_retries=1),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
is_cancelled=_cancelled,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.success is False
|
|
|
|
|
|
assert result.error_type == "cancelled"
|
|
|
|
|
|
assert result.last_status_code == 499
|
|
|
|
|
|
assert result.attempt_count == 0
|
|
|
|
|
|
assert attempt.await_count == 0
|
|
|
|
|
|
assert [item.status for item in result.candidate_keys] == ["cancelled", "cancelled"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
async def test_failover_engine_stops_before_next_pool_key_when_cancelled() -> None:
|
|
|
|
|
|
db = MagicMock()
|
|
|
|
|
|
engine = FailoverEngine(db, error_classifier=_StubErrorClassifier(action=ErrorAction.BREAK))
|
|
|
|
|
|
|
|
|
|
|
|
provider = SimpleNamespace(id="p1", name="prov", max_retries=1, config={})
|
|
|
|
|
|
endpoint = SimpleNamespace(id="e1")
|
|
|
|
|
|
key1 = SimpleNamespace(id="k1", name="key-1", auth_type="api_key", priority=0)
|
|
|
|
|
|
key2 = SimpleNamespace(id="k2", name="key-2", auth_type="api_key", priority=0)
|
|
|
|
|
|
pool_candidate = PoolCandidate(
|
|
|
|
|
|
provider=provider, # type: ignore[arg-type]
|
|
|
|
|
|
endpoint=endpoint, # type: ignore[arg-type]
|
|
|
|
|
|
key=key1, # type: ignore[arg-type]
|
|
|
|
|
|
pool_keys=[key1, key2], # type: ignore[list-item]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
attempt = AsyncMock(side_effect=RuntimeError("boom"))
|
|
|
|
|
|
cancel_checks = {"count": 0}
|
|
|
|
|
|
|
|
|
|
|
|
async def _cancelled() -> bool:
|
|
|
|
|
|
cancel_checks["count"] += 1
|
|
|
|
|
|
return cancel_checks["count"] >= 4
|
|
|
|
|
|
|
|
|
|
|
|
result = await engine.execute(
|
|
|
|
|
|
candidates=[pool_candidate],
|
|
|
|
|
|
attempt_func=attempt,
|
|
|
|
|
|
retry_policy=RetryPolicy(mode=RetryMode.DISABLED, max_retries=1),
|
|
|
|
|
|
skip_policy=SkipPolicy(),
|
|
|
|
|
|
request_id=None,
|
|
|
|
|
|
is_cancelled=_cancelled,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result.success is False
|
|
|
|
|
|
assert result.error_type == "cancelled"
|
|
|
|
|
|
assert attempt.await_count == 1
|
|
|
|
|
|
assert any(item.key_id == "k2" and item.status == "cancelled" for item in result.candidate_keys)
|