tamar-model-client 0.1.26__py3-none-any.whl → 0.1.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ connections fail, supporting both synchronous and asynchronous clients.
7
7
 
8
8
  import json
9
9
  import logging
10
- from typing import Optional, Iterator, AsyncIterator, Dict, Any
10
+ from typing import Optional, Iterator, AsyncIterator, Dict, Any, List
11
11
 
12
12
  from . import generate_request_id, get_protected_logger
13
13
  from ..schemas import ModelRequest, ModelResponse
@@ -16,7 +16,16 @@ logger = get_protected_logger(__name__)
16
16
 
17
17
 
18
18
  class HttpFallbackMixin:
19
- """HTTP fallback functionality for synchronous clients"""
19
+ """HTTP fallback functionality for synchronous clients
20
+
21
+ This mixin requires the following attributes from the host class:
22
+ - http_fallback_url: str - The HTTP fallback URL (from BaseClient._init_resilient_features)
23
+ - jwt_token: Optional[str] - JWT token for authentication (from BaseClient)
24
+
25
+ Usage:
26
+ This mixin should be used with BaseClient or its subclasses that have
27
+ initialized the resilient features with _init_resilient_features().
28
+ """
20
29
 
21
30
  def _ensure_http_client(self) -> None:
22
31
  """Ensure HTTP client is initialized"""
@@ -29,7 +38,6 @@ class HttpFallbackMixin:
29
38
 
30
39
  # Set default headers
31
40
  self._http_client.headers.update({
32
- 'Content-Type': 'application/json',
33
41
  'User-Agent': 'TamarModelClient/1.0'
34
42
  })
35
43
 
@@ -92,8 +100,13 @@ class HttpFallbackMixin:
92
100
 
93
101
  def _invoke_http_fallback(self, model_request: ModelRequest,
94
102
  timeout: Optional[float] = None,
95
- request_id: Optional[str] = None) -> Any:
103
+ request_id: Optional[str] = None,
104
+ origin_request_id: Optional[str] = None) -> Any:
96
105
  """HTTP fallback implementation"""
106
+ # Check if http_fallback_url is available
107
+ if not hasattr(self, 'http_fallback_url') or not self.http_fallback_url:
108
+ raise RuntimeError("HTTP fallback URL not configured. Please set MODEL_CLIENT_HTTP_FALLBACK_URL environment variable.")
109
+
97
110
  self._ensure_http_client()
98
111
 
99
112
  # Generate request ID if not provided
@@ -104,10 +117,14 @@ class HttpFallbackMixin:
104
117
  logger.warning(
105
118
  f"🔻 Using HTTP fallback for request",
106
119
  extra={
120
+ "log_type": "info",
107
121
  "request_id": request_id,
108
- "provider": model_request.provider.value,
109
- "model": model_request.model,
110
- "fallback_url": self.http_fallback_url
122
+ "data": {
123
+ "origin_request_id": origin_request_id,
124
+ "provider": model_request.provider.value,
125
+ "model": model_request.model,
126
+ "fallback_url": self.http_fallback_url
127
+ }
111
128
  }
112
129
  )
113
130
 
@@ -117,8 +134,18 @@ class HttpFallbackMixin:
117
134
  # Construct URL
118
135
  url = f"{self.http_fallback_url}/v1/invoke"
119
136
 
120
- # Build headers with authentication
121
- headers = {'X-Request-ID': request_id}
137
+ # Build headers with authentication and request tracking
138
+ headers = {
139
+ 'X-Request-ID': request_id,
140
+ 'Content-Type': 'application/json',
141
+ 'User-Agent': 'TamarModelClient/1.0'
142
+ }
143
+
144
+ # Add origin request ID if provided
145
+ if origin_request_id:
146
+ headers['X-Origin-Request-ID'] = origin_request_id
147
+
148
+ # Add JWT authentication if available
122
149
  if hasattr(self, 'jwt_token') and self.jwt_token:
123
150
  headers['Authorization'] = f'Bearer {self.jwt_token}'
124
151
 
