Skip to content

Commit 5117a2a

Browse files
committed
feat(api): enhance timeout handling with swallow_exceptions option and update related logic
1 parent df9b896 commit 5117a2a

8 files changed

Lines changed: 144 additions & 62 deletions

File tree

api/routers/messages.py

Lines changed: 50 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -647,7 +647,7 @@ async def send_message(request: Request, auth_data: dict = Depends(verify_token)
647647
history_messages = await _parse_snapshot_messages(snapshot_raw_messages, req.conversation_id)
648648
if not history_messages:
649649
db_start = time.perf_counter()
650-
db_meta, db_messages = await with_timeout(
650+
db_result = await with_timeout(
651651
_load_conversation_from_db(
652652
req.conversation_id,
653653
user_id,
@@ -656,7 +656,12 @@ async def send_message(request: Request, auth_data: dict = Depends(verify_token)
656656
timeout_seconds=CONTEXT_LOAD_TIMEOUTS["db_context"],
657657
default=({}, []),
658658
context_label="db_context_load",
659+
swallow_exceptions=True,
659660
)
661+
if db_result is None:
662+
db_meta, db_messages = {}, []
663+
else:
664+
db_meta, db_messages = db_result
660665
db_ms = (time.perf_counter() - db_start) * 1000
661666
logger.info(
662667
"timing_db_load",
@@ -665,12 +670,8 @@ async def send_message(request: Request, auth_data: dict = Depends(verify_token)
665670
db_ms=round(db_ms, 2),
666671
db_messages_count=len(db_messages),
667672
)
668-
if not db_messages:
669-
logger.warning(
670-
"db_context_timeout_fallback_to_empty",
671-
conversation_id=req.conversation_id,
672-
request_id=request_id,
673-
)
673+
# Note: with_timeout already logs timeout scenarios internally.
674+
# Empty db_messages for new conversations is expected behavior.
674675
if db_meta:
675676
snapshot_meta = db_meta
676677
if db_messages:
@@ -691,6 +692,7 @@ async def send_message(request: Request, auth_data: dict = Depends(verify_token)
691692
timeout_seconds=CONTEXT_LOAD_TIMEOUTS["intent_classify"],
692693
default=ConversationIntent(type="new_query", reason="intent_timeout_default"),
693694
context_label="intent_classification",
695+
swallow_exceptions=True,
694696
)
695697
if intent is None:
696698
intent = ConversationIntent(type="new_query", reason="intent_none_default")
@@ -702,6 +704,7 @@ async def send_message(request: Request, auth_data: dict = Depends(verify_token)
702704
context_messages: list[ConversationMessage] = []
703705
context_signature = ""
704706
prompt_build_ms = 0.0
707+
context_materialized = False
705708
socratic_context = build_socratic_context(history_messages)
706709

707710
async def load_context_for_stream() -> tuple[list[ConversationMessage], str, float]:
@@ -724,6 +727,41 @@ async def load_context_for_stream() -> tuple[list[ConversationMessage], str, flo
724727
return loaded_messages, loaded_signature, local_prompt_build_ms
725728

726729
context_messages_task = asyncio.create_task(load_context_for_stream())
730+
731+
async def ensure_context_materialized(
732+
*, timeout_seconds: float, source: str
733+
) -> None:
734+
nonlocal context_messages, context_signature, prompt_build_ms, context_materialized
735+
if context_materialized:
736+
return
737+
try:
738+
loaded_messages, loaded_signature, loaded_prompt_build_ms = await asyncio.wait_for(
739+
asyncio.shield(context_messages_task),
740+
timeout=timeout_seconds,
741+
)
742+
context_messages = loaded_messages
743+
context_signature = loaded_signature
744+
prompt_build_ms = loaded_prompt_build_ms
745+
except (asyncio.TimeoutError, asyncio.CancelledError):
746+
logger.warning(
747+
"context_load_timeout",
748+
request_id=request_id,
749+
timeout_seconds=timeout_seconds,
750+
source=source,
751+
)
752+
context_messages = []
753+
context_signature = ""
754+
except Exception as exc:
755+
logger.warning(
756+
"context_load_error",
757+
request_id=request_id,
758+
source=source,
759+
error=str(exc),
760+
)
761+
context_messages = []
762+
context_signature = ""
763+
finally:
764+
context_materialized = True
727765

728766
last_three = history_messages[-3:]
729767
logger.info(
@@ -812,6 +850,7 @@ async def load_context_for_stream() -> tuple[list[ConversationMessage], str, flo
812850
system_prompt_bundle = "\n".join(
813851
[part for part in (system_prompt, mode_prompt, intent_prompt) if part]
814852
)
853+
await ensure_context_materialized(timeout_seconds=1.0, source="pre_cache")
815854
cache_key = _message_cache_key(
816855
content=effective_content,
817856
mode=selected_mode,
@@ -994,38 +1033,9 @@ async def event_generator():
9941033
user_sequence_id: int | None = None
9951034
assistant_sequence_id: int | None = None
9961035
redis_append_failed = False
997-
context_load_timeout_seconds = 1.0
998-
context_loaded_for_stream = False
9991036

10001037
async def ensure_context_for_stream() -> None:
1001-
nonlocal context_messages, context_signature, prompt_build_ms, context_loaded_for_stream
1002-
if context_loaded_for_stream:
1003-
return
1004-
context_loaded_for_stream = True
1005-
try:
1006-
loaded_messages, loaded_signature, loaded_prompt_build_ms = await asyncio.wait_for(
1007-
context_messages_task,
1008-
timeout=context_load_timeout_seconds,
1009-
)
1010-
context_messages = loaded_messages
1011-
context_signature = loaded_signature
1012-
prompt_build_ms = loaded_prompt_build_ms
1013-
except (asyncio.TimeoutError, asyncio.CancelledError):
1014-
logger.warning(
1015-
"context_load_timeout_in_stream",
1016-
request_id=request_id,
1017-
timeout_seconds=context_load_timeout_seconds,
1018-
)
1019-
context_messages = []
1020-
context_signature = ""
1021-
except Exception as exc:
1022-
logger.warning(
1023-
"context_load_error_in_stream",
1024-
request_id=request_id,
1025-
error=str(exc),
1026-
)
1027-
context_messages = []
1028-
context_signature = ""
1038+
await ensure_context_materialized(timeout_seconds=1.0, source="stream")
10291039

10301040
asyncio.create_task(
10311041
_capture_telemetry_async(
@@ -1712,13 +1722,7 @@ async def get_next_chunk():
17121722
elif status == "aborted":
17131723
error_type = "aborted"
17141724
error_message = "User aborted stream"
1715-
safe_user_id = safeNumber(user_id, default=None)
1716-
if safe_user_id is None:
1717-
logger.warning(
1718-
"messages_user_id_not_numeric",
1719-
request_id=request_id,
1720-
user_id_hash=user_id_hash,
1721-
)
1725+
safe_user_id = user_id or None
17221726
payload = build_llm_request_payload(
17231727
request_id=request_id,
17241728
user_id=safe_user_id,
@@ -1755,12 +1759,13 @@ async def get_next_chunk():
17551759
breakdown={
17561760
"snapshot_ms": round(snapshot_ms, 2),
17571761
"db_ms": round(db_ms, 2),
1758-
"context_build_ms": round(prompt_build_ms, 2),
17591762
},
17601763
)
17611764
response_started = True
17621765
return response
17631766
finally:
1767+
if not response_started:
1768+
await _ingress_dedupe_clear(client_message_id)
17641769
if not response_started and not lock_released:
17651770
_release_conversation_lock(req.conversation_id)
17661771
lock_released = True

api/routers/query.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ async def query_topic_stream(
400400
now_ts = int(time.time())
401401
idem_check = await check_idempotency_and_cache(
402402
idempotency_key=idempotency_key,
403-
cache_key=cache_key,
403+
cache_key=cache_key(topic, level, mode),
404404
now_ts=now_ts,
405405
idempotency_ttl=idempotency_ttl_seconds,
406406
idempotency_stale=idempotency_stale_seconds,

api/services/cache.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ async def _execute(self, *command: Any) -> Any:
7474
timeout_seconds=REDIS_REST_CALL_TIMEOUT_SECONDS,
7575
default=None,
7676
context_label=f"redis_call_{str(command[0]).lower() if command else 'unknown'}",
77+
swallow_exceptions=True,
7778
)
7879
if response is None:
7980
raise RuntimeError("Redis REST call timed out or failed")
@@ -96,6 +97,7 @@ async def _execute_pipeline(self, commands: list[list[Any]]) -> list[Any]:
9697
timeout_seconds=REDIS_REST_CALL_TIMEOUT_SECONDS,
9798
default=None,
9899
context_label="redis_call_pipeline",
100+
swallow_exceptions=True,
99101
)
100102
if response is None:
101103
raise RuntimeError("Redis REST pipeline call timed out or failed")

api/services/inference.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Native multi-provider inference service."""
22

33
import asyncio
4+
import inspect
45
import json
56
import re
67
import time
7-
from typing import cast
8+
from typing import Any, cast
89
import httpx
910
import structlog
1011
from openai import APIConnectionError, APIStatusError, APITimeoutError
@@ -66,15 +67,31 @@
6667

6768

6869
class _SearchServiceShim:
69-
async def get_search_context(self, topic: str):
70-
return await _load_search_context(topic, mode=LEARNING_MODE)
70+
async def get_search_context(self, topic: str, mode: str):
71+
return await _load_search_context(topic, mode=mode)
7172

7273
async def load_search_context(self, topic: str, *, mode: str):
7374
default_impl = getattr(self.get_search_context, "__func__", None) is _SearchServiceShim.get_search_context
7475
if default_impl:
7576
return await _load_search_context(topic, mode=mode)
77+
78+
get_context = cast(Any, self.get_search_context)
79+
supports_mode = False
7680
try:
77-
context = await self.get_search_context(topic)
81+
signature = inspect.signature(get_context)
82+
supports_mode = any(
83+
param.kind is inspect.Parameter.VAR_KEYWORD or param.name == "mode"
84+
for param in signature.parameters.values()
85+
)
86+
except (TypeError, ValueError):
87+
# If signature inspection fails (e.g. dynamic callables), prefer the new contract.
88+
supports_mode = True
89+
90+
try:
91+
if supports_mode:
92+
context = await get_context(topic, mode=mode)
93+
else:
94+
context = await get_context(topic)
7895
except Exception:
7996
return ""
8097
return _truncate_search_context(str(context or ""))

api/services/message_streaming.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ async def event_generator():
120120
telemetry_sink: dict[str, Any] = {}
121121
stream_failed = False
122122
last_progress_update = start_time
123+
actual_context_messages = context_messages
124+
actual_socratic_context = socratic_context
123125

124126
async def update_progress() -> None:
125127
nonlocal last_progress_update
@@ -207,8 +209,6 @@ def emit(event: str, payload: dict[str, Any] | str) -> str:
207209
return
208210

209211
generation_start = time.perf_counter()
210-
actual_context_messages = context_messages
211-
actual_socratic_context = socratic_context
212212
if context_messages_task and not actual_context_messages:
213213
try:
214214
actual_context_messages = await asyncio.wait_for(
@@ -574,16 +574,18 @@ def emit(event: str, payload: dict[str, Any] | str) -> str:
574574
sampled=True,
575575
)
576576
if assistant_message_id:
577+
current_assistant_message_id = assistant_message_id
578+
577579
def _update_db():
578580
try:
579-
ChatRepository.update_assistant_message(assistant_message_id, full_content)
581+
ChatRepository.update_assistant_message(current_assistant_message_id, full_content)
580582
except Exception as exc:
581583
logger.error(
582584
"messages_assistant_update_failed",
583585
error=str(exc),
584586
request_id=request_id,
585587
user_id_hash=user_id_hash,
586-
message_id=assistant_message_id,
588+
message_id=current_assistant_message_id,
587589
retry=bool(req.regenerate),
588590
sampled=False,
589591
)

api/tests/test_inference.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,25 @@ async def fake_call_model(_model, _prompt, **_kwargs):
115115
assert calls["count"] == 1
116116

117117

118+
@pytest.mark.asyncio
119+
async def test_search_service_load_search_context_forwards_mode_to_override(monkeypatch):
120+
captured: dict[str, str] = {}
121+
122+
async def fake_search_context(_topic: str, *, mode: str):
123+
captured["mode"] = mode
124+
return f"context for {mode}"
125+
126+
monkeypatch.setattr(inference_module.search_service, "get_search_context", fake_search_context)
127+
128+
result = await inference_module.search_service.load_search_context(
129+
"dns",
130+
mode=inference_module.SOCRATIC_MODE,
131+
)
132+
133+
assert result == "context for socratic"
134+
assert captured["mode"] == inference_module.SOCRATIC_MODE
135+
136+
118137
@pytest.mark.asyncio
119138
async def test_learning_length_policy_default_adds_cue_when_trimmed(monkeypatch):
120139
async def fake_search_context(_topic: str):

api/tests/test_utils.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from utils import sanitize_topic, topic_cache_key
3+
from utils import sanitize_topic, topic_cache_key, with_timeout
44

55

66
def test_sanitize_topic_valid():
@@ -25,3 +25,40 @@ def test_sanitize_topic_missing():
2525
def test_topic_cache_key_format():
2626
key = topic_cache_key("Hello World!", "eli5")
2727
assert key == "knowbear:hello_world:eli5"
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_with_timeout_reraises_unexpected_exception_by_default():
32+
async def boom():
33+
raise ValueError("unexpected")
34+
35+
with pytest.raises(ValueError, match="unexpected"):
36+
await with_timeout(
37+
boom(),
38+
timeout_seconds=0.05,
39+
default="fallback",
40+
context_label="unit_test_context",
41+
)
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_with_timeout_swallow_exceptions_returns_default_and_logs(caplog):
46+
async def boom():
47+
raise RuntimeError("network-ish failure")
48+
49+
with caplog.at_level("ERROR"):
50+
result = await with_timeout(
51+
boom(),
52+
timeout_seconds=0.05,
53+
default="fallback",
54+
context_label="unit_test_context",
55+
swallow_exceptions=True,
56+
)
57+
58+
assert result == "fallback"
59+
exception_logs = [
60+
record for record in caplog.records if "timeout_wrapper_exception" in record.getMessage()
61+
]
62+
assert exception_logs
63+
assert any("unit_test_context" in record.getMessage() for record in exception_logs)
64+
assert any(record.exc_info for record in exception_logs)

api/utils.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ async def with_timeout(
170170
error_on_timeout: bool = False,
171171
default: T | None = None,
172172
context_label: str = "operation",
173+
swallow_exceptions: bool = False,
173174
) -> T | None:
174175
"""Run awaitable with a timeout and graceful fallback behavior."""
175176
try:
@@ -184,10 +185,9 @@ async def with_timeout(
184185
return default
185186
except asyncio.CancelledError:
186187
_logger.debug("timeout_wrapper_cancelled", extra={"context": context_label})
187-
return default
188-
except Exception as exc:
189-
_logger.warning(
190-
"timeout_wrapper_exception",
191-
extra={"context": context_label, "error": str(exc)},
192-
)
193-
return default
188+
raise
189+
except Exception:
190+
_logger.exception("timeout_wrapper_exception context=%s", context_label)
191+
if swallow_exceptions:
192+
return default
193+
raise

0 commit comments

Comments
 (0)