Files
ZNJJ-api-server/test/services/test_field_commit_and_retry.py
T
Eric Wang 24e2cfbe10 fix(backends): make streamed replies and session state consistent
- Validate stage prefixes before forwarding model tokens. Invalid prefixes
  may use the existing repair budget; once output starts, validation failure
  aborts the turn without appending a regenerated reply. Bound the token
  queue and close inner streams when consumption stops.
- Replace delete-time lock eviction with reference-counted session locks
  covering holders and waiters. Serialize set/get with chat/stream/delete
  and release cancelled waiters without replacing a live lock.
- Store person synonyms under workflow-native driver keys and expose
  consistent alias views to single/group reads and graph prompt injection.
- Separate committed field patches from public formUpdate visibility.
  Commit callbacks keep repository fields synchronized even when updates
  are hidden or the consumer closes after graph completion.
- Disable OpenAI SDK automatic retries. Own bounded retry attempts in the
  application, use 1/2/4 second backoff, retry streams only before text is
  emitted, enforce attempt timeouts, and close upstream/client resources.
- Translate FastGPT SDK max_retries (total attempts) from the configured
  extra-retry count, and verify the real SDK loop uses four attempts.
- Document the per-LLM budget and streaming failure semantics without
  claiming the whole multi-call HTTP turn has the same timeout.

Validation: 267 offline tests passed, including actual SDK requests through
MockTransport, stream repair/abort, timeout/close, lock races, aliases and
formUpdate visibility; existing four dependency deprecation warnings.
2026-10-23 15:54:51 +08:00

159 lines
6.6 KiB
Python

import asyncio
import json
from contextlib import aclosing
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
from openai import AsyncOpenAI
from src.agent.llm import LLMAPIError, LLMRateLimitError, OpenAILLMService
from src.agent.prompts import SINGLE_CONFIRM, render_prompt
from src.backends.chat import ChatInput, FormUpdate, TextDelta
from src.backends.langgraph import LangGraphBackend
from src.services.app_service import AccidentAppService
from src.services.repository import InMemorySessionRepository
class PatchGraph:
async def ainvoke(self, *args, **kwargs):
return {'response_text': '<state>1002</state>ok', 'turn_patch': {'has_injury': False}}
@pytest.mark.asyncio
async def test_person_synonyms_share_storage_and_prompt_values():
repo = InMemorySessionRepository()
service = AccidentAppService(LangGraphBackend(graph=PatchGraph()), repo)
for index in (1, 2):
for api, native in ((f'sfzmhm{index}', f'driver{index}ID'), (f'sjhm{index}', f'driver{index}Phonenumber')):
await service.set_info('s', native, 'old')
await service.set_info('s', api, 'new')
assert json.loads(await service.get_info('s', api)) == 'new'
assert json.loads(await service.get_info('s', native)) == 'new'
assert api not in repo.load('s')
fields = service._injected_fields('s')['person_fields']
prompt = render_prompt(SINGLE_CONFIRM, {'person_fields': fields})
assert 'old' not in prompt
assert 'new' in prompt
await service.set_info('s', 'driver1ID', 'latest')
group = json.loads(await service.get_info('s', 'acdhuman1'))
assert group['driver1ID'] == group['sfzmhm1'] == 'latest'
@pytest.mark.asyncio
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('visible', [False, True])
async def test_form_visibility_does_not_control_internal_commit(stream, visible):
backend = LangGraphBackend(graph=PatchGraph())
service = AccidentAppService(backend, InMemorySessionRepository())
request = ChatInput('s', 'hello', need_form_update=visible)
if stream:
events = [e async for e in service.stream(request)]
assert any(isinstance(e, FormUpdate) for e in events) is visible
else:
result = await service.chat(request)
assert bool(result.form_update) is visible
assert json.loads(await service.get_info('s', 'ywrysw')) == '0'
@pytest.mark.asyncio
async def test_commit_is_not_lost_when_consumer_closes_after_graph_completes():
backend = LangGraphBackend(graph=PatchGraph())
repo = InMemorySessionRepository()
service = AccidentAppService(backend, repo)
async with aclosing(service.stream(ChatInput('s', 'hello'))) as events:
assert isinstance(await anext(events), TextDelta)
assert json.loads(await service.get_info('s', 'ywrysw')) == '0'
assert not service._locks._entries
@pytest.mark.asyncio
async def test_factory_disables_sdk_retries():
llm = OpenAILLMService(model='m', api_key='audit')
try:
assert llm._get_client().max_retries == 0
finally:
await llm.aclose()
def completion(stream):
if not stream:
return httpx.Response(200, json={
'id': 'r', 'object': 'chat.completion', 'created': 0, 'model': 'm',
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': 'ok'}, 'finish_reason': 'stop'}],
})
data = {'id': 'r', 'object': 'chat.completion.chunk', 'created': 0, 'model': 'm',
'choices': [{'index': 0, 'delta': {'content': 'ok'}, 'finish_reason': None}]}
return httpx.Response(200, headers={'content-type': 'text/event-stream'},
content='data: '+json.dumps(data)+'\n\ndata: [DONE]\n\n')
@pytest.mark.asyncio
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('status', [429, 503])
async def test_actual_http_attempt_count_is_not_multiplied_by_sdk(stream, status):
calls = []
def handler(request):
calls.append(json.loads(request.content))
if len(calls) <= 3:
return httpx.Response(status, json={'error': {'message': 'retry'}})
return completion(stream)
llm = OpenAILLMService(model='m', api_key='audit', max_retries=3,
extra_body={'reasoning_effort': 'none'})
llm._client = AsyncOpenAI(api_key='audit', base_url='http://audit/v1', max_retries=0,
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
llm._retry_delay = AsyncMock()
try:
result = ''.join([t async for t in llm.stream_tokens('system', 'user')]) if stream else await llm.complete('system', 'user')
assert result == 'ok'
assert len(calls) == 4
assert llm._retry_delay.await_count == 3
assert all(c['reasoning_effort'] == 'none' for c in calls)
finally:
await llm.aclose()
@pytest.mark.asyncio
async def test_stream_failure_after_first_token_closes_response_without_retry():
class Response:
def __init__(self): self.closed = False
def __aiter__(self): return self.generate()
async def generate(self):
yield SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content='partial'))])
raise OSError('broken socket')
async def close(self): self.closed = True
response = Response()
create = AsyncMock(return_value=response)
llm = OpenAILLMService(model='m', api_key='audit')
llm._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
tokens = []
with pytest.raises(LLMAPIError):
async for token in llm.stream_tokens('system', 'user'):
tokens.append(token)
assert tokens == ['partial']
assert create.await_count == 1
assert response.closed
@pytest.mark.asyncio
@pytest.mark.parametrize('stream', [False, True])
async def test_retry_budget_exhaustion_is_exact(stream):
calls = []
def handler(request):
calls.append(request)
return httpx.Response(429, json={'error': {'message': 'retry'}})
llm = OpenAILLMService(model='m', api_key='audit', max_retries=2)
llm._retry_delay = AsyncMock()
llm._client = AsyncOpenAI(api_key='audit', base_url='http://audit/v1', max_retries=0,
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
try:
with pytest.raises(LLMRateLimitError):
if stream:
async for _ in llm.stream_tokens('system', 'user'): pass
else:
await llm.complete('system', 'user')
assert len(calls) == 3
finally:
await llm.aclose()