@@ -138,10 +165,101 @@ class HttpFallbackMixin:
138
165
  # Parse response
139
166
  data = response.json()
140
167
  return ModelResponse(**data)
168
+
169
+ def _invoke_batch_http_fallback(self, batch_request: 'BatchModelRequest',
170
+ timeout: Optional[float] = None,
171
+ request_id: Optional[str] = None,
172
+ origin_request_id: Optional[str] = None) -> List['BatchModelResponse']:
173
+ """HTTP batch fallback implementation"""
174
+ # Import here to avoid circular import
175
+ from ..schemas import BatchModelRequest, BatchModelResponse
176
+
177
+ # Check if http_fallback_url is available
178
+ if not hasattr(self, 'http_fallback_url') or not self.http_fallback_url:
179
+ raise RuntimeError("HTTP fallback URL not configured. Please set MODEL_CLIENT_HTTP_FALLBACK_URL environment variable.")
180
+
181
+ self._ensure_http_client()
182
+
183
+ # Generate request ID if not provided
184
+ if not request_id:
185
+ request_id = generate_request_id()
186
+
187
+ # Log fallback usage
188
+ logger.warning(
189
+ f"🔻 Using HTTP fallback for batch request",
190
+ extra={
191
+ "log_type": "info",
192
+ "request_id": request_id,
193
+ "data": {
194
+ "origin_request_id": origin_request_id,
195
+ "batch_size": len(batch_request.items),
196
+ "fallback_url": self.http_fallback_url
197
+ }
198
+ }
199
+ )
200
+
201
+ # Convert to HTTP format
202
+ http_payload = {
203
+ "user_context": batch_request.user_context.model_dump(),
204
+ "items": []
205
+ }
206
+
207
+ # Convert each item
208
+ for item in batch_request.items:
209
+ item_payload = self._convert_to_http_format(item)
210
+ if hasattr(item, 'custom_id') and item.custom_id:
211
+ item_payload['custom_id'] = item.custom_id
212
+ if hasattr(item, 'priority') and item.priority is not None:
213
+ item_payload['priority'] = item.priority
214
+ http_payload['items'].append(item_payload)
215
+
216
+ # Construct URL
217
+ url = f"{self.http_fallback_url}/v1/batch-invoke"
218
+
219
+ # Build headers with authentication and request tracking
220
+ headers = {
221
+ 'X-Request-ID': request_id,
222
+ 'Content-Type': 'application/json',
223
+ 'User-Agent': 'TamarModelClient/1.0'
224
+ }
225
+
226
+ # Add origin request ID if provided
227
+ if origin_request_id:
228
+ headers['X-Origin-Request-ID'] = origin_request_id
229
+
230
+ # Add JWT authentication if available
231
+ if hasattr(self, 'jwt_token') and self.jwt_token:
232
+ headers['Authorization'] = f'Bearer {self.jwt_token}'
233
+
234
+ # Send batch request
235
+ response = self._http_client.post(
236
+ url,
237
+ json=http_payload,
238
+ timeout=timeout or 120, # Longer timeout for batch requests
239
+ headers=headers
240
+ )
241
+ response.raise_for_status()
242
+
243
+ # Parse response
244
+ data = response.json()
245
+ results = []
246
+ for item_data in data.get('results', []):
247
+ results.append(BatchModelResponse(**item_data))
248
+
249
+ return results
141
250
 
142
251
 
143
252
  class AsyncHttpFallbackMixin:
144
- """HTTP fallback functionality for asynchronous clients"""
253
+ """HTTP fallback functionality for asynchronous clients
254
+
255
+ This mixin requires the following attributes from the host class:
256
+ - http_fallback_url: str - The HTTP fallback URL (from BaseClient._init_resilient_features)
257
+ - jwt_token: Optional[str] - JWT token for authentication (from BaseClient)
258
+
259
+ Usage:
260
+ This mixin should be used with BaseClient or its subclasses that have
261
+ initialized the resilient features with _init_resilient_features().
262
+ """
145
263
 
146
264
  async def _ensure_http_client(self) -> None:
