fiuai-sdk-python 0.11.9__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 (48) hide show
  1. fiuai_sdk_python-0.13.0/CHANGELOG.md +61 -0
  2. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/PKG-INFO +1 -1
  3. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/pyproject.toml +1 -1
  4. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/__init__.py +15 -1
  5. fiuai_sdk_python-0.13.0/src/fiuai_sdk_python/_resp_helpers.py +163 -0
  6. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/client.py +187 -65
  7. fiuai_sdk_python-0.13.0/src/fiuai_sdk_python/error_code.py +145 -0
  8. fiuai_sdk_python-0.13.0/src/fiuai_sdk_python/error_parsers.py +122 -0
  9. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/redis_manager.py +14 -3
  10. fiuai_sdk_python-0.13.0/src/fiuai_sdk_python/resp.py +494 -0
  11. fiuai_sdk_python-0.11.9/CHANGELOG.md +0 -37
  12. fiuai_sdk_python-0.11.9/src/fiuai_sdk_python/resp.py +0 -289
  13. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/.gitignore +0 -0
  14. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/LICENSE +0 -0
  15. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/README.md +0 -0
  16. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/auth/__init__.py +0 -0
  17. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/auth/context_mgr.py +0 -0
  18. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/auth/header.py +0 -0
  19. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/auth/helper.py +0 -0
  20. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/auth/type.py +0 -0
  21. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/bank.py +0 -0
  22. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/company.py +0 -0
  23. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/config.py +0 -0
  24. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/const.py +0 -0
  25. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/context.py +0 -0
  26. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/datatype.py +0 -0
  27. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/doctype.py +0 -0
  28. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/error.py +0 -0
  29. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/examples/fastapi_integration.py +0 -0
  30. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/http/__init__.py +0 -0
  31. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/http/client.py +0 -0
  32. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/item.py +0 -0
  33. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/perm.py +0 -0
  34. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/__init__.py +0 -0
  35. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/cache_client.py +0 -0
  36. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/circuit_breaker.py +0 -0
  37. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/decorator.py +0 -0
  38. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/cache/types.py +0 -0
  39. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/scheduler/__init__.py +0 -0
  40. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/pkg/scheduler/runner.py +0 -0
  41. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/profile.py +0 -0
  42. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/setup.py +0 -0
  43. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/type.py +0 -0
  44. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/util.py +0 -0
  45. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/utils/__init__.py +0 -0
  46. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/utils/ids.py +0 -0
  47. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/utils/logger.py +0 -0
  48. {fiuai_sdk_python-0.11.9 → fiuai_sdk_python-0.13.0}/src/fiuai_sdk_python/utils/text.py +0 -0
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
+
21
+ ## [Unreleased]
22
+
23
+ ### Added
24
+ - 新增结构化错误处理: `ApiError` model + `FiuaiErrorCode` 枚举
25
+ - `ApiError` 字段: `code` / `type` / `message` / `detail` / `context` / `http_status_code` / `traceback`
26
+ - `FiuaiErrorCode` 提供稳定错误码枚举 (跨语言/跨版本不变), 与 frappe 二开侧 codes.py 对齐
27
+ - `ApiResponse` 新增字段:
28
+ - `error: ApiError | None` - 主错误 (errors[0] 规整后)
29
+ - `errors: list[ApiError]` - 所有错误项
30
+ - `warnings: list[str]` - 非错误的提示文案
31
+ - `raw: dict` - 原始 frappe response (调试用)
32
+ - SDK 内置兜底解析器 `error_parsers.py`, 服务端没补 `errors[].context` 时自动从 frappe 异常字符串提取 doctype/name/fields
33
+ - 网络层异常 (`response=None` / 非 JSON / 非 dict body) 也统一走 `ApiError`, code 为 `NETWORK_ERROR` / `API_INVALID_JSON` / `API_NO_RESPONSE`
34
+ - frappe `messages[]` 中红色友好文案 (启发式: indicator=red / title=错误 / 文本以"错误:"开头 / raise_exception 标记) 自动合并到主 `ApiError.message`
35
+
36
+ ### Changed
37
+ - `parse_response` 重写, 返回的 `ApiResponse` 始终包含结构化 `error` 字段, 一定不会抛异常
38
+ - `_is_api_success` 修复: `data is None` 不再判失败, `delete` / `cancel` 等正常返回 None 的接口不会被误判
39
+
40
+ ### Deprecated
41
+ - `ApiResponse.error_code` / `ApiResponse.error_message` 改为派生 property, 访问触发 `DeprecationWarning`
42
+ - 迁移路径:
43
+ - `resp.error_message[0]` → `resp.error.message`
44
+ - `resp.error_code[0]` → `resp.error.code`
45
+ - 下个 major 版本将移除
46
+
47
+ ### Removed
48
+ - N/A
49
+
50
+ ### Fixed
51
+ - `parse_response` 在 HTTP 502/504 等代理层 HTML 错误时不再 silent fallback, 返回 code=`API_INVALID_JSON` 的结构化 ApiError
52
+ - `_extract_error_info` 中无意义的 try/except 包装移除
53
+
54
+ ### Security
55
+ - N/A
56
+
57
+ ## [0.1.0] - 2025-01-01
58
+
59
+ ### Added
60
+ - Initial release
61
+ - Basic SDK structure and functionality
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fiuai_sdk_python
3
- Version: 0.11.9
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.11.9"
3
+ version = "0.13.0"
4
4
  description = "FiuAI Python SDK - 企业级AI服务集成开发工具包"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -10,8 +10,15 @@ from .context import (
10
10
  is_current_context_valid,
11
11
  get_trace_id,
12
12
  )
