1414"""
1515
1616import json
17+ import logging
18+ import os
19+ import time
1720from dataclasses import dataclass , field
18- from typing import Any , Iterable
21+ from typing import Any , Callable , Iterable , TypeVar
1922
2023import httpx
2124
2225from gooddata_eval .core .models import ChatResult , DatasetItem
2326
27+ _log = logging .getLogger (__name__ )
28+
2429SSE_DATA_PREFIX = "data: "
2530
31+ _RETRYABLE_STATUS_CODES : frozenset [int ] = frozenset ({429 , 502 , 503 , 504 })
32+ _METADATA_SYNC_MARKER = "METADATA_SYNC_IN_PROGRESS"
33+
34+
35+ class ChatError (RuntimeError ):
36+ """Non-retryable error reported by the chat SSE stream."""
37+
38+ def __init__ (self , message : str , * , status_code : int | None = None , detail : str | None = None ) -> None :
39+ super ().__init__ (message )
40+ self .status_code = status_code
41+ self .detail = detail
42+
43+
44+ class TransientChatError (ChatError ):
45+ """Retryable transient error: gen-ai temporarily unavailable or still syncing metadata."""
46+
47+
48+ def _int_env (name : str , default : int ) -> int :
49+ """Read an int from the environment, falling back to ``default`` when unset or blank."""
50+ raw = os .getenv (name )
51+ return int (raw ) if raw else default
52+
53+
54+ def _float_env (name : str , default : float ) -> float :
55+ """Read a float from the environment, falling back to ``default`` when unset or blank."""
56+ raw = os .getenv (name )
57+ return float (raw ) if raw else default
58+
59+
60+ # Retry budget. Defaults give a ~2 min worst-case cap per send (5/10/20/40/60s);
61+ # overridable via env so CI can retune without cutting a new gooddata-eval release.
62+ _MAX_RETRIES = _int_env ("GOODDATA_EVAL_CHAT_MAX_RETRIES" , 5 )
63+ _INITIAL_BACKOFF_S = _float_env ("GOODDATA_EVAL_CHAT_INITIAL_BACKOFF_S" , 5.0 )
64+ _BACKOFF_FACTOR = _float_env ("GOODDATA_EVAL_CHAT_BACKOFF_FACTOR" , 2.0 )
65+ _MAX_BACKOFF_S = _float_env ("GOODDATA_EVAL_CHAT_MAX_BACKOFF_S" , 60.0 )
66+
67+ T = TypeVar ("T" )
68+
69+
70+ def _is_retryable_exc (exc : Exception ) -> bool :
71+ if isinstance (exc , TransientChatError ):
72+ return True
73+ if isinstance (exc , httpx .HTTPStatusError ):
74+ return exc .response .status_code in _RETRYABLE_STATUS_CODES
75+ return False
76+
77+
78+ def _retry_transient (operation : Callable [[], T ], * , is_retryable : Callable [[Exception ], bool ]) -> T :
79+ """Run ``operation``; retry retryable failures with bounded exponential backoff."""
80+ delay = _INITIAL_BACKOFF_S
81+ for attempt in range (_MAX_RETRIES + 1 ): # 0..N => N retries + 1 initial attempt
82+ try :
83+ return operation ()
84+ except Exception as exc : # noqa: PERF203 — retry loop: per-attempt try/except is intentional
85+ if attempt == _MAX_RETRIES or not is_retryable (exc ):
86+ raise
87+ sleep_s = min (delay , _MAX_BACKOFF_S )
88+ _log .warning (
89+ "Transient gen-ai error (attempt %d/%d): %s; retrying in %.0fs" ,
90+ attempt + 1 ,
91+ _MAX_RETRIES + 1 ,
92+ exc ,
93+ sleep_s ,
94+ )
95+ time .sleep (sleep_s )
96+ delay *= _BACKOFF_FACTOR
97+ raise AssertionError ("unreachable" ) # loop either returns or raises
98+
2699
27100@dataclass
28101class _SseAccumulator :
@@ -114,12 +187,23 @@ def parse_sse_lines(lines: Iterable[str]) -> ChatResult:
114187 if not line or line .startswith ("event: " ) or not line .startswith (SSE_DATA_PREFIX ):
115188 continue
116189 data_str = line [len (SSE_DATA_PREFIX ) :]
190+ if _METADATA_SYNC_MARKER in data_str :
191+ raise TransientChatError (
192+ f"SSE transient error: { _METADATA_SYNC_MARKER } " ,
193+ status_code = None ,
194+ detail = None ,
195+ )
117196 try :
118197 event_data = json .loads (data_str )
119198 except json .JSONDecodeError :
120199 continue
121200 if "statusCode" in event_data :
122- raise RuntimeError (f"SSE error { event_data .get ('statusCode' )} : { event_data .get ('detail' )} " )
201+ code = event_data .get ("statusCode" )
202+ detail = event_data .get ("detail" )
203+ message = f"SSE error { code } : { detail } "
204+ if code in _RETRYABLE_STATUS_CODES :
205+ raise TransientChatError (message , status_code = code , detail = detail )
206+ raise ChatError (message , status_code = code , detail = detail )
123207 item = event_data .get ("item" )
124208 if not item :
125209 continue
@@ -149,12 +233,17 @@ def __init__(self, host: str, token: str, workspace_id: str, *, timeout: float =
149233 self ._client = httpx .Client (timeout = timeout )
150234
151235 def create_conversation (self ) -> str :
152- resp = self ._client .post (self ._base , headers = {** self ._auth , "Content-Type" : "application/json" })
153- resp .raise_for_status ()
154- body = resp .json ()
155- if "conversationId" not in body :
156- raise ValueError (f"GoodData /chat/conversations response missing 'conversationId': { body } " )
157- return body ["conversationId" ]
236+ def _do () -> str :
237+ resp = self ._client .post (self ._base , headers = {** self ._auth , "Content-Type" : "application/json" })
238+ resp .raise_for_status ()
239+ body = resp .json ()
240+ if "conversationId" not in body :
241+ raise ValueError (f"GoodData /chat/conversations response missing 'conversationId': { body } " )
242+ return body ["conversationId" ]
243+
244+ # NOTE: retrying create is not idempotent — a created-then-503 can leak an
245+ # orphaned (ephemeral) conversation. Acceptable for eval; do not reuse blindly.
246+ return _retry_transient (_do , is_retryable = _is_retryable_exc )
158247
159248 def delete_conversation (self , conversation_id : str ) -> None :
160249 try :
@@ -166,9 +255,13 @@ def send_message(self, conversation_id: str, question: str) -> ChatResult:
166255 url = f"{ self ._base } /{ conversation_id } /messages"
167256 headers = {** self ._auth , "Accept" : "text/event-stream" , "Content-Type" : "application/json" }
168257 body = {"item" : {"role" : "user" , "content" : {"type" : "text" , "text" : question }}}
169- with self ._client .stream ("POST" , url , json = body , headers = headers ) as resp :
170- resp .raise_for_status ()
171- return parse_sse_lines (resp .iter_lines ())
258+
259+ def _do () -> ChatResult :
260+ with self ._client .stream ("POST" , url , json = body , headers = headers ) as resp :
261+ resp .raise_for_status ()
262+ return parse_sse_lines (resp .iter_lines ())
263+
264+ return _retry_transient (_do , is_retryable = _is_retryable_exc )
172265
173266 def ask (self , item : DatasetItem ) -> ChatResult :
174267 """Run one single-turn conversation: create, send, parse, clean up."""
0 commit comments