147
265
  """Ensure async HTTP client is initialized"""
@@ -149,7 +267,6 @@ class AsyncHttpFallbackMixin:
149
267
  import aiohttp
150
268
  self._http_session = aiohttp.ClientSession(
151
269
  headers={
152
- 'Content-Type': 'application/json',
153
270
  'User-Agent': 'AsyncTamarModelClient/1.0'
154
271
  }
155
272
  )
@@ -192,8 +309,13 @@ class AsyncHttpFallbackMixin:
192
309
 
193
310
  async def _invoke_http_fallback(self, model_request: ModelRequest,
194
311
  timeout: Optional[float] = None,
195
- request_id: Optional[str] = None) -> Any:
312
+ request_id: Optional[str] = None,
313
+ origin_request_id: Optional[str] = None) -> Any:
196
314
  """Async HTTP fallback implementation"""
315
+ # Check if http_fallback_url is available
316
+ if not hasattr(self, 'http_fallback_url') or not self.http_fallback_url:
317
+ raise RuntimeError("HTTP fallback URL not configured. Please set MODEL_CLIENT_HTTP_FALLBACK_URL environment variable.")
318
+
197
319
  await self._ensure_http_client()
198
320
 
199
321
  # Generate request ID if not provided
@@ -204,10 +326,14 @@ class AsyncHttpFallbackMixin:
204
326
  logger.warning(
205
327
  f"🔻 Using HTTP fallback for request",
206
328
  extra={
329
+ "log_type": "info",
207
330
  "request_id": request_id,
208
- "provider": model_request.provider.value,
209
- "model": model_request.model,
210
- "fallback_url": self.http_fallback_url
331
+ "data": {
332
+ "origin_request_id": origin_request_id,
333
+ "provider": model_request.provider.value,
334
+ "model": model_request.model,
335
+ "fallback_url": self.http_fallback_url
336
+ }
211
337
  }
212
338
  )
213
339
 
@@ -217,8 +343,18 @@ class AsyncHttpFallbackMixin:
217
343
  # Construct URL
218
344
  url = f"{self.http_fallback_url}/v1/invoke"
219
345
 
220
- # Build headers with authentication
221
- headers = {'X-Request-ID': request_id}
346
+ # Build headers with authentication and request tracking
347
+ headers = {
348
+ 'X-Request-ID': request_id,
349
+ 'Content-Type': 'application/json',
350
+ 'User-Agent': 'AsyncTamarModelClient/1.0'
351
+ }
352
+
353
+ # Add origin request ID if provided
354
+ if origin_request_id:
355
+ headers['X-Origin-Request-ID'] = origin_request_id
356
+
357
+ # Add JWT authentication if available
222
358
  if hasattr(self, 'jwt_token') and self.jwt_token:
223
359
  headers['Authorization'] = f'Bearer {self.jwt_token}'
224
360
 
@@ -242,6 +378,91 @@ class AsyncHttpFallbackMixin:
242
378
  data = await response.json()
243
379
  return ModelResponse(**data)
244
380
 
