Coordinate AI retries and recover shared provider outages
Development CI / check-and-build (push) Successful in 3m3s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / deploy-development (push) Successful in 2m29s

Retry transient requests after one and three minutes and coordinate provider recovery across processes with bounded probe intervals. Preserve completed results when recovery bookkeeping fails, keep concurrent failure accounting accurate, and expose retry state in the UI.
This commit is contained in:
eezd committed 2026-11-06 16:25:16 +08:00
1 parent 3fcd4c7fb8
commit 8766e829f9
27 files changed
+670 -108

No files matched your search

+9
View File
@@ -11,4 +11,13 @@ def trading(monkeypatch):
}.items():
monkeypatch.setenv(key, value)
from alphapulse.trading import executor
from unittest.mock import AsyncMock
from alphapulse.ai import llm, recovery
class HealthyGate:
async def call(self, operation, request_id='', epoch=-1):
return {'epoch': 0, 'level': 0, 'retry_at': 0, 'probe': False, 'admitted': True, 'now': 0}
monkeypatch.setattr(llm, 'recovery_gate', lambda _: HealthyGate())
monkeypatch.setattr(recovery, 'mark_waiting', AsyncMock())
monkeypatch.setattr(recovery, 'clear_waiting', AsyncMock())
monkeypatch.setattr(recovery, 'waiting_count', AsyncMock(return_value=0))
return executor
+160
View File
@@ -0,0 +1,160 @@
"""隔离 Redis 验证实际 Lua 原子操作;不访问生产接入。"""
import asyncio
from types import SimpleNamespace as N
from unittest.mock import AsyncMock
import httpx
import openai
import pytest
def test_global_failure_takes_over_local_retry_and_recovers(trading, monkeypatch):
from alphapulse.ai import llm
states = iter([
dict(level=0, epoch=0, admitted=True, probe=False, now=0, retry_at=0),
dict(level=1, epoch=1, admitted=False, probe=False, now=0, retry_at=180),
dict(level=1, epoch=1, admitted=False, probe=False, now=0, retry_at=180),
dict(level=1, epoch=1, admitted=True, probe=True, now=180, retry_at=180),
])
async def coordinate(operation, *args):
if operation in ('enter', 'failure'):
return next(states)
return dict(level=0)
gate = N(call=AsyncMock(side_effect=coordinate))
monkeypatch.setattr(llm, 'recovery_gate', lambda _: gate)
monkeypatch.setattr(llm.asyncio, 'sleep', AsyncMock())
local = AsyncMock()
monkeypatch.setattr(llm, '_wait_local', local)
calls = []
async def adapter(*args):
calls.append(args)
if len(calls) == 1:
raise openai.RateLimitError('limited', response=httpx.Response(429, request=httpx.Request('POST', 'https://test.invalid')), body={})
yield llm.Completion(text='done')
monkeypatch.setattr(llm, '_stream_openai', adapter)
async def run():
return [x async for x in llm.stream(N(protocol='openai'), '', [], [])]
result = asyncio.run(run())
assert result[-1].text == 'done' and len(calls) == 2
assert any(isinstance(x, llm.RetryStatus) and x.scope == 'upstream' for x in result)
local.assert_not_awaited()
def test_coordinator_failure_does_not_send(trading, monkeypatch):
from alphapulse.ai import llm
adapter = AsyncMock()
monkeypatch.setattr(llm, '_stream_openai', adapter)
monkeypatch.setattr(llm, 'recovery_gate', lambda _: N(call=AsyncMock(side_effect=RuntimeError('offline'))))
async def run():
with pytest.raises(llm.LlmError, match='协调不可用'):
async for _ in llm.stream(N(protocol='openai'), '', [], []):
pass
asyncio.run(run())
adapter.assert_not_called()
def test_fail_fast_does_not_send_or_wait(trading, monkeypatch):
from alphapulse.ai import llm
gate = N(call=AsyncMock(return_value=dict(level=2, epoch=1, admitted=False, probe=False, now=100, retry_at=700)))
monkeypatch.setattr(llm, 'recovery_gate', lambda _: gate)
adapter = AsyncMock()
monkeypatch.setattr(llm, '_stream_openai', adapter)
async def run():
token = llm.fail_fast.set(True)
try:
with pytest.raises(llm.LlmError, match='下次尝试'):
async for _ in llm.stream(N(protocol='openai'), '', [], []):
pass
finally:
llm.fail_fast.reset(token)
asyncio.run(run())
adapter.assert_not_called()
def test_completed_probe_is_confirmed_before_caller_returns(trading, monkeypatch):
from alphapulse.ai import llm
gate = N(call=AsyncMock(return_value=dict(level=0, epoch=1, admitted=True, probe=True, now=1, retry_at=0)))
monkeypatch.setattr(llm, 'recovery_gate', lambda _: gate)
async def adapter(*args):
yield llm.Completion(text='done')
monkeypatch.setattr(llm, '_stream_openai', adapter)
async def run():
stream = llm.stream(N(protocol='openai'), '', [], [])
assert (await anext(stream)).text == 'done'
assert any(call.args[0] == 'success' for call in gate.call.await_args_list)
await stream.aclose()
asyncio.run(run())
def test_retry_refreshes_only_current_request(trading, monkeypatch):
from alphapulse.ai import llm
calls = []
async def adapter(provider, system, messages, tools, effort):
calls.append(list(messages))
if len(calls) == 1:
raise httpx.ConnectError('unavailable')
yield llm.Completion(text='ok')
messages = [{'role': 'tool', 'tool_call_id': 'already-done', 'result': 'retained'}]
async def refresh():
messages.append({'role': 'user', 'text': 'fresh snapshot'})
monkeypatch.setattr(llm, '_stream_openai', adapter)
monkeypatch.setattr(llm, '_wait_local', AsyncMock())
async def run():
return [x async for x in llm.stream(N(protocol='openai'), '', messages, [], before_retry=refresh)]
assert asyncio.run(run())[-1].text == 'ok'
assert len(calls) == 2 and calls[1][0] == calls[0][0]
assert calls[1][-1]['text'] == 'fresh snapshot'
@pytest.mark.parametrize('failed_operation', ['success', 'release', 'both'])
def test_completed_result_survives_coordination_write_failure(trading, monkeypatch, failed_operation):
from alphapulse.ai import llm
from alphapulse.news_research.model import complete
from alphapulse.news_research.contracts import IndexResult
calls = []
async def coordinate(operation, *args):
calls.append(operation)
if operation == failed_operation or failed_operation == 'both' and operation in ('success', 'release'):
raise RuntimeError('synthetic Redis outage')
return dict(level=0, epoch=0, admitted=True, probe=False, now=0, retry_at=0)
async def adapter(*args):
yield llm.Completion(tool_calls=[{'name': 'submit', 'id': '1', 'arguments': {
'keywords': [{'canonical': '新闻', 'aliases': ['news'], 'quote': 'news'}],
}}], usage={'input_tokens': 100, 'output_tokens': 10})
monkeypatch.setattr(llm, 'recovery_gate', lambda _: N(call=coordinate))
monkeypatch.setattr(llm, '_stream_openai', adapter)
async def run():
return await complete(N(protocol='openai', max_tokens=100, context_window=250000,
reasoning_effort=None), '', {}, IndexResult)
output, usage = asyncio.run(run())
assert output.keywords[0].canonical == '新闻'
assert usage == {'input_tokens': 100, 'output_tokens': 10}
assert calls.count('enter') == 1 and 'release' in calls
def test_cancellation_survives_lease_release_failure(trading, monkeypatch):
from alphapulse.ai import llm
started = asyncio.Event()
async def coordinate(operation, *args):
if operation == 'release':
raise RuntimeError('synthetic Redis outage')
return dict(level=0, epoch=0, admitted=True, probe=False, now=0, retry_at=0)
async def adapter(*args):
started.set()
await asyncio.Event().wait()
yield
monkeypatch.setattr(llm, 'recovery_gate', lambda _: N(call=coordinate))
monkeypatch.setattr(llm, '_stream_openai', adapter)
async def run():
async def consume():
return [x async for x in llm.stream(N(protocol='openai'), '', [], [])]
task = asyncio.create_task(consume())
await started.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
asyncio.run(run())
+4 -2
View File
@@ -58,7 +58,7 @@ def test_model_requires_protocol_end_marker(trading, monkeypatch, protocol, comp
asyncio.run(check())
def test_connection_failure_uses_only_sdk_retry_budget(trading, monkeypatch):
def test_connection_failure_has_one_retry_owner(trading, monkeypatch):
from alphapulse.ai import llm
attempts = 0
def handle(request):
@@ -66,12 +66,14 @@ def test_connection_failure_uses_only_sdk_retry_budget(trading, monkeypatch):
attempts += 1
raise httpx.ConnectError("测试连接失败", request=request)
provider = install_model_transport(llm, monkeypatch, "openai", handle)
from unittest.mock import AsyncMock
monkeypatch.setattr(llm, "_wait_local", AsyncMock())
async def check():
with pytest.raises(llm.LlmError, match="无法连接"):
async for _ in llm.stream(provider, "system", [], []):
pass
asyncio.run(check())
assert attempts == llm.MAX_RETRIES + 1
assert attempts == 3
def test_pipeline_error_keeps_history_checkpoint_and_marks_run_failed(trading, monkeypatch):
+10 -19
View File
@@ -17,7 +17,7 @@ def test_news_transport_retry_budget_and_output_guard(trading, monkeypatch, mode
calls.append(args)
if mode == 'partial': yield llm.TextDelta('started')
if mode == 'tool_fragment': llm._news_output()
if mode == 'success' and len(calls) == 3:
if mode in ('success', 'long_wait') and len(calls) == 3:
yield llm.Completion(usage={'input_tokens': 1})
return
request = httpx.Request('POST', 'https://example.invalid/v1/chat/completions')
@@ -27,31 +27,22 @@ def test_news_transport_retry_budget_and_output_guard(trading, monkeypatch, mode
raise openai.APIError('Concurrency limit exceeded for account', request, body={'error': {'message': 'Concurrency limit exceeded for account'}})
monkeypatch.setattr(llm, '_stream_openai', adapter)
sleep = AsyncMock()
monkeypatch.setattr(llm.asyncio, 'sleep', sleep)
monkeypatch.setattr(llm, '_wait_local', sleep)
record = {}
async def run():
token = current.set(record)
try: return [item async for item in model_stream(N(protocol='openai'), '', [], [], 'max')]
finally: current.reset(token)
if mode == 'success': assert asyncio.run(run())[-1].usage == {'input_tokens': 1}
if mode in ('success', 'long_wait'): assert asyncio.run(run())[-1].usage == {'input_tokens': 1}
else:
with pytest.raises(llm.LlmError): asyncio.run(run())
retries = mode in ('success', 'exhausted')
retries = mode in ('success', 'exhausted', 'long_wait')
assert len(calls) == (3 if retries else 1)
assert [call.args[0] for call in sleep.await_args_list] == ([30, 60] if retries else [])
assert [call.args[0] for call in sleep.await_args_list] == ([60, 180] if retries else [])
assert llm.news_retry.get() is None
if retries: assert '2/2' in record['phase']
if retries: assert record['retry_attempt'] == 2
def test_retry_after_seconds_and_dates(trading):
from alphapulse.ai import llm
from datetime import UTC, datetime, timedelta
from email.utils import format_datetime
request = httpx.Request('POST', 'https://example.invalid')
for value in ('90', format_datetime(datetime.now(UTC) + timedelta(seconds=95))):
error = openai.RateLimitError('wait', response=httpx.Response(429, headers={'retry-after': value}, request=request), body={})
assert 89 <= llm._news_retry_delay(error, 0) <= 96
def test_cancelling_backoff_releases_context_and_progress(trading, monkeypatch):
from alphapulse.ai import llm
@@ -61,11 +52,11 @@ def test_cancelling_backoff_releases_context_and_progress(trading, monkeypatch):
async def adapter(*args):
raise openai.APIError('Concurrency limit exceeded', httpx.Request('POST', 'https://example.invalid'), body={})
yield
async def sleep(delay):
async def sleep(delay, coordinate):
entered.set()
await asyncio.Event().wait()
monkeypatch.setattr(llm, '_stream_openai', adapter)
monkeypatch.setattr(llm.asyncio, 'sleep', sleep)
monkeypatch.setattr(llm, '_wait_local', sleep)
async def run():
async def consume():
try:
@@ -82,7 +73,7 @@ def test_cancelling_backoff_releases_context_and_progress(trading, monkeypatch):
@pytest.mark.parametrize('protocol', ['openai', 'anthropic'])
@pytest.mark.parametrize('news', [True, False])
def test_sdk_retry_is_disabled_only_for_news(trading, monkeypatch, protocol, news):
def test_sdk_retry_is_disabled_for_every_request(trading, monkeypatch, protocol, news):
from alphapulse.ai import llm
options=[]
class Client:
@@ -107,4 +98,4 @@ def test_sdk_retry_is_disabled_only_for_news(trading, monkeypatch, protocol, new
try: return [item async for item in llm.stream(provider,'rules',[],[],None)]
finally: llm.news_retry.reset(token)
assert asyncio.run(run())[-1] == llm.Completion()
assert options[0]['max_retries'] == (0 if news else llm.MAX_RETRIES)
assert options[0]['max_retries'] == 0