volcano-sdk-python 0.7.3__py3-none-any.whl → 0.9.0__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.
@@ -54,13 +54,16 @@ class LockGuard:
54
54
  *,
55
55
  ttl: int,
56
56
  started_at: float,
57
+ lease_started_at: float | None = None,
57
58
  ) -> None:
58
59
  """Track one acquired lease against its local monotonic deadline."""
59
60
  self._state_lock = threading.Lock()
60
61
  self._lease = lease
61
62
  self._ttl = ttl
62
63
  self._absolute_deadline = started_at + MAX_LOCK_LIFETIME_SECONDS
63
- self._lease_deadline = min(started_at + ttl, self._absolute_deadline)
64
+ # A successful retry renews the TTL, not the maximum ownership lifetime.
65
+ lease_started_at = started_at if lease_started_at is None else lease_started_at
66
+ self._lease_deadline = min(lease_started_at + ttl, self._absolute_deadline)
64
67
  self._failure: Exception | None = None
65
68
  self._lost = threading.Event()
66
69
 
@@ -0,0 +1,70 @@
1
+ """Internal session continuity checks shared by refresh and revocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from collections.abc import Mapping
8
+ from typing import TYPE_CHECKING, cast
9
+ from uuid import UUID
10
+
11
+ from .errors import AuthenticationError
12
+
13
+ if TYPE_CHECKING:
14
+ from .models import Session
15
+
16
+ _JWT_PARTS = 3
17
+ _REFRESH_USER_MISMATCH = "Refreshed session belongs to a different user"
18
+ _REFRESH_SESSION_MISMATCH = "Refreshed credentials belong to a different server session"
19
+ _MISSING_SESSION_ID = "Cannot refresh supplied credentials without a session identifier"
20
+
21
+
22
+ def session_id_from_access_token(access_token: str) -> str | None:
23
+ """Read an untrusted continuity constraint; this never authenticates a user."""
24
+ parts = access_token.split(".")
25
+ if len(parts) != _JWT_PARTS:
26
+ return None
27
+ padding = "=" * (-len(parts[1]) % 4)
28
+ try:
29
+ payload: object = json.loads(
30
+ base64.urlsafe_b64decode(parts[1] + padding).decode()
31
+ )
32
+ except (ValueError, UnicodeDecodeError, RecursionError):
33
+ return None
34
+ if not isinstance(payload, Mapping):
35
+ return None
36
+ values = cast("Mapping[object, object]", payload)
37
+ session_id = values.get("session_id")
38
+ if not isinstance(session_id, str) or not session_id.strip():
39
+ return None
40
+ try:
41
+ return str(UUID(session_id.strip()))
42
+ except ValueError:
43
+ return None
44
+
45
+
46
+ def validate_refresh_source(current: Session, *, verified: bool = False) -> None:
47
+ """Require a session constraint for supplied credentials."""
48
+ if not verified and session_id_from_access_token(current.access_token) is None:
49
+ raise AuthenticationError(_MISSING_SESSION_ID)
50
+
51
+
52
+ def validate_refresh_identity(current: Session | None, refreshed: Session) -> None:
53
+ """Reject a refresh outside the captured server session or validated user."""
54
+ if current is None:
55
+ return
56
+ session_id = session_id_from_access_token(current.access_token)
57
+ if session_id is not None and session_id != session_id_from_access_token(
58
+ refreshed.access_token
59
+ ):
60
+ raise AuthenticationError(_REFRESH_SESSION_MISMATCH)
61
+ if current.user_id is None:
62
+ return
63
+ if current.user_id == refreshed.user_id:
64
+ return
65
+ try:
66
+ matches = UUID(current.user_id) == UUID(str(refreshed.user_id))
67
+ except ValueError:
68
+ matches = False
69
+ if not matches:
70
+ raise AuthenticationError(_REFRESH_USER_MISMATCH)
@@ -0,0 +1,158 @@
1
+ """Bound refresh and revocation work to one explicitly established session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from concurrent.futures import Future
6
+ from threading import Lock
7
+ from typing import TYPE_CHECKING, TypeVar
8
+
9
+ from .errors import SessionChangedError, VolcanoError
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ from .models import Session
15
+
16
+ _T = TypeVar("_T")
17
+
18
+
19
+ def _copy_failure(error: BaseException, *, include_cause: bool = True) -> BaseException:
20
+ """Copy public failure details without retaining request frames or contexts."""
21
+ copied = type(error).__new__(type(error), *error.args)
22
+ if isinstance(error, VolcanoError) and isinstance(copied, VolcanoError):
23
+ copied.status = error.status
24
+ copied.code = error.code
25
+ copied.retry_after = error.retry_after
26
+ if include_cause and isinstance(error.__cause__, VolcanoError):
27
+ copied.__cause__ = _copy_failure(error.__cause__, include_cause=False)
28
+ return copied
29
+
30
+
31
+ class SessionOperations:
32
+ """Retain only this session's latest refresh and shared sign-out outcome."""
33
+
34
+ def __init__(self, verified: Session | None = None) -> None:
35
+ self._lock = Lock()
36
+ self.refreshing: Future[Session] | None = None
37
+ self.signing_out: Future[BaseException | None] | None = None
38
+ self._locally_cleared = False
39
+ self._verified_pair = (
40
+ (verified.access_token, verified.refresh_token)
41
+ if verified is not None
42
+ else None
43
+ )
44
+
45
+ def verify_pair(self, session: Session | None) -> None:
46
+ with self._lock:
47
+ if self._locally_cleared:
48
+ return
49
+ self._verified_pair = (
50
+ (session.access_token, session.refresh_token)
51
+ if session is not None
52
+ else None
53
+ )
54
+
55
+ def clear_local_credentials(self) -> None:
56
+ """Keep pending revocation joinable; discard credentials after other clears."""
57
+ with self._lock:
58
+ if self.signing_out is not None:
59
+ return
60
+ self._locally_cleared = True
61
+ self._verified_pair = None
62
+ refreshing = self.refreshing
63
+ if refreshing is not None:
64
+ refreshing.add_done_callback(self._forget_refresh)
65
+
66
+ def _forget_refresh(self, refreshing: Future[Session]) -> None:
67
+ with self._lock:
68
+ if self.refreshing is refreshing:
69
+ self.refreshing = None
70
+
71
+ def has_verified_pair(self, session: Session) -> bool:
72
+ with self._lock:
73
+ return session.refresh_token is not None and self._verified_pair == (
74
+ session.access_token,
75
+ session.refresh_token,
76
+ )
77
+
78
+ def refresh(self, operation: Callable[[], Session]) -> Session:
79
+ with self._lock:
80
+ if self.signing_out is not None or self._locally_cleared:
81
+ raise SessionChangedError
82
+ future = self.refreshing
83
+ if future is None or future.done():
84
+ future = self.refreshing = Future()
85
+ owner = True
86
+ else:
87
+ owner = False
88
+ if owner:
89
+ self._complete(future, operation)
90
+ return future.result()
91
+
92
+ def sign_out(
93
+ self, operation: Callable[[Future[Session] | None, bool], None]
94
+ ) -> None:
95
+ with self._lock:
96
+ future = self.signing_out
97
+ if future is None:
98
+ future = self.signing_out = Future()
99
+ owner = True
100
+ else:
101
+ owner = False
102
+ preceding = self.refreshing
103
+ pending = preceding is not None and not preceding.done()
104
+ if owner:
105
+ self._complete_revocation(future, operation, preceding, pending=pending)
106
+ self._sign_out_result(future)
107
+
108
+ def wait_for_sign_out(self) -> None:
109
+ with self._lock:
110
+ future = self.signing_out
111
+ pending = future is not None and not future.done()
112
+ if pending and future is not None:
113
+ self._sign_out_result(future)
114
+
115
+ @staticmethod
116
+ def _sign_out_result(future: Future[BaseException | None]) -> None:
117
+ failure = future.result()
118
+ if failure is not None:
119
+ # Raising the retained template would attach credential-bearing frames.
120
+ raise _copy_failure(failure)
121
+
122
+ def _complete_revocation(
123
+ self,
124
+ future: Future[BaseException | None],
125
+ operation: Callable[[Future[Session] | None, bool], None],
126
+ preceding: Future[Session] | None,
127
+ *,
128
+ pending: bool,
129
+ ) -> None:
130
+ try:
131
+ self._revoke(operation, preceding, pending=pending)
132
+ except BaseException as error:
133
+ future.set_result(_copy_failure(error))
134
+ raise
135
+ future.set_result(None)
136
+
137
+ def _revoke(
138
+ self,
139
+ operation: Callable[[Future[Session] | None, bool], None],
140
+ preceding: Future[Session] | None,
141
+ *,
142
+ pending: bool,
143
+ ) -> None:
144
+ try:
145
+ operation(preceding, pending)
146
+ finally:
147
+ with self._lock:
148
+ self._verified_pair = None
149
+ self.refreshing = None
150
+
151
+ @staticmethod
152
+ def _complete(future: Future[_T], operation: Callable[[], _T]) -> None:
153
+ try:
154
+ future.set_result(operation())
155
+ except BaseException as error:
156
+ # Every waiter must finish, including when the owner is interrupted.
157
+ future.set_exception(error)
158
+ raise
volcano_sdk/_transport.py CHANGED
@@ -104,12 +104,17 @@ from ._generated.api.functions.invoke_function import (
104
104
  _get_kwargs as invoke_function_kwargs,
105
105
  )