381
+ async def _invoke_batch_http_fallback(self, batch_request: 'BatchModelRequest',
382
+ timeout: Optional[float] = None,
383
+ request_id: Optional[str] = None,
384
+ origin_request_id: Optional[str] = None) -> List['BatchModelResponse']:
385
+ """Async HTTP batch fallback implementation"""
386
+ # Import here to avoid circular import
387
+ from ..schemas import BatchModelRequest, BatchModelResponse
388
+
389
+ # Check if http_fallback_url is available
390
+ if not hasattr(self, 'http_fallback_url') or not self.http_fallback_url:
391
+ raise RuntimeError("HTTP fallback URL not configured. Please set MODEL_CLIENT_HTTP_FALLBACK_URL environment variable.")
392
+
393
+ await self._ensure_http_client()
394
+
395
+ # Generate request ID if not provided
396
+ if not request_id:
397
+ request_id = generate_request_id()
398
+
399
+ # Log fallback usage
400
+ logger.warning(
401
+ f"🔻 Using HTTP fallback for batch request",
402
+ extra={
403
+ "log_type": "info",
404
+ "request_id": request_id,
405
+ "data": {
406
+ "origin_request_id": origin_request_id,
407
+ "batch_size": len(batch_request.items),
408
+ "fallback_url": self.http_fallback_url
409
+ }
410
+ }
411
+ )
412
+
413
+ # Convert to HTTP format
414
+ http_payload = {
415
+ "user_context": batch_request.user_context.model_dump(),
416
+ "items": []
417
+ }
418
+
419
+ # Convert each item
420
+ for item in batch_request.items:
421
+ item_payload = self._convert_to_http_format(item)
422
+ if hasattr(item, 'custom_id') and item.custom_id:
423
+ item_payload['custom_id'] = item.custom_id
424
+ if hasattr(item, 'priority') and item.priority is not None:
425
+ item_payload['priority'] = item.priority
426
+ http_payload['items'].append(item_payload)
427
+
428
+ # Construct URL
429
+ url = f"{self.http_fallback_url}/v1/batch-invoke"
430
+
431
+ # Build headers with authentication and request tracking
432
+ headers = {
433
+ 'X-Request-ID': request_id,
434
+ 'Content-Type': 'application/json',
435
+ 'User-Agent': 'AsyncTamarModelClient/1.0'
436
+ }
437
+
438
+ # Add origin request ID if provided
439
+ if origin_request_id:
440
+ headers['X-Origin-Request-ID'] = origin_request_id
441
+
442
+ # Add JWT authentication if available
443
+ if hasattr(self, 'jwt_token') and self.jwt_token:
444
+ headers['Authorization'] = f'Bearer {self.jwt_token}'
445
+
446
+ # Send batch request
447
+ import aiohttp
448
+ timeout_obj = aiohttp.ClientTimeout(total=timeout or 120) if timeout else None
449
+
450
+ async with self._http_session.post(
451
+ url,
452
+ json=http_payload,
453
+ timeout=timeout_obj,
454
+ headers=headers
455
+ ) as response:
456
+ response.raise_for_status()
457
+
458
+ # Parse response
459
+ data = await response.json()
460
+ results = []
461
+ for item_data in data.get('results', []):
462
+ results.append(BatchModelResponse(**item_data))
463
+
464
+ return results
465
+
245
466
  async def _cleanup_http_session(self) -> None:
246
467
  """Clean up HTTP session"""
247
468
  if hasattr(self, '_http_session') and self._http_session:
@@ -10,7 +10,7 @@ import threading
10
10
  from typing import Optional, Dict
11
11
 
12
12
  from ..json_formatter import JSONFormatter
13
- from .utils import get_request_id
13
+ from .utils import get_request_id, get_origin_request_id
14
14
 
15
15
  # gRPC 消息长度限制(32位系统兼容)
16
16
  MAX_MESSAGE_LENGTH = 2 ** 31 - 1