13
+ from .error_code import (
14
+ EXCEPTION_TO_CODE,
15
+ FiuaiErrorCode,
16
+ exception_to_code,
17
+ status_code_to_code,
18
+ )
19
+ from .resp import ApiError, ApiResponse, parse_response
13
20
 
14
- __version__ = "0.11.6"
21
+ __version__ = "0.13.0"
15
22
 
16
23
  __all__ = [
17
24
  'FiuaiSDK',
@@ -25,4 +32,11 @@ __all__ = [
25
32
  'get_current_missing_fields',
26
33
  'is_current_context_valid',
27
34
  'get_trace_id',
35
+ 'ApiError',
36
+ 'ApiResponse',
37
+ 'parse_response',
38
+ 'FiuaiErrorCode',
39
+ 'EXCEPTION_TO_CODE',
40
+ 'exception_to_code',
41
+ 'status_code_to_code',
28
42
  ]
@@ -0,0 +1,163 @@
1
+ # -- coding: utf-8 --
2
+ # Project: fiuai_sdk_python
3
+ # Created Date: 2026-05-09
4
+ # Author: liming
5
+ # Email: lmlala@aliyun.com
6
+ # Copyright (c) 2025 FiuAI
7
+
8
+ """
9
+ resp.py 的内部 helper 集合.
10
+
11
+ 不是公开 API, 不要从外部直接 import. 拆出来仅为了控制 resp.py 行数 (单文件 < 400 行规约).
12
+ """
13
+
14
+ import logging
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from httpx import Response
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # --------------------------------------------------------------------------
23
+ # 响应体读取 (用于 JSON 解析失败时的日志/预览)
24
+ # --------------------------------------------------------------------------
25
+
26
+
27
+ def get_response_body_preview(response: Response, max_chars: int = 300) -> str:
28
+ """安全读取响应体前若干字符, 用于错误信息预览. 兼容编码问题及中文."""
29
+ try:
30
+ raw = response.content
31
+ if not raw:
32
+ return ""
33
+ text = raw.decode("utf-8", errors="replace").strip()
34
+ if len(text) > max_chars:
35
+ text = text[:max_chars] + "..."
36
+ return text
37
+ except Exception:
38
+ return ""
39
+
40
+
41
+ def get_response_body_for_log(response: Response, max_chars: int = 2000) -> str:
42
+ """安全读取完整响应体用于日志, 避免编码异常, 支持中文."""
43
+ try:
44
+ raw = response.content
45
+ if not raw:
46
+ return "(empty)"
47
+ text = raw.decode("utf-8", errors="replace").strip()
48
+ if len(text) > max_chars:
49
+ text = text[:max_chars] + "...(truncated)"
50
+ return text
51
+ except Exception as ex:
52
+ return f"(decode error: {ex})"
53
+
54
+
55
+ # --------------------------------------------------------------------------
56
+ # 业务成功判定
57
+ # --------------------------------------------------------------------------
58
+
59
+
60
+ def is_api_success(response_data: Dict[str, Any]) -> bool:
61
+ """判断 API 业务是否成功.
62
+
63
+ 与旧版的差异:
64
+ - 不再要求"必须有 data 才算成功". delete/cancel 等接口正常返回 None 不会被误判
65
+ - 仅依据 errors / exc / http_status_code (frappe 内嵌) 判定
66
+ """
67
+ if not isinstance(response_data, dict):
68
+ return False
69
+ if response_data.get("errors"):
70
+ return False
71
+ if response_data.get("exc"):
72
+ return False
73
+ inner_status = response_data.get("http_status_code")
74
+ if inner_status is not None:
75
+ try:
76
+ return 200 <= int(inner_status) < 300
77
+ except (TypeError, ValueError):
78
+ return False
79
+ return True
80
+
81
+
82
+ def extract_success_data(response_data: Dict[str, Any]) -> Any:
83
+ """从成功响应中提取数据. 优先 data 字段, 其次 messages 字段."""
84
+ data = response_data.get("data")
85
+ if data is not None:
86
+ return data
87
+ messages = response_data.get("messages")
88
+ if messages is not None:
89
+ return messages
90
+ return None
91
+
92
+
93
+ # --------------------------------------------------------------------------
94
+ # messages 红色 / warning 启发式分流
95
+ # --------------------------------------------------------------------------
96
+
97
+
98
+ _RED_INDICATOR = "red"
99
+ _ERROR_TITLES = {"错误", "Error", "ERROR"}
100
+ # frappe _get_missing_mandatory_fields() 等默认友好文案的前缀, 用于启发式判定:
101
+ # "Error: Value missing for ..." / "错误: ..." / "错误:..." (中文全角冒号)
102
+ _ERROR_TEXT_PREFIXES = ("错误:", "错误:", "Error:", "ERROR:", "error:")
103
+
104
+
105
+ def _is_error_message(msg: Dict[str, Any]) -> bool:
106
+ """判定一条 message 是否是错误补充文案 (vs 普通提示).
107
+
108
+ frappe 在调用 ``frappe.throw`` / ``MandatoryError`` 时, 默认 msgprint 不设
109
+ indicator 也不设 title, 但 message 文本本身会以 ``错误:`` / ``Error:`` 开头.
110
+ 所以判定要走多重启发式:
111
+ 1. ``indicator == "red"``
112
+ 2. ``title in {"错误", "Error", "ERROR"}``
113
+ 3. ``raise_exception`` 标记
114
+ 4. message 文本以错误前缀开头
115
+ """
116
+ if (msg.get("indicator") or "").strip().lower() == _RED_INDICATOR:
117
+ return True
118
+ if (msg.get("title") or "").strip() in _ERROR_TITLES:
119
+ return True
120
+ if msg.get("raise_exception"):
121
+ return True
122
+ text = (msg.get("message") or "").strip()
123
+ return text.startswith(_ERROR_TEXT_PREFIXES)
124
+
125
+
126
+ def split_messages(raw_messages: Any) -> tuple[List[str], List[str]]:
127
+ """把 frappe messages[] 拆成 (error_texts, warning_texts).
128
+
129
+ 判定基于 ``_is_error_message`` 多重启发式 (indicator=red / title=错误 / message
130
+ 文本以 "错误:" / "Error:" 开头 / raise_exception 标记). 命中即归错误文案,
131
+ 否则进 warnings.
132
+ """
133
+ error_texts: List[str] = []
134
+ warnings: List[str] = []
135
+ if not isinstance(raw_messages, list):
136
+ return error_texts, warnings
137
+
138
+ for msg in raw_messages:
139
+ if not isinstance(msg, dict):
140
+ text = str(msg).strip()
141
+ if text:
142
+ warnings.append(text)
143
+ continue
144
+ text = (msg.get("message") or "").strip()
145
+ if not text:
146
+ continue
147
+ if _is_error_message(msg):
148
+ error_texts.append(text)
149
+ else:
150
+ warnings.append(text)
151
+
152
+ return error_texts, warnings
153
+
154
+
155
+ def merge_error_message(primary: str, extras: List[str]) -> str:
156
+ """把 frappe messages 里的友好文案合并到主错误 message, 去重去空."""
157
+ pieces: List[str] = []
158
+ if primary:
159
+ pieces.append(primary)
160
+ for extra in extras:
161
+ if extra and extra not in pieces:
162
+ pieces.append(extra)
163
+ return "; ".join(pieces)
@@ -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响应,使用结构化的错误处理系统