T029: concurrent preview+schema check test T030: static asyncio.sleep audit T031: LLM rate-limit backoff test + 6 edge cases T046: TaskManager concurrent tasks + cancellation tests T047: async notifications — SMTP timeout test T048: EventBus publish/subscribe + maxsize tests 34 total async tests passing.
194 lines
7.8 KiB
Python
194 lines
7.8 KiB
Python
# #region TestLLMAsyncHttp [C:3] [TYPE Module] [SEMANTICS test, translate, llm, async, httpx, rate-limit]
|
|
# @BRIEF Tests for async LLM HTTP client — rate-limit retry, asyncio.sleep backoff, structured output fallback.
|
|
# @RELATION BINDS_TO -> [LLMAsyncHttpClient]
|
|
# @RELATION BINDS_TO -> [call_openai_compatible]
|
|
# @TEST_CONTRACT: 429 retry uses asyncio.sleep -> no event loop block
|
|
# @TEST_EDGE: rate_limit_429_retries -> VERIFIED_BY: test_llm_rate_limit_uses_asyncio_sleep
|
|
# @TEST_EDGE: empty_choices_raises -> VERIFIED_BY: test_call_openai_compatible_empty_choices
|
|
# @TEST_EDGE: missing_base_url_raises -> VERIFIED_BY: test_call_openai_compatible_no_base_url
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
|
|
# #region test_llm_rate_limit_uses_asyncio_sleep [C:2] [TYPE Function]
|
|
# @BRIEF Verify 429 backoff doesn't block other requests — mock httpx with 2 failures then success.
|
|
# @TEST_EDGE: rate_limit_429_retries
|
|
@pytest.mark.asyncio
|
|
async def test_llm_rate_limit_uses_asyncio_sleep():
|
|
"""Verify call_openai_compatible retries on 429 using asyncio.sleep, not time.sleep."""
|
|
from src.plugins.translate._llm_async_http import call_openai_compatible
|
|
|
|
call_count = 0
|
|
|
|
def mock_post_side_effect(*args, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count <= 2:
|
|
resp = MagicMock(spec=httpx.Response)
|
|
resp.status_code = 429
|
|
resp.ok = False
|
|
resp.text = "Rate limited"
|
|
resp.headers = {"Retry-After": "1"}
|
|
resp.json.return_value = {}
|
|
return resp
|
|
resp = MagicMock(spec=httpx.Response)
|
|
resp.status_code = 200
|
|
resp.ok = True
|
|
resp.text = '{"choices":[{"message":{"content":"{\\"translated\\": \\"hola\\"}"},"finish_reason":"stop"}]}'
|
|
resp.json.return_value = {
|
|
"choices": [{"message": {"content": '{"translated": "hola"}'}, "finish_reason": "stop"}]
|
|
}
|
|
return resp
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(side_effect=mock_post_side_effect)
|
|
|
|
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
|
|
# Patch asyncio.sleep to verify it's called (not time.sleep)
|
|
sleep_calls = []
|
|
original_sleep = asyncio.sleep
|
|
|
|
async def tracking_sleep(seconds):
|
|
sleep_calls.append(seconds)
|
|
await original_sleep(0) # Don't actually wait
|
|
|
|
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", side_effect=tracking_sleep):
|
|
content, finish_reason = await call_openai_compatible(
|
|
base_url="http://fake-llm.local",
|
|
api_key="test-key",
|
|
model="gpt-4",
|
|
prompt="translate: hello",
|
|
)
|
|
|
|
assert call_count == 3, f"Expected 3 HTTP calls (2 retries + 1 success), got {call_count}"
|
|
assert len(sleep_calls) == 2, f"Expected 2 sleep calls for 429 retry, got {len(sleep_calls)}"
|
|
assert finish_reason == "stop"
|
|
# #endregion test_llm_rate_limit_uses_asyncio_sleep
|
|
|
|
|
|
# #region test_call_openai_compatible_no_base_url [C:2] [TYPE Function]
|
|
# @BRIEF Verify call_openai_compatible raises ValueError when base_url is empty.
|
|
# @TEST_EDGE: missing_base_url_raises
|
|
@pytest.mark.asyncio
|
|
async def test_call_openai_compatible_no_base_url():
|
|
"""Verify empty base_url raises ValueError immediately."""
|
|
from src.plugins.translate._llm_async_http import call_openai_compatible
|
|
|
|
with pytest.raises(ValueError, match="no base_url"):
|
|
await call_openai_compatible(
|
|
base_url="",
|
|
api_key="test-key",
|
|
model="gpt-4",
|
|
prompt="hello",
|
|
)
|
|
# #endregion test_call_openai_compatible_no_base_url
|
|
|
|
|
|
# #region test_call_openai_compatible_empty_choices [C:2] [TYPE Function]
|
|
# @BRIEF Verify call_openai_compatible raises ValueError when LLM returns empty choices.
|
|
# @TEST_EDGE: empty_choices_raises
|
|
@pytest.mark.asyncio
|
|
async def test_call_openai_compatible_empty_choices():
|
|
"""Verify LLM response with empty choices raises ValueError."""
|
|
from src.plugins.translate._llm_async_http import call_openai_compatible
|
|
|
|
mock_response = MagicMock(spec=httpx.Response)
|
|
mock_response.status_code = 200
|
|
mock_response.ok = True
|
|
mock_response.json.return_value = {"choices": []}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
|
|
with pytest.raises(ValueError, match="no choices"):
|
|
await call_openai_compatible(
|
|
base_url="http://fake-llm.local",
|
|
api_key="test-key",
|
|
model="gpt-4",
|
|
prompt="hello",
|
|
)
|
|
# #endregion test_call_openai_compatible_empty_choices
|
|
|
|
|
|
# #region test_call_openai_compatible_refusal [C:2] [TYPE Function]
|
|
# @BRIEF Verify call_openai_compatible raises ValueError when LLM returns a refusal.
|
|
# @TEST_EDGE: llm_refusal_raises
|
|
@pytest.mark.asyncio
|
|
async def test_call_openai_compatible_refusal():
|
|
"""Verify LLM refusal response raises ValueError."""
|
|
from src.plugins.translate._llm_async_http import call_openai_compatible
|
|
|
|
mock_response = MagicMock(spec=httpx.Response)
|
|
mock_response.status_code = 200
|
|
mock_response.ok = True
|
|
mock_response.json.return_value = {
|
|
"choices": [{"message": {"content": "", "refusal": "I cannot translate this"}, "finish_reason": "stop"}]
|
|
}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value=mock_response)
|
|
|
|
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
|
|
with pytest.raises(ValueError, match="refused"):
|
|
await call_openai_compatible(
|
|
base_url="http://fake-llm.local",
|
|
api_key="test-key",
|
|
model="gpt-4",
|
|
prompt="hello",
|
|
)
|
|
# #endregion test_call_openai_compatible_refusal
|
|
|
|
|
|
# #region test_retry_does_not_block_event_loop [C:2] [TYPE Function]
|
|
# @BRIEF Verify 429 retry sleep doesn't block concurrent tasks on the same event loop.
|
|
# @TEST_INVARIANT: retry_backoff_non_blocking -> VERIFIED_BY: test_retry_does_not_block_event_loop
|
|
@pytest.mark.asyncio
|
|
async def test_retry_does_not_block_event_loop():
|
|
"""Verify that 429 retry backoff allows other coroutines to run."""
|
|
from src.plugins.translate._llm_async_http import _do_http_request
|
|
|
|
concurrent_ran = False
|
|
|
|
async def concurrent_task():
|
|
nonlocal concurrent_ran
|
|
await asyncio.sleep(0.01)
|
|
concurrent_ran = True
|
|
|
|
call_count = 0
|
|
|
|
def mock_post(*args, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
resp = MagicMock(spec=httpx.Response)
|
|
resp.status_code = 429
|
|
resp.ok = False
|
|
resp.text = "Rate limited"
|
|
resp.headers = {"Retry-After": "1"}
|
|
return resp
|
|
resp = MagicMock(spec=httpx.Response)
|
|
resp.status_code = 200
|
|
resp.ok = True
|
|
resp.text = "ok"
|
|
return resp
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(side_effect=mock_post)
|
|
|
|
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
|
|
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
|
# Run the HTTP request alongside a concurrent task
|
|
http_task = asyncio.create_task(_do_http_request("http://fake", {}, {}))
|
|
conc_task = asyncio.create_task(concurrent_task())
|
|
await asyncio.gather(http_task, conc_task)
|
|
|
|
assert concurrent_ran, "Concurrent task was blocked by 429 retry"
|
|
assert mock_sleep.called, "asyncio.sleep was not called during 429 retry"
|
|
# #endregion test_retry_does_not_block_event_loop
|
|
|
|
# #endregion TestLLMAsyncHttp
|