@@ -45,6 +45,20 @@ class RequestIdFilter(logging.Filter):
45
45
  """
46
46
  # 从 ContextVar 中获取当前的 request_id
47
47
  record.request_id = get_request_id()
48
+
49
+ # 添加 origin_request_id 到 data 字段
50
+ origin_request_id = get_origin_request_id()
51
+ if origin_request_id:
52
+ # 确保 data 字段存在且是字典类型
53
+ if not hasattr(record, 'data'):
54
+ record.data = {}
55
+ elif record.data is None:
56
+ record.data = {}
57
+ elif isinstance(record.data, dict):
58
+ # 只有在 data 是字典且没有 origin_request_id 时才添加
59
+ if 'origin_request_id' not in record.data:
60
+ record.data['origin_request_id'] = origin_request_id
61
+
48
62
  return True
49
63
 
50
64
 
@@ -0,0 +1,112 @@
1
+ """
2
+ Request ID 管理器
3
+
4
+ 管理 request_id 的生成和追踪,支持为同一个原始 request_id 生成带序号的组合 ID。
5
+ """
6
+
7
+ import threading
8
+ import time
9
+ from typing import Dict, Tuple, Optional
10
+
11
+
12
+ class RequestIdManager:
13
+ """
14
+ 管理 request_id 的生成和追踪
15
+
16
+ 为同一个原始 request_id 生成带序号的组合 ID,如:
17
+ - 原始 ID: abc-123
18
+ - 第一次调用: abc-123-1
19
+ - 第二次调用: abc-123-2
20
+
21
+ 包含自动清理机制,避免内存泄漏。
22
+ """
23
+
24
+ def __init__(self, ttl_seconds: int = 3600):
25
+ """
26
+ 初始化 RequestIdManager
27
+
28
+ Args:
29
+ ttl_seconds: 计数器的生存时间(秒),默认 1 小时
30
+ """
31
+ self._counters: Dict[str, Dict[str, any]] = {} # {origin_id: {'count': int, 'last_used': float}}
32
+ self._lock = threading.Lock()
33
+ self._ttl = ttl_seconds
34
+ self._last_cleanup = time.time()
35
+ self._cleanup_interval = 300 # 每 5 分钟执行一次清理
36
+
37
+ def get_composite_id(self, origin_id: str) -> Tuple[str, str]:
38
+ """
39
+ 获取组合的 request_id
40
+
41
+ Args:
42
+ origin_id: 原始的 request_id
43
+
44
+ Returns:
45
+ tuple: (composite_request_id, origin_request_id)
46
+ - composite_request_id: 带序号的组合 ID,如 "abc-123-1"
47
+ - origin_request_id: 原始 ID,如 "abc-123"
48
+ """
49
+ with self._lock:
50
+ current_time = time.time()
51
+
52
+ # 定期清理过期的计数器
53
+ if current_time - self._last_cleanup > self._cleanup_interval:
54
+ self._cleanup_expired(current_time)
55
+ self._last_cleanup = current_time
56
+
57
+ # 获取或初始化计数器
58
+ if origin_id not in self._counters:
59
+ self._counters[origin_id] = {
60
+ 'count': 0,
61
+ 'last_used': current_time
62
+ }
63
+
64
+ # 递增计数
65
+ self._counters[origin_id]['count'] += 1
66
+ self._counters[origin_id]['last_used'] = current_time
67
+
68
+ composite_id = f"{origin_id}-{self._counters[origin_id]['count']}"
69
+ return composite_id, origin_id
70
+
71
+ def _cleanup_expired(self, current_time: float):
72
+ """
73
+ 清理过期的计数器
74
+
75
+ Args:
76
+ current_time: 当前时间戳
77
+ """
78
+ expired_ids = [
79
+ origin_id for origin_id, info in self._counters.items()
80
+ if current_time - info['last_used'] > self._ttl
81
+ ]
82
+
83
+ for origin_id in expired_ids:
84
+ del self._counters[origin_id]
85
+
86
+ # 如果计数器数量过多,保留最近使用的一半
87
+ if len(self._counters) > 1000:
88
+ sorted_items = sorted(
89
+ self._counters.items(),
90
+ key=lambda x: x[1]['last_used'],
91
+ reverse=True
92
+ )[:500]
93
+ self._counters = dict(sorted_items)
94
+
95
+ def get_stats(self) -> Dict[str, any]:
96
+ """
97
+ 获取统计信息
98
+
99
+ Returns:
100
+ dict: 包含计数器数量等统计信息
101
+ """
102
+ with self._lock:
103
+ return {
104
+ 'total_counters': len(self._counters),
105
+ 'ttl_seconds': self._ttl,
106
+ 'cleanup_interval': self._cleanup_interval
107
+ }
108
+
109
+ def clear(self):
110
+ """清空所有计数器"""
111
+ with self._lock:
112
+ self._counters.clear()
@@ -15,6 +15,7 @@ from pydantic import BaseModel
15
15
 
16
16
  # 使用 contextvars 管理请求ID,支持异步和同步上下文中的请求追踪
17
17
  _request_id: ContextVar[str] = ContextVar('request_id', default='-')
18
+ _origin_request_id: ContextVar[str] = ContextVar('origin_request_id', default=None)
18
19
 
19
20
 
20
21
  def is_effective_value(value) -> bool:
@@ -168,4 +169,29 @@ def get_request_id() -> str:
168
169
  Returns:
169
170
  str: 当前的请求ID或默认值
170
171
  """