106
106
  from ._generated.api.locks import (
107
- acquire_project_lock,
108
107
  force_release_project_lock,
109
108
  get_project_lock,
110
109
  release_project_lock,
111
110
  renew_project_lock,
112
111
  )
112
+ from ._generated.api.locks.acquire_project_lock import (
113
+ _build_response as build_lock_acquire_response,
114
+ )
115
+ from ._generated.api.locks.acquire_project_lock import (
116
+ _get_kwargs as lock_acquire_kwargs,
117
+ )
113
118
  from ._generated.api.logs.get_project_log_activity import (
114
119
  _build_response as build_log_activity_response,
115
120
  )
@@ -253,6 +258,7 @@ if TYPE_CHECKING:
253
258
  )
254
259
  from .models import JSONValue
255
260
 
261
+ HTTP_CREATED = 201
256
262
  HTTP_UNAUTHORIZED = 401
257
263
  HTTP_NOT_FOUND = 404
258
264
  HTTP_CONFLICT = 409
@@ -643,6 +649,7 @@ class Transport(Protocol):
643
649
  key: str,
644
650
  ttl: int,
645
651
  token: str,
652
+ request_id: str | None = None,
646
653
  ) -> TransportResponse: ...
647
654
 
648
655
  def release_project_lock(
@@ -651,6 +658,7 @@ class Transport(Protocol):
651
658
  authorization: str,
652
659
  key: str,
653
660
  token: str,
661
+ request_id: str | None = None,
654
662
  ) -> TransportResponse: ...
655
663
 
656
664
 
@@ -1735,15 +1743,19 @@ class GeneratedTransport:
1735
1743
  key: str,
1736
1744
  ttl: int,
1737
1745
  token: str,
1746
+ request_id: str | None = None,
1738
1747
  ) -> TransportResponse:
1739
1748
  with self._client(authorization) as client:
1740
- response = acquire_project_lock.sync_detailed(
1749
+ request_kwargs = lock_acquire_kwargs(
1741
1750
  key,
1742
- client=client,
1743
1751
  body=ProjectLockLeaseRequest(ttl_seconds=ttl),
1744
1752
  x_volcano_lock_token=cast("UUID", token),
1745
- x_volcano_request_id=cast("UUID", str(uuid4())),
1753
+ x_volcano_request_id=cast("UUID", request_id or str(uuid4())),
1746
1754
  )
1755
+ raw_response = client.get_httpx_client().request(**request_kwargs)
1756
+ if raw_response.status_code != HTTP_CREATED:
1757
+ return self._raw_response(raw_response)
1758
+ response = build_lock_acquire_response(client=client, response=raw_response)
1747
1759
  return self._response(response)
1748
1760
 
1749
1761
  def get_project_lock(
@@ -1751,12 +1763,13 @@ class GeneratedTransport:
1751
1763
  *,
1752
1764
  authorization: str,
1753
1765
  key: str,
1766
+ request_id: str | None = None,
1754
1767
  ) -> TransportResponse:
1755
1768
  with self._client(authorization) as client:
1756
1769
  response = get_project_lock.sync_detailed(
1757
1770
  key,
1758
1771
  client=client,
1759
- x_volcano_request_id=cast("UUID", str(uuid4())),
1772
+ x_volcano_request_id=cast("UUID", request_id or str(uuid4())),
1760
1773
  )
1761
1774
  return self._response(response)
1762
1775
 
@@ -1765,12 +1778,13 @@ class GeneratedTransport:
1765
1778
  *,
1766
1779
  authorization: str,
1767
1780
  key: str,
1781
+ request_id: str | None = None,
1768
1782
  ) -> TransportResponse:
1769
1783
  with self._client(authorization) as client:
1770
1784
  response = force_release_project_lock.sync_detailed(
1771
1785
  key,
1772
1786
  client=client,
1773
- x_volcano_request_id=cast("UUID", str(uuid4())),
1787
+ x_volcano_request_id=cast("UUID", request_id or str(uuid4())),
1774
1788
  )
1775
1789
  return self._response(response)
1776
1790
 
@@ -1781,6 +1795,7 @@ class GeneratedTransport:
1781
1795
  key: str,
1782
1796
  ttl: int,
1783
1797
  token: str,
1798
+ request_id: str | None = None,
1784
1799
  ) -> TransportResponse:
1785
1800
  with self._client(authorization) as client:
1786
1801
  response = renew_project_lock.sync_detailed(
@@ -1788,7 +1803,7 @@ class GeneratedTransport:
1788
1803
  client=client,
1789
1804
  body=ProjectLockLeaseRequest(ttl_seconds=ttl),
1790
1805
  x_volcano_lock_token=cast("UUID", token),
1791
- x_volcano_request_id=cast("UUID", str(uuid4())),
1806
+ x_volcano_request_id=cast("UUID", request_id or str(uuid4())),
1792
1807
  )
1793
1808
  return self._response(response)
1794
1809
 
@@ -1798,12 +1813,13 @@ class GeneratedTransport:
1798
1813
  authorization: str,
1799
1814
  key: str,
1800
1815
  token: str,
1816
+ request_id: str | None = None,
1801
1817
  ) -> TransportResponse:
1802
1818
  with self._client(authorization) as client:
1803
1819
  response = release_project_lock.sync_detailed(
1804
1820
  key,
1805
1821
  client=client,
1806
1822
  x_volcano_lock_token=cast("UUID", token),
1807
- x_volcano_request_id=cast("UUID", str(uuid4())),
1823
+ x_volcano_request_id=cast("UUID", request_id or str(uuid4())),
1808
1824
  )
1809
1825
  return self._response(response)
volcano_sdk/auth.py CHANGED
@@ -2,11 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import base64
6
- import binascii
7
- import json
8
5
  import secrets
9
- import threading
10
6
  from collections.abc import Mapping
11
7
  from contextlib import suppress
12
8
  from copy import deepcopy
@@ -43,6 +39,11 @@ from ._generated.models.refresh_o_auth_provider_token_response_200 import (
43
39
  RefreshOAuthProviderTokenResponse200,
44
40
  )
45
41
  from ._generated.types import Unset
