fiuai-sdk-python 0.12.0__tar.gz → 0.13.0__tar.gz

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.
Files changed (46) hide show
  1. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/CHANGELOG.md +13 -0
  2. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/PKG-INFO +1 -1
  3. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/pyproject.toml +1 -1
  4. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/__init__.py +1 -1
  5. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/client.py +187 -65
  6. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/resp.py +90 -7
  7. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/.gitignore +0 -0
  8. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/LICENSE +0 -0
  9. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/README.md +0 -0
  10. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/_resp_helpers.py +0 -0
  11. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/auth/__init__.py +0 -0
  12. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/auth/context_mgr.py +0 -0
  13. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/auth/header.py +0 -0
  14. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/auth/helper.py +0 -0
  15. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/auth/type.py +0 -0
  16. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/bank.py +0 -0
  17. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/company.py +0 -0
  18. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/config.py +0 -0
  19. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/const.py +0 -0
  20. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/context.py +0 -0
  21. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/datatype.py +0 -0
  22. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/doctype.py +0 -0
  23. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/error.py +0 -0
  24. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/error_code.py +0 -0
  25. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/error_parsers.py +0 -0
  26. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/examples/fastapi_integration.py +0 -0
  27. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/http/__init__.py +0 -0
  28. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/http/client.py +0 -0
  29. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/item.py +0 -0
  30. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/perm.py +0 -0
  31. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/__init__.py +0 -0
  32. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/cache_client.py +0 -0
  33. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/circuit_breaker.py +0 -0
  34. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/decorator.py +0 -0
  35. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/redis_manager.py +0 -0
  36. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/types.py +0 -0
  37. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/scheduler/__init__.py +0 -0
  38. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/scheduler/runner.py +0 -0
  39. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/profile.py +0 -0
  40. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/setup.py +0 -0
  41. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/type.py +0 -0
  42. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/util.py +0 -0
  43. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/utils/__init__.py +0 -0
  44. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/utils/ids.py +0 -0
  45. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/utils/logger.py +0 -0
  46. {fiuai_sdk_python-0.12.0 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/utils/text.py +0 -0
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.13.0] - 2026-05-13
9
+
10
+ ### Fixed
11
+ - `ApiError` 字段 (`code` / `type` / `message` / `detail` / `traceback`) 增加 `field_validator(mode="before")`, 对 frappe 偶发返回的 `list` / `dict` / `None` 类型自动规范化为 `str`, 修复 `ApiError.message=['Contract already exists']` 等 list 形态触发 Pydantic 校验失败导致 `parse_response` 整体崩溃的问题
12
+ - `_normalize_error_dict` 入口对 `type` / `message` / `display_message` / `title` / `code` / `context` 同步用 `_to_text` 兜底规范化, 避免 `_MANDATORY_RE.match(...)` 在拿到非 str 时抛 `TypeError`
13
+ - `parse_response` 加最外层 try/except 兜底, 任何未预期异常都返回结构化 `ApiResponse(error.code=SERVER_ERROR)`, 兑现"绝不抛异常"契约
14
+ - `FiuaiSDK.get_user_profile_info` 解析失败分支错误地把 `str` 赋给 `ApiError` 字段、给没有 setter 的 deprecated property 赋值, 导致二次崩溃, 现改为构造 `ApiError(code=INTERNAL_VALIDATION_ERROR)` 写入 `error/errors`
15
+ - `__version__` 与 `pyproject.toml` 版本号不一致 (`0.11.6` vs `0.12.0`) 统一为 `0.13.0`
16
+
17
+ ### Added
18
+ - `FiuaiSDK._safe_request` 内部统一封装所有 `httpx` 调用, 捕获 `TimeoutException` / `RequestError` / 其他异常, 统一返回 `ApiResponse(error.code=NETWORK_ERROR)` 或 `SERVER_ERROR`, SDK 调用方不再需要捕获 httpx 异常
19
+ - `resp._to_text` 公共兜底 helper, 把任意 (list / dict / None / 其他) 安全规范化为 str
20
+
8
21
  ## [Unreleased]
9
22
 
10
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fiuai_sdk_python
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: FiuAI Python SDK - 企业级AI服务集成开发工具包
5
5
  Project-URL: Homepage, https://github.com/fiuai/fiuai-sdk-python
6
6
  Project-URL: Documentation, https://github.com/fiuai/fiuai-sdk-python#readme
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fiuai_sdk_python"
3
- version = "0.12.0"
3
+ version = "0.13.0"
4
4
  description = "FiuAI Python SDK - 企业级AI服务集成开发工具包"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -18,7 +18,7 @@ from .error_code import (
18
18
  )