171
- return _request_id.get()
172
+ return _request_id.get()
173
+
174
+
175
+ def set_origin_request_id(origin_request_id: str):
176
+ """
177
+ 设置当前上下文的原始请求ID
178
+
179
+ 在 ContextVar 中设置原始请求ID,使得在整个异步调用链中
180
+ 都能访问到同一个原始请求ID,便于追踪请求来源。
181
+
182
+ Args:
183
+ origin_request_id: 要设置的原始请求ID字符串
184
+ """
185
+ _origin_request_id.set(origin_request_id)
186
+
187
+
188
+ def get_origin_request_id() -> str:
189
+ """
190
+ 获取当前上下文的原始请求ID
191
+
192
+ 从 ContextVar 中获取当前的原始请求ID,如果没有设置则返回 None
193
+
194
+ Returns:
195
+ str: 当前的原始请求ID或 None
196
+ """
197
+ return _origin_request_id.get()
@@ -230,19 +230,106 @@ class EnhancedRetryHandler:
230
230
  except (grpc.RpcError, grpc.aio.AioRpcError) as e:
231
231
  # 创建错误上下文
232
232
  error_context = ErrorContext(e, context)
233
+ current_duration = time.time() - method_start_time
234
+ context['duration'] = current_duration
233
235
 
234
236
  # 判断是否可以重试
235
- if not self._should_retry(e, attempt):
237
+ should_retry = self._should_retry(e, attempt)
238
+
239
+ # 检查是否应该尝试快速降级(需要从外部注入client引用)
240
+ should_try_fallback = False
241
+ if hasattr(self.error_handler, 'client') and hasattr(self.error_handler.client, '_should_try_fallback'):
242
+ should_try_fallback = self.error_handler.client._should_try_fallback(e.code(), attempt)
243
+
244
+ if should_try_fallback:
245
+ # 尝试快速降级到HTTP
246
+ logger.warning(
247
+ f"🚀 Fast fallback triggered for {e.code().name} after {attempt + 1} attempts",
248
+ extra={
249
+ "log_type": "fast_fallback",
250
+ "request_id": error_context.request_id,
251
+ "data": {
252
+ "error_code": e.code().name,
253
+ "attempt": attempt,
254
+ "fallback_reason": "immediate" if hasattr(self.error_handler.client, 'immediate_fallback_errors') and e.code() in self.error_handler.client.immediate_fallback_errors else "after_retries"
255
+ }
256
+ }
257
+ )
258
+
259
+ try:
260
+ # 尝试HTTP降级(需要从context获取必要参数)
261
+ if hasattr(self.error_handler, 'client'):
262
+ # 检查是否是批量请求
263
+ if hasattr(self.error_handler.client, '_current_batch_request'):
264
+ batch_request = self.error_handler.client._current_batch_request
265
+ origin_request_id = getattr(self.error_handler.client, '_current_origin_request_id', None)
266
+ timeout = context.get('timeout')
267
+ request_id = context.get('request_id')
268
+
269
+ # 尝试批量HTTP降级
270
+ result = await self.error_handler.client._invoke_batch_http_fallback(batch_request, timeout, request_id, origin_request_id)
271
+ elif hasattr(self.error_handler.client, '_current_model_request'):
272
+ model_request = self.error_handler.client._current_model_request
273
+ origin_request_id = getattr(self.error_handler.client, '_current_origin_request_id', None)
274
+ timeout = context.get('timeout')
275
+ request_id = context.get('request_id')
276
+
277
+ # 尝试HTTP降级
278
+ result = await self.error_handler.client._invoke_http_fallback(model_request, timeout, request_id, origin_request_id)
279
+
280
+ logger.info(
281
+ f"✅ Fast fallback to HTTP succeeded",
282
+ extra={
283
+ "log_type": "fast_fallback_success",
284
+ "request_id": request_id,
285
+ "data": {
286
+ "grpc_attempts": attempt + 1,
287
+ "fallback_duration": time.time() - method_start_time
288
+ }
289
+ }
290
+ )
291
+
292
+ return result
293
+ except Exception as fallback_error:
294
+ # 降级失败,记录日志但继续原有重试逻辑
295
+ logger.warning(
296
+ f"⚠️ Fast fallback to HTTP failed: {str(fallback_error)}",
297
+ extra={
298
+ "log_type": "fast_fallback_failed",
299
+ "request_id": error_context.request_id,
300
+ "data": {
301
+ "fallback_error": str(fallback_error),
302
+ "will_continue_grpc_retry": should_retry and attempt < self.max_retries
303
+ }
304
+ }
305
+ )
306
+
307
+ if not should_retry:
236
308
  # 不可重试或已达到最大重试次数
