Skip to content

Commit 8f5876d

Browse files
committed
fix: surface clearer longbridge token expiry errors
1 parent 675e18c commit 8f5876d

2 files changed

Lines changed: 102 additions & 10 deletions

File tree

src/quant_platform_kit/longbridge/auth.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import hmac
66
import json
77
import time
8+
from datetime import datetime, timezone
89
from typing import Any, Callable
910

1011

@@ -42,6 +43,26 @@ def _longport_sign(method: str, uri: str, headers: dict[str, str], params: str,
4243
return f"HMAC-SHA256 SignedHeaders=authorization;x-api-key;x-timestamp, Signature={signature}"
4344

4445

46+
def _decode_token_expiry(token: str) -> float | None:
47+
try:
48+
parts = token.split(".")
49+
if len(parts) <= 1:
50+
return None
51+
payload_b64 = parts[1]
52+
padded_payload = payload_b64 + "=" * (-len(payload_b64) % 4)
53+
payload = json.loads(base64.urlsafe_b64decode(padded_payload).decode("utf-8"))
54+
expiry = payload.get("exp")
55+
if expiry is None:
56+
return None
57+
return float(expiry)
58+
except Exception:
59+
return None
60+
61+
62+
def _format_expiry(expiry_timestamp: float) -> str:
63+
return datetime.fromtimestamp(expiry_timestamp, timezone.utc).isoformat()
64+
65+
4566
def refresh_token_if_needed(
4667
current_token: str,
4768
*,
@@ -53,19 +74,20 @@ def refresh_token_if_needed(
5374
requests_module: Any | None = None,
5475
secret_client_factory: Callable[[], Any] | None = None,
5576
) -> str:
77+
expiry_timestamp = _decode_token_expiry(current_token)
78+
now = time.time()
79+
5680
if not app_key or not app_secret:
81+
if expiry_timestamp is not None and expiry_timestamp <= now:
82+
raise RuntimeError(
83+
"LongPort token in secret "
84+
f"'{secret_name}' expired at {_format_expiry(expiry_timestamp)} "
85+
"and cannot be refreshed because LONGPORT_APP_KEY/LONGPORT_APP_SECRET is missing."
86+
)
5787
return current_token
5888

59-
try:
60-
parts = current_token.split(".")
61-
if len(parts) > 1:
62-
payload_b64 = parts[1]
63-
padded_payload = payload_b64 + "=" * (-len(payload_b64) % 4)
64-
payload = json.loads(base64.urlsafe_b64decode(padded_payload).decode("utf-8"))
65-
if (payload.get("exp", 0) - time.time()) / 86400 > refresh_threshold_days:
66-
return current_token
67-
except Exception:
68-
pass
89+
if expiry_timestamp is not None and (expiry_timestamp - now) / 86400 > refresh_threshold_days:
90+
return current_token
6991

7092
if requests_module is None:
7193
import requests as requests_module
@@ -83,6 +105,13 @@ def refresh_token_if_needed(
83105
timeout=15,
84106
).json()
85107
if response.get("code") != 0:
108+
if expiry_timestamp is not None and expiry_timestamp <= now:
109+
code = response.get("code")
110+
message = response.get("message") or "unknown error"
111+
raise RuntimeError(
112+
f"LongPort token in secret '{secret_name}' expired at {_format_expiry(expiry_timestamp)}; "
113+
f"refresh failed with code {code}: {message}"
114+
)
86115
return current_token
87116

88117
new_token = response["data"]["token"]

tests/test_longbridge_auth.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import base64
44
import json
55
import sys
6+
import time
67
import types
78
import unittest
89
from unittest.mock import patch
@@ -62,6 +63,17 @@ def json():
6263
return Response()
6364

6465

66+
class FakeFailedRequests:
67+
@staticmethod
68+
def get(url, headers, timeout):
69+
class Response:
70+
@staticmethod
71+
def json():
72+
return {"code": 401003, "message": "token expired", "data": None}
73+
74+
return Response()
75+
76+
6577
class LongBridgeAuthTests(unittest.TestCase):
6678
def test_fetch_token_from_secret_reads_latest_version(self) -> None:
6779
client = FakeSecretClient("token-abc")
@@ -109,6 +121,57 @@ def test_refresh_token_if_needed_persists_new_token(self) -> None:
109121
self.assertEqual(client.created_parent, "projects/demo/secrets/token")
110122
self.assertEqual(client.destroyed, ["projects/demo/secrets/token/versions/1"])
111123

124+
def test_refresh_token_if_needed_raises_clear_error_when_expired_and_refresh_fails(self) -> None:
125+
payload = {"exp": 1}
126+
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8").rstrip("=")
127+
token = f"aaa.{encoded}.bbb"
128+
129+
with self.assertRaises(RuntimeError) as context:
130+
refresh_token_if_needed(
131+
token,
132+
project_id="demo",
133+
secret_name="longport_token_sg",
134+
app_key="key",
135+
app_secret="secret",
136+
requests_module=FakeFailedRequests,
137+
)
138+
139+
self.assertIn("longport_token_sg", str(context.exception))
140+
self.assertIn("refresh failed with code 401003", str(context.exception))
141+
142+
def test_refresh_token_if_needed_returns_same_token_when_refresh_fails_but_token_not_expired(self) -> None:
143+
payload = {"exp": int(time.time()) + 86400}
144+
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8").rstrip("=")
145+
token = f"aaa.{encoded}.bbb"
146+
147+
refreshed = refresh_token_if_needed(
148+
token,
149+
project_id="demo",
150+
secret_name="token",
151+
app_key="key",
152+
app_secret="secret",
153+
refresh_threshold_days=30,
154+
requests_module=FakeFailedRequests,
155+
)
156+
157+
self.assertEqual(refreshed, token)
158+
159+
def test_refresh_token_if_needed_raises_clear_error_when_expired_and_app_credentials_missing(self) -> None:
160+
payload = {"exp": 1}
161+
encoded = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8").rstrip("=")
162+
token = f"aaa.{encoded}.bbb"
163+
164+
with self.assertRaises(RuntimeError) as context:
165+
refresh_token_if_needed(
166+
token,
167+
project_id="demo",
168+
secret_name="longport_token_sg",
169+
app_key="",
170+
app_secret="",
171+
)
172+
173+
self.assertIn("LONGPORT_APP_KEY/LONGPORT_APP_SECRET is missing", str(context.exception))
174+
112175
def test_build_contexts_uses_longport_openapi(self) -> None:
113176
longport_module = types.ModuleType("longport")
114177
openapi_module = types.ModuleType("longport.openapi")

0 commit comments

Comments
 (0)