19
19
  from .resp import ApiError, ApiResponse, parse_response
20
20
 
21
- __version__ = "0.11.6"
21
+ __version__ = "0.13.0"
22
22
 
23
23
  __all__ = [
24
24
  'FiuaiSDK',
@@ -10,7 +10,8 @@ from logging import getLogger
10
10
  from .type import UserProfile
11
11
  from .profile import UserProfileInfo
12
12
  from .auth import AuthHeader
13
- from .resp import parse_response, ApiResponse
13
+ from .resp import ApiError, ApiResponse, _build_network_error_response, parse_response
14
+ from .error_code import FiuaiErrorCode
14
15
  from .context import get_current_headers
15
16
 
16
17
 
@@ -220,11 +221,12 @@ class FiuaiSDK(object):
220
221
 
221
222
  def get_avaliable_company(self, page: int=1, page_size: int=20, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
222
223
  headers = self._get_merged_headers(extra_headers)
223
- r = self.client.get(self.url + "/api/method/fiuai.network.doctype.company.company.get_available_companies",
224
- params={"page": page, "page_size": page_size},
225
- headers=headers)
226
-
227
- return self.post_process(r)
224
+ return self._safe_request(
225
+ "GET",
226
+ self.url + "/api/method/fiuai.network.doctype.company.company.get_available_companies",
227
+ params={"page": page, "page_size": page_size},
228
+ headers=headers,
229
+ )
228
230
 
229
231
  def swith_company(self, tenant: str = "", company: str = "") -> ApiResponse:
230
232
 
@@ -266,37 +268,60 @@ class FiuaiSDK(object):
266
268
  user_id = self.headers.x_fiuai_user
267
269
 
268
270
  headers = self._get_merged_headers(extra_headers)
269
- res = self.client.get(self.url + f"/api/v2/internal/user/profile/{user_id}", headers=headers)
270
-
271
- profile_response = self.post_process(res)
271
+ profile_response = self._safe_request(
272
+ "GET",
273
+ self.url + f"/api/v2/internal/user/profile/{user_id}",
274
+ headers=headers,
275
+ )
272
276
 
273
- if profile_response.is_success():
274
- try:
275
- profile_response.data = UserProfileInfo.model_validate(profile_response.data)
276
- except Exception as e:
277
- profile_response.error = str(e)
278
- profile_response.error_code = "PROFILE_FORMAT_ERROR"
279
- return profile_response
280
- return profile_response
281
- else:
277
+ if not profile_response.is_success():
282
278
  return profile_response
283
279
 
280
+ try:
281
+ profile_response.data = UserProfileInfo.model_validate(profile_response.data)
282
+ except Exception as e:
283
+ logger.warning("UserProfileInfo parse failed for user=%s: %s", user_id, e)
284
+ err = ApiError(
285
+ code=FiuaiErrorCode.INTERNAL_VALIDATION_ERROR.value,
286
+ type="ProfileFormatError",
287
+ message="用户信息格式异常,请联系管理员",
288
+ detail=str(e),
289
+ http_status_code=profile_response.status_code,
290
+ )
291
+ profile_response.api_success = False
292
+ profile_response.error = err
293
+ profile_response.errors = [err]
294
+ profile_response.data = None
295
+ return profile_response
296
+
284
297
 
285
298
 
286
299
  def internal_post_req(self, uri, postdata={}, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
287
300
  headers = self._get_merged_headers(extra_headers)
288
- res = self.client.post(self.url + '/api/v2/internal/' + uri.lstrip('/'), data=postdata, headers=headers)
289
- return self.post_process(res)
301
+ return self._safe_request(
302
+ "POST",
303
+ self.url + '/api/v2/internal/' + uri.lstrip('/'),
304
+ data=postdata,
305
+ headers=headers,
306
+ )
290
307
 
291
308
  def internal_get_req(self, uri, params={}, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
292
309
  headers = self._get_merged_headers(extra_headers)
293
- res = self.client.get(self.url + '/api/v2/internal/' + uri.lstrip('/'), params=params, headers=headers)
294
- return self.post_process(res)
295
-
310
+ return self._safe_request(
311
+ "GET",
312
+ self.url + '/api/v2/internal/' + uri.lstrip('/'),
313
+ params=params,
314
+ headers=headers,
315
+ )
316
+
296
317
  def internal_create(self, data={}, auto_submit: bool = False, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
297
318
  headers = self._get_merged_headers(extra_headers)
298
- res = self.client.post(self.url + '/api/v2/internal/doctype/create', data={"data":json.dumps(data, ensure_ascii=False), "auto_submit": auto_submit}, headers=headers)
299
- return self.post_process(res)
319
+ return self._safe_request(
320
+ "POST",
321
+ self.url + '/api/v2/internal/doctype/create',
322
+ data={"data": json.dumps(data, ensure_ascii=False), "auto_submit": auto_submit},
323
+ headers=headers,
324
+ )
300
325
 
301
326
  def internal_get(self, doctype, name, fields=None, filters=None, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
302
327
  d = {
@@ -308,8 +333,12 @@ class FiuaiSDK(object):
308
333
  if filters:
309
334
  d["filters"] = json.dumps(filters)
310
335
  headers = self._get_merged_headers(extra_headers)
311
- res = self.client.get(self.url + '/api/v2/internal/doctype/get', params=d, headers=headers)
312
- return self.post_process(res)
336
+ return self._safe_request(
337
+ "GET",
338
+ self.url + '/api/v2/internal/doctype/get',
339
+ params=d,
340
+ headers=headers,
341
+ )
313
342
 
314
343
  def get_decrypted_password(
315
344
  self,
@@ -321,12 +350,12 @@ class FiuaiSDK(object):
321
350
  """从 Frappe __Auth 读取 Password 类型字段明文 (internal get_password)."""
322
351
  params = {"doctype": doctype, "name": name, "fieldname": fieldname}
323
352
  headers = self._get_merged_headers(extra_headers)
324
- res = self.client.get(
353
+ resp = self._safe_request(
354
+ "GET",
325
355
  self.url + "/api/v2/internal/doctype/get_password",
326
356
  params=params,
327
357
  headers=headers,
328
358
  )
329
- resp = self.post_process(res)
330
359
  if resp.is_success() and resp.data and isinstance(resp.data, dict):
331
360
  pw = resp.data.get("password")
332
361
  return pw if isinstance(pw, str) else None
@@ -346,76 +375,169 @@ class FiuaiSDK(object):
346
375
  if order_by:
347
376
  d["order_by"] = order_by
348
377
  headers = self._get_merged_headers(extra_headers)
349
- res = self.client.get(
350
- self.url + '/api/v2/internal/doctype/get_list',
351
- params=d,
352
- headers=headers)
353
- return self.post_process(res)
378
+ return self._safe_request(
379
+ "GET",
380
+ self.url + '/api/v2/internal/doctype/get_list',
381
+ params=d,
382
+ headers=headers,
383
+ )
354
384
 
355
-
356
385
  def internal_update(self, data={}, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
357
386
  headers = self._get_merged_headers(extra_headers)
358
- res = self.client.post(self.url + '/api/v2/internal/doctype/update', data={"data":json.dumps(data, ensure_ascii=False)}, headers=headers)
359
- return self.post_process(res)
387
+ return self._safe_request(
388
+ "POST",
389
+ self.url + '/api/v2/internal/doctype/update',
390
+ data={"data": json.dumps(data, ensure_ascii=False)},
391
+ headers=headers,
392
+ )
360
393
 
361
394
  def internal_delete(self, doctype, name, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
362
395
  headers = self._get_merged_headers(extra_headers)
363
- res = self.client.post(self.url + '/api/v2/internal/doctype/delete', data={"doctype": doctype, "name":name}, headers=headers)
364
- return self.post_process(res)
396
+ return self._safe_request(
397
+ "POST",
398
+ self.url + '/api/v2/internal/doctype/delete',
399
+ data={"doctype": doctype, "name": name},
400
+ headers=headers,
401
+ )
365
402
 
366
403
  def internal_submit(self, doctype, name, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
367
404
  headers = self._get_merged_headers(extra_headers)
368
- res = self.client.post(self.url + '/api/v2/internal/doctype/submit', data={"doctype": doctype, "name":name}, headers=headers)
369
- return self.post_process(res)
370
-
405
+ return self._safe_request(
406
+ "POST",
407
+ self.url + '/api/v2/internal/doctype/submit',
408
+ data={"doctype": doctype, "name": name},
409
+ headers=headers,
410
+ )
411
+
371
412
  def internal_cancel(self, doctype, name, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
372
413
  headers = self._get_merged_headers(extra_headers)
373
- res = self.client.post(self.url + '/api/v2/internal/doctype/cancel', data={"doctype": doctype, "name":name}, headers=headers)
374
- return self.post_process(res)
414
+ return self._safe_request(
415
+ "POST",
416
+ self.url + '/api/v2/internal/doctype/cancel',
417
+ data={"doctype": doctype, "name": name},
418
+ headers=headers,
419
+ )
375
420
 
376
421
 
377
422
  ######## 特殊接口 ########
378
423
  def get_meta(self, doctype: str, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
379
424
  headers = self._get_merged_headers(extra_headers)
380
- res = self.client.get(self.url + '/api/v2/internal/doctype/meta/' + doctype, headers=headers)
381
-
382
- return self.post_process(res)
383
-
425
+ return self._safe_request(
426
+ "GET",
427
+ self.url + '/api/v2/internal/doctype/meta/' + doctype,
428
+ headers=headers,
429
+ )
384
430
 
385
431
  def get_ontology_event(self, event_id: str, extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
386
432
  headers = self._get_merged_headers(extra_headers)
387
- res = self.client.get(self.url + '/api/v2/internal/ontology/event/' + event_id, headers=headers)
388
- return self.post_process(res)
433
+ return self._safe_request(
434
+ "GET",
435
+ self.url + '/api/v2/internal/ontology/event/' + event_id,
436
+ headers=headers,
437
+ )
389
438
 
390
439
  def get_ontology_event_list(self, event_filter: Dict[str, Any], extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
391
-
392
440
  headers = self._get_merged_headers(extra_headers)
393
-
394
- ### url encode 可能 的 list 或者 dict 值
395
-
396
-
397
- res = self.client.get(self.url + '/api/v2/internal/ontology/event', params={"event_filter": quote(json.dumps(event_filter, ensure_ascii=False))}, headers=headers)
398
-
399
- return self.post_process(res)
441
+ return self._safe_request(
442
+ "GET",
443
+ self.url + '/api/v2/internal/ontology/event',
444
+ params={"event_filter": quote(json.dumps(event_filter, ensure_ascii=False))},
445
+ headers=headers,
446
+ )
400
447
 
401
448
  def add_ontology_event(self, data: Dict[str, Any], extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
402
449
  headers = self._get_merged_headers(extra_headers)
403
- res = self.client.post(self.url + '/api/v2/internal/ontology/event/add', data={"data": json.dumps(data, ensure_ascii=False)}, headers=headers)
404
- return self.post_process(res)
450
+ return self._safe_request(
451
+ "POST",
452
+ self.url + '/api/v2/internal/ontology/event/add',
453
+ data={"data": json.dumps(data, ensure_ascii=False)},
454
+ headers=headers,
455
+ )
405
456
 
406
457
  def update_ontology_event(self, data: Dict[str, Any], extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
407
-
408
458
  headers = self._get_merged_headers(extra_headers)
409
- res = self.client.post(self.url + '/api/v2/internal/ontology/event', data={"data": json.dumps(data, ensure_ascii=False)}, headers=headers)
410
- return self.post_process(res)
459
+ return self._safe_request(
460
+ "POST",
461
+ self.url + '/api/v2/internal/ontology/event',
462
+ data={"data": json.dumps(data, ensure_ascii=False)},
463
+ headers=headers,
464
+ )
411
465
 
412
466
  def batch_add_ontology_event(self, data: List[Dict[str, Any]], extra_headers: Optional[Dict[str, Any]] = None) -> ApiResponse:
413
467
  headers = self._get_merged_headers(extra_headers)
414
- res = self.client.post(self.url + '/api/v2/internal/ontology/event/batch_add', data={"data": json.dumps(data, ensure_ascii=False)}, headers=headers)
415
- return self.post_process(res)
468
+ return self._safe_request(
469
+ "POST",
470
+ self.url + '/api/v2/internal/ontology/event/batch_add',
471
+ data={"data": json.dumps(data, ensure_ascii=False)},
472
+ headers=headers,
473
+ )
416
474
 
417
475
 
418
476
  ######## 数据处理 ########
477
+ def _safe_request(self, method: str, url: str, **kwargs) -> ApiResponse:
478
+ """统一的 HTTP 请求封装, 兜底捕获 httpx 网络异常.
479
+
480
+ SDK 对外契约: 永远返回 ``ApiResponse``, 不抛 httpx 异常. 调用方只需读
481
+ ``resp.is_success()`` / ``resp.error.code`` 即可分流业务/网络错误.
482
+
483
+ Args:
484
+ method: HTTP 方法, "GET" / "POST" (大小写不敏感)
485
+ url: 完整请求 URL
486
+ **kwargs: 透传给 ``httpx.Client.{get,post}`` 的参数
487
+
488
+ Returns:
489
+ ApiResponse: 结构化响应. 网络异常时 ``error.code`` 为
490
+ ``NETWORK_ERROR`` / ``SERVER_ERROR``.
491
+ """
492
+ method_upper = method.upper()
493
+ try:
494
+ if method_upper == "GET":
495
+ res = self.client.get(url, **kwargs)
496
+ elif method_upper == "POST":
497
+ res = self.client.post(url, **kwargs)
498
+ else:
499
+ logger.error("Unsupported HTTP method: %s", method)
500
+ return _build_network_error_response(
501
+ code=FiuaiErrorCode.SERVER_ERROR,
502
+ message=f"Unsupported HTTP method: {method}",
503
+ status_code=500,
504
+ http_success=False,
505
+ )
506
+ except httpx.TimeoutException as e:
507
+ logger.warning("HTTP timeout: %s %s, detail=%s", method_upper, url, e)
508
+ return _build_network_error_response(
509
+ code=FiuaiErrorCode.NETWORK_ERROR,
510
+ message="请求超时,请稍后重试",
511
+ status_code=504,
512
+ http_success=False,
513
+ detail=str(e),
514
+ )
515
+ except httpx.RequestError as e:
516
+ logger.warning(
517
+ "HTTP request error: %s %s, type=%s, detail=%s",
518
+ method_upper,
519
+ url,
520
+ type(e).__name__,
521
+ e,
522
+ )
523
+ return _build_network_error_response(
524
+ code=FiuaiErrorCode.NETWORK_ERROR,
525
+ message=f"网络异常: {type(e).__name__}",
526
+ status_code=502,
527
+ http_success=False,
528
+ detail=str(e),
529
+ )
530
+ except Exception as e:
531
+ logger.exception("Unexpected HTTP error: %s %s", method_upper, url)
532
+ return _build_network_error_response(
533
+ code=FiuaiErrorCode.SERVER_ERROR,
534
+ message="请求执行异常",
535
+ status_code=500,
536
+ http_success=False,
537
+ detail=str(e),
538
+ )
539
+ return self.post_process(res)
540
+
419
541
  def post_process(self, response) -> ApiResponse:
420
542
  """
421
543
  处理API响应,使用结构化的错误处理系统
@@ -26,12 +26,13 @@ HTTP 响应规整化.
26
26
  内部 helper 拆到 ``_resp_helpers.py`` (单文件 < 400 行规约).
27
27
  """
28
28
 
29
+ import json
29
30
  import logging
30
31
  import warnings
31
32
  from typing import Any, Dict, List, Optional
32
33
 
33
34
  from httpx import Response
34
- from pydantic import BaseModel, ConfigDict, Field
35
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
35
36
 
36
37
  from . import _resp_helpers as _h
37
38
  from .error_code import (
@@ -44,6 +45,40 @@ from .error_parsers import parse_error_context
44
45
  logger = logging.getLogger(__name__)
45
46
 
46
47
 
48
+ def _to_text(value: Any) -> str:
49
+ """把任意值安全规范化成字符串.
50
+
51
+ 设计目标: 上游 (frappe / 网关) 偶尔会把本该是 str 的字段返回 list/dict/None
52
+ (例如 ``message=['Contract already exists']``), 直接进 Pydantic 会校验失败.
53
+ 本 helper 兜底处理:
54
+ - None / 空白 -> ""
55
+ - list -> "; ".join(各元素递归 _to_text)
56
+ - dict -> 优先取 message/title, 否则 json.dumps
57
+ - 其他 -> str(value)
58
+ """
59
+ if value is None:
60
+ return ""
61
+ if isinstance(value, str):
62
+ return value
63
+ if isinstance(value, list):
64
+ parts: List[str] = []
65
+ for item in value:
66
+ text = _to_text(item)
67
+ if text and text.strip():
68
+ parts.append(text)
69
+ return "; ".join(parts)
70
+ if isinstance(value, dict):
71
+ for key in ("message", "title", "detail"):
72
+ candidate = value.get(key)
73
+ if candidate:
74
+ return _to_text(candidate)
75
+ try:
76
+ return json.dumps(value, ensure_ascii=False)
77
+ except Exception:
78
+ return str(value)
79
+ return str(value)
80
+
81
+
47
82
  # 历史调试样例, 保留作为协议参考 (frappe v2 errors + messages 双源结构):
48
83
  # {
49
84
  # 'errors': [{
@@ -84,6 +119,36 @@ class ApiError(BaseModel):
84
119
  http_status_code: int = Field(default=500, description="HTTP 状态码")
85
120
  traceback: Optional[str] = Field(default=None, description="异常 traceback (仅 dev)")
86
121
 
122
+ @field_validator("code", "type", "message", mode="before")
123
+ @classmethod
124
+ def _coerce_required_text(cls, value: Any, info: Any) -> str:
125
+ """str 必填字段的兜底规范化: list/dict/None 也不会触发 Pydantic 校验失败.
126
+
127
+ - code 为 None/空 时回退到 ``FiuaiErrorCode.UNKNOWN.value``, 保住错误码语义
128
+ - message/type 为 None 时降级为空字符串
129
+ """
130
+ if value is None or (isinstance(value, str) and not value):
131
+ if info.field_name == "code":
132
+ return FiuaiErrorCode.UNKNOWN.value
133
+ return ""
134
+ if isinstance(value, str):
135
+ return value
136
+ text = _to_text(value)
137
+ if not text and info.field_name == "code":
138
+ return FiuaiErrorCode.UNKNOWN.value
139
+ return text
140
+
141
+ @field_validator("detail", "traceback", mode="before")
142
+ @classmethod
143
+ def _coerce_optional_text(cls, value: Any) -> Optional[str]:
144
+ """Optional[str] 字段保留 None 语义, 但 list/dict 仍兜底成 str."""
145
+ if value is None:
146
+ return None
147
+ if isinstance(value, str):
148
+ return value
149
+ text = _to_text(value)
150
+ return text or None
151
+
87
152
 
88
153
  class ApiResponse(BaseModel):
89
154
  """API 响应结构体.
@@ -172,18 +237,20 @@ def _normalize_error_dict(raw_error: Dict[str, Any], http_status_code: int) -> A
172
237
  if not isinstance(raw_error, dict):
173
238
  raw_error = {}
174
239
 
175
- exception_type = (raw_error.get("type") or "").strip()
176
- raw_message = raw_error.get("message") or ""
177
- display_message = raw_error.get("display_message") or ""
178
- title = raw_error.get("title") or ""
240
+ exception_type = _to_text(raw_error.get("type")).strip()
241
+ raw_message = _to_text(raw_error.get("message"))
242
+ display_message = _to_text(raw_error.get("display_message"))
243
+ title = _to_text(raw_error.get("title"))
179
244
 
180
- code_str = raw_error.get("code")
245
+ code_value = raw_error.get("code")
246
+ code_str = _to_text(code_value).strip() if code_value is not None else ""
181
247
  if not code_str:
182
248
  code_str = exception_to_code(exception_type).value
183
249
  if code_str == FiuaiErrorCode.UNKNOWN.value:
184
250
  code_str = status_code_to_code(http_status_code).value
185
251
 
186
- context = raw_error.get("context") or {}
252
+ raw_context = raw_error.get("context")
253
+ context: Dict[str, Any] = raw_context if isinstance(raw_context, dict) else {}
187
254
  if not context and exception_type and raw_message:
188
255
  context = parse_error_context(exception_type, raw_message)
189
256
 
@@ -336,6 +403,22 @@ def parse_response(response: Optional[Response]) -> ApiResponse:
336
403
  Returns:
337
404
  ApiResponse: 结构化的 API 响应, 一定不会抛异常
338
405
  """
406
+ try:
407
+ return _parse_response_impl(response)
408
+ except Exception as e:
409
+ logger.exception("parse_response unexpected failure")
410
+ fallback_status = getattr(response, "status_code", 0) or 500
411
+ return _build_network_error_response(
412
+ code=FiuaiErrorCode.SERVER_ERROR,
413
+ message="SDK 解析响应时发生未预期错误,请联系管理员",
414
+ status_code=fallback_status,
415
+ http_success=False,
416
+ detail=str(e),
417
+ )
418
+
419
+
420
+ def _parse_response_impl(response: Optional[Response]) -> ApiResponse:
421
+ """``parse_response`` 主体实现, 任何异常由外层 ``parse_response`` 统一兜底."""
339
422
  if response is None:
340
423
  return _build_network_error_response(
341
424
  code=FiuaiErrorCode.API_NO_RESPONSE,