237
- current_duration = time.time() - method_start_time
238
- context['duration'] = current_duration
309
+ # 记录最终失败日志
310
+ log_data = {
311
+ "log_type": "info",
312
+ "request_id": error_context.request_id,
313
+ "data": {
314
+ "error_code": error_context.error_code.name if error_context.error_code else 'UNKNOWN',
315
+ "error_message": error_context.error_message,
316
+ "retry_count": attempt,
317
+ "max_retries": self.max_retries,
318
+ "category": error_context._get_error_category(),
319
+ "is_retryable": False,
320
+ "method": error_context.method,
321
+ "final_failure": True
322
+ },
323
+ "duration": current_duration
324
+ }
325
+ logger.warning(
326
+ f"Final attempt {attempt + 1}/{self.max_retries + 1} failed: {e.code()} (no more retries)",
327
+ extra=log_data
328
+ )
239
329
  last_exception = self.error_handler.handle_error(e, context)
240
330
  break
241
331
 
242
- # 计算当前耗时
243
- current_duration = time.time() - method_start_time
244
-
245
- # 记录重试日志
332
+ # 可以重试,记录重试日志
246
333
  log_data = {
247
334
  "log_type": "info",
248
335
  "request_id": error_context.request_id,
@@ -252,13 +339,15 @@ class EnhancedRetryHandler:
252
339
  "retry_count": attempt,
253
340
  "max_retries": self.max_retries,
254
341
  "category": error_context._get_error_category(),
255
- "is_retryable": True, # 既然在重试,说明是可重试的
256
- "method": error_context.method
342
+ "is_retryable": True,
343
+ "method": error_context.method,
344
+ "will_retry": True,
345
+ "fallback_attempted": should_try_fallback
257
346
  },
258
347
  "duration": current_duration
259
348
  }
260
349
  logger.warning(
261
- f"Attempt {attempt + 1}/{self.max_retries + 1} failed: {e.code()}",
350
+ f"Attempt {attempt + 1}/{self.max_retries + 1} failed: {e.code()} (will retry)",
262
351
  extra=log_data
263
352
  )
264
353
 
@@ -267,8 +356,8 @@ class EnhancedRetryHandler:
267
356
  delay = self._calculate_backoff(attempt)
268
357
  await asyncio.sleep(delay)
269
358
 
270
- context['duration'] = current_duration
271
- last_exception = self.error_handler.handle_error(e, context)
359
+ # 保存异常,以备后续使用
360
+ last_exception = e
272
361
 
273
362
  except Exception as e:
274
363
  # 非 gRPC 错误,直接包装抛出
@@ -280,7 +369,11 @@ class EnhancedRetryHandler:
280
369
 
281
370
  # 抛出最后的异常
282
371
  if last_exception:
283
- raise last_exception
372
+ if isinstance(last_exception, TamarModelException):
373
+ raise last_exception
374
+ else:
375
+ # 对于原始的 gRPC 异常,需要包装
376
+ raise self.error_handler.handle_error(last_exception, context)
284
377
  else:
285
378
  raise TamarModelException("Unknown error occurred")
286
379