42
+ from ._session import (
43
+ session_id_from_access_token,
44
+ validate_refresh_identity,
45
+ validate_refresh_source,
46
+ )
46
47
  from ._transport import (
47
48
  AuthCallOAuthAPITransport,
48
49
  AuthCancelEmailChangeTransport,
@@ -75,6 +76,7 @@ from ._transport import (
75
76
  )
76
77
  from .errors import (
77
78
  AuthenticationError,
79
+ RateLimitedError,
78
80
  SessionChangedError,
79
81
  TransportError,
80
82
  VolcanoError,
@@ -115,8 +117,8 @@ _INVALID_OAUTH_PARAMETER = "OAuth parameters must be non-empty strings"
115
117
  _INVALID_OAUTH_STATE = "OAuth state must not exceed 255 characters"
116
118
  _OAUTH_STATE_MISMATCH = "OAuth state mismatch"
117
119
  _MAX_OAUTH_STATE_LENGTH = 255
118
- _JWT_PARTS = 3
119
120
  _NO_ACTIVE_SESSION = "No active session"
121
+ _REFRESH_UNAVAILABLE = "No refresh token"
120
122
  _T = TypeVar("_T")
121
123
  _OAUTH_PROVIDERS: frozenset[str] = frozenset({"apple", "github", "google", "microsoft"})
122
124
  _OAUTH_API_METHODS: frozenset[str] = frozenset({"GET", "POST"})
@@ -124,11 +126,13 @@ _HOSTED_AUTH_ACTIONS: frozenset[str] = frozenset({"login", "signup", "forgot-pas
124
126
 
125
127
  if TYPE_CHECKING:
126
128
  from collections.abc import Callable
129
+ from concurrent.futures import Future
127
130
 
128
131
  from ._generated.models import (
129
132
  AuthListOAuthProvidersResponse200ProvidersItem,
130
133
  )
131
134
  from ._generated.models.auth_session import AuthSession as GeneratedAuthSession
135
+ from ._session_operations import SessionOperations
132
136
  from ._transport import TransportResponse
133
137
 
134
138
 
@@ -169,26 +173,6 @@ def _validate_oauth_callback_state(state: str, expected_state: str) -> None:
169
173
  raise ValueError(_OAUTH_STATE_MISMATCH)
170
174
 
171
175
 
172
- def _session_id_from_access_token(access_token: str) -> str | None:
173
- parts = access_token.split(".")
174
- if len(parts) != _JWT_PARTS:
175
- return None
176
- padding = "=" * (-len(parts[1]) % 4)
177
- try:
178
- payload: object = json.loads(
179
- base64.urlsafe_b64decode(parts[1] + padding).decode()
180
- )
181
- except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError):
182
- return None
183
- if not isinstance(payload, Mapping):
184
- return None
185
- values = cast("Mapping[object, object]", payload)
186
- session_id = values.get("session_id")
187
- if not isinstance(session_id, str) or not session_id.strip():
188
- return None
189
- return session_id.strip()
190
-
191
-
192
176
  def _has_complete_values(session: Session) -> bool:
193
177
  return all(
194
178
  _is_non_empty_string(value)
@@ -468,7 +452,9 @@ class AuthContext(Protocol):
468
452
 
469
453
  def _capture_session(self) -> tuple[int, Session | None]: ...
470
454
 
471
- def _capture_session_binding(self) -> tuple[int, int, Session | None]: ...
455
+ def _capture_session_binding(
456
+ self,
457
+ ) -> tuple[int, SessionOperations, Session | None]: ...
472
458
 
473
459
  def _update_session_user_if_current(
474
460
  self, user: Mapping[str, JSONValue], generation: int
@@ -487,6 +473,7 @@ class AuthContext(Protocol):
487
473
  self,
488
474
  generation: int,
489
475
  *,
476
+ lineage: SessionOperations | None = None,
490
477
  event: AuthChangeEvent = "SIGNED_OUT",
491
478
  notifications: list[Callable[[], None]] | None = None,
492
479
  ) -> bool: ...
@@ -503,8 +490,7 @@ class Auth:
503
490
  def __init__(self, client: AuthContext) -> None:
504
491
  """Create an authentication facade backed by a client."""
505
492
  self._client = client
506
- self._refresh_lock = threading.RLock()
507
- self._rejected_refresh: tuple[int, int] | None = None
493
+ self._rejected_refresh: tuple[int, SessionOperations] | None = None
508
494
 
509
495
  def get_session(self) -> Session | None:
510
496
  """Return the immutable locally held session without validating it."""
@@ -895,7 +881,7 @@ class Auth:
895
881
  generation, current = self._client._capture_session()
896
882
  if current is None:
897
883
  raise AuthenticationError(_NO_ACTIVE_SESSION)
898
- current_session_id = _session_id_from_access_token(current.access_token)
884
+ current_session_id = session_id_from_access_token(current.access_token)
899
885
  deletes_current = (
900
886
  current_session_id is not None
901
887
  and current_session_id.casefold() == session_id.casefold()
@@ -953,7 +939,7 @@ class Auth:
953
939
  response_payload(response, 200)
954
940
 
955
941
  def _update_current_user(
956
- self, payload: object, binding: tuple[int, int, Session | None]
942
+ self, payload: object, binding: tuple[int, SessionOperations, Session | None]
957
943
  ) -> User:
958
944
  generation = self._owned_refresh_session(binding)[0]
959
945
  user, snapshot = _user_from_payload(payload)
@@ -1030,7 +1016,7 @@ class Auth:
1030
1016
  self,
1031
1017
  operation: Callable[[str], TransportResponse],
1032
1018
  *,
1033
- binding: tuple[int, int, Session | None] | None = None,
1019
+ binding: tuple[int, SessionOperations, Session | None] | None = None,
1034
1020
  ) -> TransportResponse:
1035
1021
  if binding is None:
1036
1022
  binding = self._client._capture_session_binding()
@@ -1057,24 +1043,31 @@ class Auth:
1057
1043
  self._owned_refresh_session(binding)
1058
1044
  return response
1059
1045
 
1060
- def _validate_read_failure(self, binding: tuple[int, int, Session | None]) -> None:
1046
+ def _validate_read_failure(
1047
+ self, binding: tuple[int, SessionOperations, Session | None]
1048
+ ) -> None:
1061
1049
  with suppress(AuthenticationError):
1062
1050
  self._owned_refresh_session(binding)
1063
1051
 
1064
1052
  def _refresh_session_for_binding(
1065
- self, binding: tuple[int, int, Session | None]
1053
+ self, binding: tuple[int, SessionOperations, Session | None]
1066
1054
  ) -> Session:
1067
- generation, _, current = binding
1055
+ generation, owner, current = binding
1068
1056
  if current is None:
1069
1057
  raise AuthenticationError(_NO_ACTIVE_SESSION)
1070
1058
  notifications: list[Callable[[], None]] = []
1071
1059
  try:
1072
- with self._refresh_lock:
1073
- active_generation, _, _ = self._owned_refresh_session(binding)
1074
- if active_generation == generation:
1075
- self._perform_refresh(binding, current, notifications)
1060
+ active_generation, _, _ = self._owned_refresh_session(binding)
1061
+ if active_generation == generation:
1062
+ owner.refresh(
1063
+ lambda: self._perform_refresh(binding, current, notifications)
1064
+ )
1065
+ if owner.signing_out is not None:
1066
+ raise SessionChangedError
1067
+ except VolcanoError:
1068
+ self._validate_read_failure(binding)
1069
+ raise
1076
1070
  finally:
1077
- # Only the dispatcher owner drains, after refresh coordination is released.
1078
1071
  for dispatch in notifications:
1079
1072
  dispatch()
1080
1073
  active = self._owned_refresh_session(binding)[2]
@@ -1083,14 +1076,14 @@ class Auth:
1083
1076
  return active
1084
1077
 
1085
1078
  def _owned_refresh_session(
1086
- self, binding: tuple[int, int, Session | None]
1087
- ) -> tuple[int, int, Session | None]:
1079
+ self, binding: tuple[int, SessionOperations, Session | None]
1080
+ ) -> tuple[int, SessionOperations, Session | None]:
1088
1081
  generation, lineage, _ = binding
1089
1082
  active = self._client._capture_session_binding()
1090
- if self._rejected_refresh == (generation, lineage) and active == (
1091
- generation + 1,
1092
- lineage + 1,
1093
- None,
1083
+ if (
1084
+ self._rejected_refresh == (generation, lineage)
1085
+ and active[0] == generation + 1
1086
+ and active[2] is None
1094
1087
  ):
1095
1088
  raise AuthenticationError(_NO_ACTIVE_SESSION)
1096
1089
  if active[1] != lineage or active[2] is None:
@@ -1099,35 +1092,49 @@ class Auth:
1099
1092
 
1100
1093
  def _perform_refresh(
1101
1094
  self,
1102
- binding: tuple[int, int, Session | None],
1095
+ binding: tuple[int, SessionOperations, Session | None],
1103
1096
  current: Session,
1104
1097
  notifications: list[Callable[[], None]],
1105
- ) -> None:
1106
- generation, lineage, _ = binding
1098
+ ) -> Session:
1099
+ generation, owner, _ = binding
1100
+ active_generation, _, active = self._owned_refresh_session(binding)
1101
+ if active_generation != generation and active is not None:
1102
+ return active
1103
+ if current.refresh_token is None:
1104
+ raise AuthenticationError(_REFRESH_UNAVAILABLE)
1105
+ verified = owner.has_verified_pair(current)
1106
+ validate_refresh_source(current, verified=verified)
1107
+ owner.verify_pair(None)
1107
1108
  try:
1108
- refreshed = self._request_refreshed_session(current)
1109
+ refreshed = self._request_refreshed_session(current.refresh_token)
1110
+ except RateLimitedError:
1111
+ if verified:
1112
+ owner.verify_pair(current)
1113
+ raise
1109
1114
  except AuthenticationError:
1110
- if not self._client._clear_session_if_current(
1115
+ if owner.signing_out is None and self._client._clear_session_if_current(
1111
1116
  generation, notifications=notifications
1112
1117
  ):
1113
- raise SessionChangedError from None
1114
- self._rejected_refresh = (generation, lineage)
1118
+ self._rejected_refresh = (generation, owner)
1115
1119
  raise
1116
- except VolcanoError:
1117
- self._owned_refresh_session(binding)
1118
- raise
1119
- if not self._client._set_session_if_current(
1120
- refreshed, generation, event="TOKEN_REFRESHED", notifications=notifications
1121
- ):
1122
- raise SessionChangedError
1120
+ validate_refresh_identity(current, refreshed)
1121
+ owner.verify_pair(refreshed)
1122
+ if owner.signing_out is None:
1123
+ self._client._set_session_if_current(
1124
+ refreshed,
1125
+ generation,
1126
+ event="TOKEN_REFRESHED",
1127
+ notifications=notifications,
1128
+ )
1129
+ return refreshed
1123
1130
 
1124
- def _request_refreshed_session(self, current: Session) -> Session:
1131
+ def _request_refreshed_session(self, refresh_token: str) -> Session:
1125
1132
  transport = cast("AuthRefreshTransport", self._client._transport)
1126
1133
  try:
1127
1134
  response = invoke(
1128
1135
  transport.auth_refresh,
1129
1136
  authorization=self._client._anon_token(),
1130
- refresh_token=current.refresh_token,
1137
+ refresh_token=refresh_token,
1131
1138
  )
1132
1139
  return _session_from_payload(response_payload(response, 200))
1133
1140
  except (KeyError, TypeError, ValueError) as error:
@@ -1135,21 +1142,108 @@ class Auth:
1135
1142
 
1136
1143
  def sign_out(self) -> None:
1137
1144
  """Revoke and clear the current session."""
1138
- generation, current = self._client._capture_session()
1145
+ binding = self._client._capture_session_binding()
1146
+ if binding[2] is None:
1147
+ binding[1].wait_for_sign_out()
1148
+ return
1149
+ notifications: list[Callable[[], None]] = []
1150
+ try:
1151
+ binding[1].sign_out(
1152
+ lambda preceding, pending: self._sign_out_captured(
1153
+ binding, preceding, notifications, pending=pending
1154
+ )
1155
+ )
1156
+ finally:
1157
+ for dispatch in notifications:
1158
+ dispatch()
1159
+
1160
+ def _sign_out_captured(
1161
+ self,
1162
+ binding: tuple[int, SessionOperations, Session | None],
1163
+ preceding: Future[Session] | None,
1164
+ notifications: list[Callable[[], None]],
1165
+ *,
1166
+ pending: bool,
1167
+ ) -> None:
1168
+ generation, owner, current = binding
1169
+ refresh_error: VolcanoError | None = None
1170
+ if preceding is not None:
1171
+ try:
1172
+ current = preceding.result()
1173
+ except VolcanoError as caught:
1174
+ refresh_error = caught
1139
1175
  if current is None:
1140
1176
  return
1141
- transport = cast("AuthLogoutTransport", self._client._transport)
1142
1177
  error: VolcanoError | None = None
1143
1178
  try:
1144
- response = invoke(
1145
- transport.auth_logout,
1146
- authorization=self._client._anon_token(),
1147
- refresh_token=current.refresh_token,
1179
+ self._revoke_session(
1180
+ current, owner, refresh_error if pending else None, joined=pending
1148
1181
  )
1149
- response_payload(response, 204)
1150
1182
  except VolcanoError as caught:
1151
1183
  error = caught
1152
- if not self._client._clear_session_if_current(generation):
1184
+ if not self._client._clear_session_if_current(
1185
+ generation, lineage=owner, notifications=notifications
1186
+ ):
1153
1187
  raise SessionChangedError from error
1154
1188
  if error is not None:
1155
1189
  raise error
1190
+
1191
+ def _revoke_session(
1192
+ self,
1193
+ session: Session,
1194
+ owner: SessionOperations,
1195
+ refresh_error: VolcanoError | None,
1196
+ *,
1197
+ joined: bool,
1198
+ ) -> None:
1199
+ session_id = session_id_from_access_token(session.access_token)
1200
+ verified = owner.has_verified_pair(session)
1201
+ if session_id is not None and not verified:
1202
+ self._revoke_access_session(
1203
+ session, session_id, refresh_error, joined=joined
1204
+ )
1205
+ return
1206
+ if refresh_error is not None and not verified:
1207
+ raise refresh_error
1208
+ if session.refresh_token is not None:
1209
+ transport = cast("AuthLogoutTransport", self._client._transport)
1210
+ response = invoke(
1211
+ transport.auth_logout,
1212
+ authorization=self._client._anon_token(),
1213
+ refresh_token=session.refresh_token,
1214
+ )
1215
+ else:
1216
+ return
1217
+ response_payload(response, 204)
1218
+
1219
+ def _revoke_access_session(
1220
+ self,
1221
+ session: Session,
1222
+ session_id: str,
1223
+ refresh_error: VolcanoError | None,
1224
+ *,
1225
+ joined: bool,
1226
+ ) -> None:
1227
+ transport = cast("AuthDeleteMySessionTransport", self._client._transport)
1228
+ response = invoke(
1229
+ transport.auth_delete_my_session,
1230
+ authorization=session.access_token,
1231
+ session_id=session_id,
1232
+ )
1233
+ if (
1234
+ response.status_code == HTTPStatus.UNAUTHORIZED
1235
+ and session.refresh_token is not None
1236
+ ):
1237
+ if refresh_error is not None:
1238
+ raise refresh_error
1239
+ if joined:
1240
+ response_payload(response, 204)
1241
+ return
1242
+ refreshed = self._request_refreshed_session(session.refresh_token)
1243
+ validate_refresh_identity(session, refreshed)
1244
+ response = invoke(
1245
+ transport.auth_delete_my_session,
1246
+ authorization=refreshed.access_token,
1247
+ session_id=session_id,
1248
+ )
1249
+ response_payload(response, 204)
volcano_sdk/client.py CHANGED
@@ -5,9 +5,11 @@ from __future__ import annotations
5
5
  import threading
6
6
  from collections import deque
7
7
  from dataclasses import replace
8
- from typing import TYPE_CHECKING
8
+ from typing import TYPE_CHECKING, TypedDict, Unpack
9
9
  from uuid import UUID
10
10
 
11
+ from ._session import validate_refresh_identity
12
+ from ._session_operations import SessionOperations
11
13
  from ._transport import GeneratedTransport, Transport
12
14
  from .auth import Auth
13
15
  from .database import Database
@@ -32,6 +34,39 @@ if TYPE_CHECKING:
32
34
  _NO_ACTIVE_SESSION = "No active session"
33
35
  _NO_SERVICE_KEY = "No service key configured"
34
36
  _PROFILE_USER_MISMATCH = "Profile user does not match the active session"
37
+ _BOOTSTRAP_ACCESS_REQUIRED = "refresh_token requires access_token"
38
+
39
+
40
+ class _BootstrapCredentials(TypedDict, total=False):
41
+ access_token: str | None
42
+ refresh_token: str | None
43
+
44
+
45
+ def _validate_bootstrap_credential(name: str, token: object) -> None:
46
+ if token is not None and (not isinstance(token, str) or not token.strip()):
47
+ message = f"{name} must be a non-empty string"
48
+ raise ValueError(message)
49
+
50
+
51
+ def _bootstrap_session(
52
+ credentials: _BootstrapCredentials,
53
+ ) -> Session | None:
54
+ unknown = credentials.keys() - {"access_token", "refresh_token"}
55
+ if unknown:
56
+ message = f"Unexpected keyword argument: {next(iter(unknown))}"
57
+ raise TypeError(message)
58
+ access_token = credentials.get("access_token")
59
+ refresh_token = credentials.get("refresh_token")
60
+ if access_token is None:
61
+ if refresh_token is not None:
62
+ raise ValueError(_BOOTSTRAP_ACCESS_REQUIRED)
63
+ return None
64
+ for name, token in (
65
+ ("access_token", access_token),
66
+ ("refresh_token", refresh_token),
67
+ ):
68
+ _validate_bootstrap_credential(name, token)
69
+ return Session(access_token=access_token, refresh_token=refresh_token)
35
70
 
36
71
 
37
72
  class _CallbackOutcome:
@@ -65,6 +100,7 @@ class VolcanoClient:
65
100
  timeout: float = 60.0,
66
101
  _transport: Transport | None = None,
67
102
  _realtime_client_factory: CentrifugeFactory | None = None,
103
+ **credentials: Unpack[_BootstrapCredentials],
68
104
  ) -> None:
69
105
  """Create a client for a Volcano project."""
70
106
  self._api_url = api_url.rstrip("/")
@@ -72,8 +108,8 @@ class VolcanoClient:
72
108
  self._service_key = service_key
73
109
  self._session_lock = threading.Lock()
74
110
  self._session_generation = 0
75
- self._session_lineage = 0
76
- self._current_session: Session | None = None
111
+ self._session_lineage = SessionOperations()
112
+ self._current_session = _bootstrap_session(credentials)
77
113
  self._auth_callbacks: dict[int, AuthStateCallback] = {}
78
114
  self._next_auth_callback_id = 0
79
115
  self._auth_notifications: deque[
@@ -146,7 +182,7 @@ class VolcanoClient:
146
182
  with self._session_lock:
147
183
  self._current_session = session
148
184
  self._session_generation += 1
149
- self._session_lineage += 1
185
+ self._session_lineage = SessionOperations()
150
186
  if event is None:
151
187
  return
152
188
  callback_ids = tuple(self._auth_callbacks)
@@ -158,7 +194,7 @@ class VolcanoClient:
158
194
  with self._session_lock:
159
195
  return self._session_generation, self._current_session
160
196
 
161
- def _capture_session_binding(self) -> tuple[int, int, Session | None]:
197
+ def _capture_session_binding(self) -> tuple[int, SessionOperations, Session | None]:
162
198
  with self._session_lock:
163
199
  return (
164
200
  self._session_generation,
@@ -173,15 +209,16 @@ class VolcanoClient:
173
209
  current = self._current_session
174
210
  if generation != self._session_generation or current is None:
175
211
  return False
212
+ user_id = str(user["id"]) if current.user_id is None else current.user_id
176
213
  try:
177
- same_user = UUID(str(user["id"])) == UUID(current.user_id)
214
+ same_user = UUID(str(user["id"])) == UUID(user_id)
178
215
  except ValueError:
179
216
  same_user = False
180
217
  if not same_user:
181
218
  raise AuthenticationError(_PROFILE_USER_MISMATCH)
182
219
  # Profile updates do not replace credentials or invalidate other requests.
183
220
  self._current_session = replace(
184
- current, user={**user, "id": current.user_id}
221
+ current, user_id=user_id, user={**user, "id": user_id}
185
222
  )
186
223
  return True
187
224
 
@@ -196,10 +233,12 @@ class VolcanoClient:
196
233
  with self._session_lock:
197
234
  if generation != self._session_generation:
198
235
  return False
236
+ if event == "TOKEN_REFRESHED":
237
+ validate_refresh_identity(self._current_session, session)
199
238
  self._current_session = session
200
239
  self._session_generation += 1
201
240
  if event != "TOKEN_REFRESHED":
202
- self._session_lineage += 1
241
+ self._session_lineage = SessionOperations(session)
203
242
  callback_ids = tuple(self._auth_callbacks)
204
243
  dispatch = self._enqueue_auth_state_change(callback_ids, event, session)
205
244
  if dispatch:
@@ -210,15 +249,21 @@ class VolcanoClient:
210
249
  self,
211
250
  generation: int,
212
251
  *,
252
+ lineage: SessionOperations | None = None,
213
253
  event: AuthChangeEvent = "SIGNED_OUT",
214
254
  notifications: list[Callable[[], None]] | None = None,
215
255
  ) -> bool:
216
256
  with self._session_lock:
217
- if generation != self._session_generation:
257
+ if lineage is not None:
258
+ if lineage != self._session_lineage:
259
+ return False
260
+ elif generation != self._session_generation:
218
261
  return False
262
+ if self._current_session is None:
263
+ return True
219
264
  self._current_session = None
265
+ self._session_lineage.clear_local_credentials()
220
266
  self._session_generation += 1
221
- self._session_lineage += 1
222
267
  callback_ids = tuple(self._auth_callbacks)
223
268
  dispatch = self._enqueue_auth_state_change(callback_ids, event, None)
224
269
  if dispatch:
volcano_sdk/locks.py CHANGED
@@ -5,11 +5,12 @@ from __future__ import annotations
5
5
  from contextlib import contextmanager, suppress
6
6
  from datetime import datetime
7
7
  from typing import TYPE_CHECKING, Protocol, cast
8
- from uuid import uuid4
8
+ from uuid import UUID, uuid4
9
9
 
10
10
  from ._lock_guard import LockGuard, _lease_now
11
11
  from ._lock_worker import LockRenewer
12
12
  from ._transport import Transport, invoke, response_payload
13
+ from .errors import ServerError, TransportError
13
14
  from .models import LockLease, LockState
14
15
 
15
16
  if TYPE_CHECKING:
@@ -37,6 +38,7 @@ class LockGetTransport(Protocol):
37
38
  *,
38
39
  authorization: str,
39
40
  key: str,
41
+ request_id: str | None = None,
40
42
  ) -> object:
41
43
  """Get one project-scoped lock."""
42
44
  ...
@@ -52,6 +54,7 @@ class LockRenewTransport(Protocol):
52
54
  key: str,
53
55
  ttl: int,
54
56
  token: str,
57
+ request_id: str | None = None,
55
58
  ) -> object:
56
59
  """Renew one project-scoped lock."""
57
60
  ...
@@ -65,6 +68,7 @@ class LockForceReleaseTransport(Protocol):
65
68
  *,
66
69
  authorization: str,
67
70
  key: str,
71
+ request_id: str | None = None,
68
72
  ) -> object:
69
73
  """Force release one project-scoped lock."""
70
74
  ...
@@ -85,6 +89,17 @@ def _validate_ttl(ttl: object) -> None:
85
89
  raise ValueError(_INVALID_LOCK_TTL)
86
90
 
87
91
 
92
+ def _request_uuid(value: str | None, name: str) -> str:
93
+ if value is None:
94
+ return str(uuid4())
95
+ try:
96
+ UUID(value)
97
+ except (AttributeError, ValueError) as error:
98
+ message = f"{name} must be a UUID string"
99
+ raise ValueError(message) from error
100
+ return value
101
+
102
+
88
103
  class Locks:
89
104
  """Acquire and release project-scoped distributed locks."""
90
105
 
@@ -92,13 +107,14 @@ class Locks:
92
107
  """Create a lock facade backed by a client."""
93
108
  self._client = client
94
109
 
95
- def get(self, key: str) -> LockState:
110
+ def get(self, key: str, *, request_id: str | None = None) -> LockState:
96
111
  """Return the current state of a project-scoped lock."""
97
112
  transport = cast("LockGetTransport", self._client._transport)
98
113
  response = invoke(
99
114
  transport.get_project_lock,
100
115
  authorization=self._client._service_token(),
101
116
  key=key,
117
+ request_id=_request_uuid(request_id, "request_id"),
102
118
  )
103
119
  payload = response_payload(response, 200)
104
120
  return LockState(
@@ -107,26 +123,64 @@ class Locks:
107
123
  fencing_token=payload.get("fencing_token"),
108
124
  )
109
125
 
110
- def acquire(self, key: str, *, ttl: int) -> LockLease:
111
- """Acquire a lock lease for the requested number of seconds."""
126
+ def acquire(
127
+ self,
128
+ key: str,
129
+ *,
130
+ ttl: int,
131
+ token: str | None = None,
132
+ request_id: str | None = None,
133
+ ) -> LockLease:
134
+ """Acquire with one bounded retry using the same ownership token."""
135
+ lease, _ = self._acquire_with_start(
136
+ key, ttl=ttl, token=token, request_id=request_id
137
+ )
138
+ return lease
139
+
140
+ def _acquire_with_start(
141
+ self,
142
+ key: str,
143
+ *,
144
+ ttl: int,
145
+ token: str | None,
146
+ request_id: str | None,
147
+ ) -> tuple[LockLease, float]:
112
148
  _validate_ttl(ttl)
113
- token = str(uuid4())
114
- response = invoke(
115
- self._client._transport.acquire_project_lock,
116
- authorization=self._client._service_token(),
149
+ token = _request_uuid(token, "token")
150
+ request_id = _request_uuid(request_id, "request_id")
151
+ authorization = self._client._service_token()
152
+ started_at = _lease_now()
153
+ try:
154
+ payload = self._acquire_payload(key, ttl, token, request_id, authorization)
155
+ except (TransportError, ServerError) as error:
156
+ if error.status not in (None, 503):
157
+ raise
158
+ started_at = _lease_now()
159
+ payload = self._acquire_payload(key, ttl, token, request_id, authorization)
160
+ lease = LockLease(
117
161
  key=key,
118
- ttl=ttl,
119
162
  token=token,
163
+ expires_at=_parse_datetime(payload.get("expires_at")),
164
+ fencing_token=cast("int | None", payload.get("fencing_token")),
120
165
  )
121
- payload = response_payload(response, 201)
122
- return LockLease(
166
+ return lease, started_at
167
+
168
+ def _acquire_payload(
169
+ self, key: str, ttl: int, token: str, request_id: str, authorization: str
170
+ ) -> dict[str, object]:
171
+ response = invoke(
172
+ self._client._transport.acquire_project_lock,
173
+ authorization=authorization,
123
174
  key=key,
175
+ ttl=ttl,
124
176
  token=token,
125
- expires_at=_parse_datetime(payload.get("expires_at")),
126
- fencing_token=payload.get("fencing_token"),
177
+ request_id=request_id,
127
178
  )
179
+ return cast("dict[str, object]", response_payload(response, 201))
128
180
 
129
- def renew(self, key: str, lease: LockLease, *, ttl: int) -> LockLease:
181
+ def renew(
182
+ self, key: str, lease: LockLease, *, ttl: int, request_id: str | None = None
183
+ ) -> LockLease:
130
184
  """Renew a lock lease and return its immutable replacement."""
131
185
  _validate_ttl(ttl)
132
186
  transport = cast("LockRenewTransport", self._client._transport)
@@ -134,6 +188,7 @@ class Locks:
134
188
  transport.renew_project_lock,
135
189
  authorization=self._client._service_token(),
136
190
  key=key,
191
+ request_id=_request_uuid(request_id, "request_id"),
137
192
  ttl=ttl,
138
193
  token=lease.token,
139
194
  )
@@ -145,35 +200,47 @@ class Locks:
145
200
  fencing_token=payload.get("fencing_token"),
146
201
  )
147
202
 
148
- def release(self, key: str, lease: LockLease) -> None:
203
+ def release(
204
+ self, key: str, lease: LockLease, *, request_id: str | None = None
205
+ ) -> None:
149
206
  """Release a lock lease."""
150
207
  response = invoke(
151
208
  self._client._transport.release_project_lock,
152
209
  authorization=self._client._service_token(),
153
210
  key=key,
211
+ request_id=_request_uuid(request_id, "request_id"),
154
212
  token=lease.token,
155
213
  )
156
214
  response_payload(response, 204)
157
215
 
158
- def force_release(self, key: str) -> None:
216
+ def force_release(self, key: str, *, request_id: str | None = None) -> None:
159
217
  """Release a lock regardless of which token owns it."""
160
218
  transport = cast("LockForceReleaseTransport", self._client._transport)
161
219
  response = invoke(
162
220
  transport.force_release_project_lock,
163
221
  authorization=self._client._service_token(),
164
222
  key=key,
223
+ request_id=_request_uuid(request_id, "request_id"),
165
224
  )
166
225
  response_payload(response, 204)
167
226
 
168
227
  @contextmanager
169
- def with_lock(self, key: str, *, ttl: int) -> Generator[LockGuard, None, None]:
228
+ def with_lock(
229
+ self,
230
+ key: str,
231
+ *,
232
+ ttl: int,
233
+ token: str | None = None,
234
+ request_id: str | None = None,
235
+ ) -> Generator[LockGuard, None, None]:
170
236
  """Hold and automatically renew a lock for the context's lifetime."""
171
237
  _validate_ttl(ttl)
172
238
  started_at = _lease_now()
239
+ lease, lease_started_at = self._acquire_with_start(
240
+ key, ttl=ttl, token=token, request_id=request_id
241
+ )
173
242
  guard = LockGuard(
174
- self.acquire(key, ttl=ttl),
175
- ttl=ttl,
176
- started_at=started_at,
243
+ lease, ttl=ttl, started_at=started_at, lease_started_at=lease_started_at
177
244
  )
178
245
  renewer = LockRenewer(self, key, guard, ttl=ttl)
179
246
  renewer_started = False
volcano_sdk/models.py CHANGED
@@ -84,11 +84,11 @@ class User:
84
84
 
85
85
  @dataclass(frozen=True, slots=True)
86
86
  class Session:
87
- """Authenticated user session."""
87
+ """Local credentials with optional refresh credentials and user identity."""
88
88
 
89
89
  access_token: str
90
- refresh_token: str
91
- user_id: str
90
+ refresh_token: str | None = None
91
+ user_id: str | None = None
92
92
  user: Mapping[str, JSONValue] | None = field(default=None, repr=False, hash=False)
93
93
 
94
94
  def __post_init__(self) -> None:
volcano_sdk/realtime.py CHANGED
@@ -25,6 +25,7 @@ from ._transport import (
25
25
  from .models import JSONValue, _freeze_json
26
26
 
27
27
  if TYPE_CHECKING:
28
+ from ._session_operations import SessionOperations
28
29
  from .models import Session
29
30
 
30
31
  MessageCallback = Callable[[Any], Any]
@@ -205,7 +206,7 @@ def _postgres_fetch_config(
205
206
 
206
207
  @dataclass(frozen=True, slots=True)
207
208
  class _PostgresDeliveryIdentity:
208
- session_lineage: int
209
+ session_lineage: SessionOperations | None
209
210
  subscription_epoch: int
210
211
 
211
212
 
@@ -282,7 +283,9 @@ class RealtimeContext(Protocol):
282
283
 
283
284
  def _session_token(self) -> str: ...
284
285
 
285
- def _capture_session_binding(self) -> tuple[int, int, Session | None]: ...
286
+ def _capture_session_binding(
287
+ self,
288
+ ) -> tuple[int, SessionOperations, Session | None]: ...
286
289
 
287
290
 
288
291
  class CentrifugeSubscription(Protocol):
@@ -608,7 +611,7 @@ class Channel:
608
611
  self._callback_task: asyncio.Task[None] | None = None
609
612
  self._pending_presence_sync: Any = NO_PENDING_CALLBACK
610
613
  self._postgres_epoch = 0
611
- self._postgres_session_lineage = 0
614
+ self._postgres_session_lineage: SessionOperations | None = None
612
615
  self._postgres_lock = asyncio.Lock()
613
616
  self._postgres_worker: PostgresFetchWorker[_PostgresDelivery] | None = None
614
617
  self._postgres_filters: dict[
@@ -732,11 +735,12 @@ class Channel:
732
735
  self,
733
736
  identity: _PostgresDeliveryIdentity,
734
737
  ) -> bool:
735
- _generation, lineage, _session = (
738
+ _generation, lineage, session = (
736
739
  self._realtime._client_context._capture_session_binding()
737
740
  )
738
741
  return (
739
742
  self._subscribed
743
+ and session is not None
740
744
  and identity.subscription_epoch == self._postgres_epoch
741
745
  and identity.session_lineage == lineage
742
746
  )
@@ -1161,7 +1165,7 @@ class Realtime:
1161
1165
  self._api_url = api_url
1162
1166
  self._client_factory = client_factory
1163
1167
  self._connection: _VolcanoCentrifugeConnection | None = None
1164
- self._connection_session_lineage: int | None = None
1168
+ self._connection_session_lineage: SessionOperations | None = None
1165
1169
  self._connection_access_token: str | None = None
1166
1170
  self._connection_lock = asyncio.Lock()
1167
1171
  self._channels: dict[str, Channel] = {}
@@ -1401,7 +1405,7 @@ class Realtime:
1401
1405
  self._connection_access_token = session.access_token
1402
1406
  return session.access_token
1403
1407
 
1404
- def _session_for_lineage(self, expected_lineage: int) -> Session:
1408
+ def _session_for_lineage(self, expected_lineage: SessionOperations) -> Session:
1405
1409
  _generation, lineage, session = self._client_context._capture_session_binding()
1406
1410
  if session is None:
1407
1411
  raise RuntimeError(NO_ACTIVE_SESSION)
@@ -1409,7 +1413,7 @@ class Realtime:
1409
1413
  raise RuntimeError(CONNECTION_SESSION_CHANGED)
1410
1414
  return session
1411
1415
 
1412
- def _connection_lineage(self) -> int:
1416
+ def _connection_lineage(self) -> SessionOperations:
1413
1417
  lineage = self._connection_session_lineage
1414
1418
  if lineage is None:
1415
1419
  raise RuntimeError(CONNECTION_SESSION_UNAVAILABLE)
volcano_sdk/storage.py CHANGED
@@ -35,6 +35,7 @@ from .models import (
35
35
  )
36
36
 
37
37
  if TYPE_CHECKING:
38
+ from ._session_operations import SessionOperations
38
39
  from .auth import Auth
39
40
 
40
41
  _INVALID_STORAGE_PAGE = "Expected a complete storage page"
@@ -292,7 +293,9 @@ class StorageContext(Protocol):
292
293
 
293
294
  def _session_token(self) -> str: ...
294
295
 
295
- def _capture_session_binding(self) -> tuple[int, int, Session | None]: ...
296
+ def _capture_session_binding(
297
+ self,
298
+ ) -> tuple[int, SessionOperations, Session | None]: ...
296
299
 
297
300
 
298
301
  class StorageListTransport(Protocol):
@@ -707,7 +710,9 @@ class StorageBucket:
707
710
  self._remove_path(path, binding)
708
711
  return path_list
709
712
 
710
- def _remove_path(self, path: str, binding: tuple[int, int, Session | None]) -> None:
713
+ def _remove_path(
714
+ self, path: str, binding: tuple[int, SessionOperations, Session | None]
715
+ ) -> None:
711
716
  transport = cast("StorageDeleteTransport", self._client._transport)
712
717
  response = self._client.auth._session_request(
713
718
  lambda token: invoke(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: volcano-sdk-python
3
- Version: 0.7.3
3
+ Version: 0.9.0
4
4
  Summary: Official Python SDK for Volcano
5
5
  Project-URL: Homepage, https://volcano.dev
6
6
  Project-URL: Repository, https://github.com/Kong/volcano-sdk-python
@@ -182,6 +182,13 @@ original error. `on_progress` runs after each successful part with cumulative
182
182
  uploaded bytes and the total size.
183
183
  `upload_part()` returns immutable part metadata and can safely retry the same
184
184
  part number to replace that part.
185
+ Acquisition accepts caller-owned UUID `token` and `request_id` values and retries
186
+ an ambiguous transport failure or HTTP 503 once with the same request and credential.
187
+ Retain those IDs to recover an uncertain acquisition. Other lock methods accept
188
+ `request_id`; block-scoped helpers forward initial IDs only to acquisition.
189
+ See the [lock guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/locks.md)
190
+ for examples and fencing requirements.
191
+
185
192
  `locks.get()` returns immutable lock availability, expiry, and fencing-token
186
193
  state without acquiring the lock.
187
194
  Lock acquisition and renewal require an integer TTL from 5 seconds through 90 days.
@@ -586,6 +593,14 @@ Success returns `None`. The reset revokes the recovered account's existing sessi
586
593
  sign it in. The client keeps any unrelated local session unchanged; sign in with the new password
587
594
  when the reset flow completes.
588
595
 
596
+ To start with only a supplied user access token, pass `access_token` to
597
+ `VolcanoClient`. Construction makes no request and leaves `refresh_token`,
598
+ `user_id`, and `user` as `None` until supplied or validated by the server.
599
+ `get_user()` validates and caches the profile without changing credentials.
600
+ Without a refresh token, `refresh_session()` raises `AuthenticationError` and
601
+ `sign_out()` revokes the server session using the access token and clears local state.
602
+ Supply `refresh_token` with `access_token` to enable refresh. See the [token bootstrap example](https://github.com/Kong/volcano-sdk-python/blob/main/docs/README.md#use-a-supplied-access-token).
603
+
589
604
  Copy a complete native session into another client's memory:
590
605
 
591
606
  ```python
@@ -643,9 +658,13 @@ client.auth.sign_out()
643
658
  assert client.auth.get_session() is None
644
659
  ```
645
660
 
661
+ Sign-out uses the refresh token directly when the SDK received both credentials together from
662
+ sign-in or a validated refresh. Supplied credentials use the access-token session; on HTTP 401,
663
+ the SDK can refresh once and revoke that same session without adopting the renewed credentials.
646
664
  Calling `sign_out()` without a session succeeds without a request. A revocation failure is raised
647
- after the captured local session is cleared. A newer session established while sign-out is in
648
- flight remains current.
665
+ after the captured local session is cleared. Sign-out waits for an already-running refresh and uses its validated credentials.
666
+ Later refresh attempts raise `SessionChangedError` without a request. Concurrent sign-out calls
667
+ share one result. A separate sign-in or adoption remains current.
649
668
 
650
669
  Realtime is async. Channels wrap `centrifuge-python`; the underlying client and
651
670
  subscription objects are not part of the public API.
@@ -1,22 +1,24 @@
1
1
  volcano_sdk/__init__.py,sha256=rPSFMLjFbxeQvSRkL1Ee8MsNWArIhs8fgNFgtDuMNzI,1954
2
2
  volcano_sdk/_function_resolution.py,sha256=_gC3LY6x71NtJc_o9d9eq3N27Z-T_Un3p2-XRu2-Cqs,5088
3
- volcano_sdk/_lock_guard.py,sha256=EZzcwOPTFTCNUrIWIQ0-nMg9tm_nPkaYkcocgeLbu94,5572
3
+ volcano_sdk/_lock_guard.py,sha256=VhHTgBc4e5EDj8j15vVIhdBOTp6fvD628x3hVBeF39o,5794
4
4
  volcano_sdk/_lock_renewer.py,sha256=tFo1ZeRiPKB5VSbW61uoMpNR67ZDYyMEe-dCjpAM3As,841
5
5
  volcano_sdk/_lock_worker.py,sha256=QhSmwR0WcwgT-7ueJMlbCogLqOakQ5eRAtyWV74fgV0,3057
6
6
  volcano_sdk/_realtime_fetch_worker.py,sha256=zQTgFF4cIUBeAL4J2OpWAlz_sIynv2BLfu588EogAdo,9269
7
- volcano_sdk/_transport.py,sha256=PzdO7uhqvgI2L-Ff0rwZCWeF7yuH_Ty-OvZx0F7dMpo,57509
8
- volcano_sdk/auth.py,sha256=Z5UCrqdvmHBS6n6ZleXya04hYV94rH4SRV1GW2yimXo,43185
9
- volcano_sdk/client.py,sha256=KutjBUOEJyK73OibKilrcWazpWeXKEZYUehSAH8qxUE,10366
7
+ volcano_sdk/_session.py,sha256=LYCv2NCDlL5Slh2YLEG8VQ8z-kDaKDYWW4vGdMvUf1U,2474
8
+ volcano_sdk/_session_operations.py,sha256=1vI9gtwjxG2eUGT-nIYnMEgRldJqgIj2lRLukBbG64E,5518
9
+ volcano_sdk/_transport.py,sha256=AFd47C3X1WAHwq3f9V2ux0WNzwGIn4NBxrwgaIN-xl0,58296
10
+ volcano_sdk/auth.py,sha256=7eCaA9DFsJYI4KHBXG6UXNM8QZLpvedNbCwXTfozgMI,46518
11
+ volcano_sdk/client.py,sha256=CJJnf_iTcXnyY2Y57LXJVAn39mTUsepblUg9op-Thzo,12283
10
12
  volcano_sdk/connection_string.py,sha256=LlTZigsrCF4IfAqQ8SDTWJv35hnnzNu1MfeVv5JW-Kk,2331
11
13
  volcano_sdk/database.py,sha256=p_5OaqlBjKAtIUfKQCflXM_CGqokrVmpOMsp6KHiJ8A,9493
12
14
  volcano_sdk/errors.py,sha256=ffs66FIN_jhyh5SMj7KdXb-zVV3DSgVK6xJEPNA5h9Y,1613
13
15
  volcano_sdk/functions.py,sha256=zDeAKyCieNnI6wzb9iZMlg9_cbjaYWXYbXHb7jzCle4,10126
14
- volcano_sdk/locks.py,sha256=iWMhdbZQDw44ZNwENaZWu7WelakjpHhjYzKzufD7SNU,6932
16
+ volcano_sdk/locks.py,sha256=eSkidfHQ_wml5-1LT2hBtHxeALP6E1cGYK0FzHjbrNk,9241
15
17
  volcano_sdk/logs.py,sha256=lxORRQBvsnJ0BdMau19MXrIeig5_mEucQSyJ4F1CZxU,4666
16
- volcano_sdk/models.py,sha256=43zM6LQ9eJ3Hn6q9PiVSozSmOzPSr4m4OL8G8KVb-gg,8619
18
+ volcano_sdk/models.py,sha256=n5EdzkWzmOstXsuFlvfGOILRemQAqG2P4VCLMelW8SA,8690
17
19
  volcano_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
18
- volcano_sdk/realtime.py,sha256=VkxZyrC7PlddnCSv8wBBLVg4nPWjpyuGo1FoBWhpf6M,59295
19
- volcano_sdk/storage.py,sha256=1bAgzGO-dhUCVG8B-5x40jenHeh68Id2csIJF57KSGk,26126
20
+ volcano_sdk/realtime.py,sha256=3p5ueLCeveaR4_ItqlLv-exkMCODqxDQNHj1aYB_YE4,59506
21
+ volcano_sdk/storage.py,sha256=p16OV5ADI6JjMAJ7gx2gcmwk9t8--YiIZcOSLQLVlDY,26238
20
22
  volcano_sdk/_generated/__init__.py,sha256=ZlB8wMYdN6EjpUS5W_lX3jsxq9G1kOcmUr-j3Fccfn8,164
21
23
  volcano_sdk/_generated/client.py,sha256=GDzw6T2uRmPlUb1DImpBGxwy0dNbhmBknfvvIQW0GuU,12387
22
24
  volcano_sdk/_generated/errors.py,sha256=trp-p5qn1_JLRxGZhdHtICaNPaCrcDCe4TgIihBravk,546
@@ -773,7 +775,7 @@ volcano_sdk/_generated/models/usage_data_point.py,sha256=d4U08JbVTiIMM_l2BmRILL1
773
775
  volcano_sdk/_generated/models/variable.py,sha256=tvGDOMv77gW6j5skufnfrlNAhzCzA1F7nuPCQWx79yY,5926
774
776
  volcano_sdk/_generated/models/variable_deploy_source.py,sha256=G_aCiFHzQx8WhJFs0GvYZEFclfTrfkDvd3kT1e3vty8,463
775
777
  volcano_sdk/_generated/models/variable_status.py,sha256=THSsKBSiAYG4kAtBzGA4TZqxjgx8hdL0Q4qCdZfOj00,391
776
- volcano_sdk_python-0.7.3.dist-info/METADATA,sha256=0lDdNzghgpaKsYFFqMZZrWCn0rUt9nqdaGWSg0FhU_U,33557
777
- volcano_sdk_python-0.7.3.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
778
- volcano_sdk_python-0.7.3.dist-info/licenses/LICENSE,sha256=AIHJy-5MFqM8fHfuGFTTb9a8rvJGypcVgN4aZ-7XTWw,11387
779
- volcano_sdk_python-0.7.3.dist-info/RECORD,,
778
+ volcano_sdk_python-0.9.0.dist-info/METADATA,sha256=RKoQTUaTeDCZvyej7-w-j6bQIizIf2DYEN-B5dGnCt8,35125
779
+ volcano_sdk_python-0.9.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
780
+ volcano_sdk_python-0.9.0.dist-info/licenses/LICENSE,sha256=AIHJy-5MFqM8fHfuGFTTb9a8rvJGypcVgN4aZ-7XTWw,11387
781
+ volcano_sdk_python-0.9.0.dist-info/RECORD,,