diff --git a/services/cliproxyapi_sync.py b/services/cliproxyapi_sync.py index 0a76b20..e4bc056 100644 --- a/services/cliproxyapi_sync.py +++ b/services/cliproxyapi_sync.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 import json +import time from datetime import datetime, timezone from typing import Any, Optional @@ -11,6 +12,8 @@ from platforms.chatgpt.status_probe import CODEX_USER_AGENT, extract_chatgpt_acc from services.chatgpt_account_state import is_account_deactivated_message DEFAULT_CLIPROXYAPI_BASE_URL = "http://127.0.0.1:8317" +SYNC_RETRY_ATTEMPTS = 3 +SYNC_RETRY_DELAY_SECONDS = 0.4 def _utcnow_iso() -> str: @@ -124,6 +127,35 @@ def _request_json(method: str, path: str, *, api_url: str | None = None, api_key return response.text +def _is_retryable_sync_error(exc: Exception) -> bool: + text = str(exc or "").strip().lower() + if not text: + return False + markers = ( + "无法连接", + "请求超时", + "connection", + "timeout", + "timed out", + ) + return any(marker in text for marker in markers) + + +def _retry_sync_call(func, *, attempts: int = SYNC_RETRY_ATTEMPTS): + last_error = None + for attempt in range(1, max(1, attempts) + 1): + try: + return func() + except Exception as exc: + last_error = exc + if attempt >= attempts or not _is_retryable_sync_error(exc): + raise + time.sleep(SYNC_RETRY_DELAY_SECONDS) + if last_error is not None: + raise last_error + raise RuntimeError("sync retry failed without captured error") + + def list_auth_files(*, api_url: str | None = None, api_key: str | None = None) -> list[dict[str, Any]]: data = _request_json("GET", "/v0/management/auth-files", api_url=api_url, api_key=api_key) files = data.get("files", []) if isinstance(data, dict) else [] @@ -240,7 +272,7 @@ def sync_chatgpt_cliproxyapi_status( ) -> dict[str, Any]: synced_at = _utcnow_iso() try: - files = list_auth_files(api_url=api_url, api_key=api_key) + files = _retry_sync_call(lambda: list_auth_files(api_url=api_url, api_key=api_key)) except Exception as exc: return { "uploaded": False, @@ -279,7 +311,11 @@ def sync_chatgpt_cliproxyapi_status( "chatgpt_subscription_active_until": str(((matched.get("id_token") or {}).get("chatgpt_subscription_active_until") if isinstance(matched.get("id_token"), dict) else "") or "").strip(), } try: - remote.update(_probe_remote_auth(remote["auth_index"], account_id, api_url=api_url, api_key=api_key)) + remote.update( + _retry_sync_call( + lambda: _probe_remote_auth(remote["auth_index"], account_id, api_url=api_url, api_key=api_key) + ) + ) except Exception as exc: remote.update( { diff --git a/tests/test_cliproxyapi_sync.py b/tests/test_cliproxyapi_sync.py index 4a44467..2a34b2b 100644 --- a/tests/test_cliproxyapi_sync.py +++ b/tests/test_cliproxyapi_sync.py @@ -25,6 +25,46 @@ class CliproxyapiSyncTests(unittest.TestCase): self.assertEqual(result["remote_state"], "unreachable") self.assertIn("无法连接", result["message"]) + def test_sync_retries_list_auth_files_until_success(self): + account = DummyAccount(email="demo@example.com", user_id="acct-123") + auth_files = [ + { + "name": "demo@example.com.json", + "provider": "codex", + "email": "demo@example.com", + "auth_index": "auth-001", + "status": "active", + "status_message": "", + "unavailable": False, + } + ] + + with mock.patch( + "services.cliproxyapi_sync.list_auth_files", + side_effect=[ + RuntimeError("CLIProxyAPI 无法连接,请确认服务已启动或 API URL 是否正确:http://127.0.0.1:8317"), + RuntimeError("CLIProxyAPI 请求超时:http://127.0.0.1:8317"), + auth_files, + ], + ) as list_mock: + with mock.patch( + "services.cliproxyapi_sync._probe_remote_auth", + return_value={ + "last_probe_at": "2026-03-31T00:00:00Z", + "last_probe_status_code": 200, + "last_probe_error_code": "", + "last_probe_message": "ok", + "remote_state": "usable", + }, + ): + with mock.patch("services.cliproxyapi_sync.time.sleep") as sleep_mock: + result = sync_chatgpt_cliproxyapi_status(account, api_url="http://127.0.0.1:8317", api_key="demo") + + self.assertTrue(result["uploaded"]) + self.assertEqual(result["remote_state"], "usable") + self.assertEqual(list_mock.call_count, 3) + self.assertEqual(sleep_mock.call_count, 2) + def test_sync_returns_not_found_when_remote_auth_missing(self): account = DummyAccount() @@ -97,6 +137,43 @@ class CliproxyapiSyncTests(unittest.TestCase): self.assertEqual(result["last_probe_error_code"], "account_deactivated") self.assertEqual(result["remote_state"], "account_deactivated") + def test_sync_retries_remote_probe_until_success(self): + account = DummyAccount(email="demo@example.com", user_id="acct-123") + auth_files = [ + { + "name": "demo@example.com.json", + "provider": "codex", + "email": "demo@example.com", + "auth_index": "auth-001", + "status": "active", + "status_message": "", + "unavailable": False, + } + ] + + with mock.patch("services.cliproxyapi_sync.list_auth_files", return_value=auth_files): + with mock.patch( + "services.cliproxyapi_sync._probe_remote_auth", + side_effect=[ + RuntimeError("CLIProxyAPI 请求超时:http://127.0.0.1:8317"), + RuntimeError("CLIProxyAPI 无法连接,请确认服务已启动或 API URL 是否正确:http://127.0.0.1:8317"), + { + "last_probe_at": "2026-03-31T00:00:00Z", + "last_probe_status_code": 200, + "last_probe_error_code": "", + "last_probe_message": "ok", + "remote_state": "usable", + }, + ], + ) as probe_mock: + with mock.patch("services.cliproxyapi_sync.time.sleep") as sleep_mock: + result = sync_chatgpt_cliproxyapi_status(account, api_url="http://127.0.0.1:8317", api_key="demo") + + self.assertTrue(result["uploaded"]) + self.assertEqual(result["remote_state"], "usable") + self.assertEqual(probe_mock.call_count, 3) + self.assertEqual(sleep_mock.call_count, 2) + if __name__ == "__main__": unittest.main()