stackit-core 0.3.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: stackit-core
3
- Version: 0.3.0
3
+ Version: v0.4.0
4
4
  Summary: Core functionality for the STACKIT SDK for Python
5
5
  Project-URL: Homepage, https://github.com/stackitcloud/stackit-sdk-python
6
6
  Project-URL: Issues, https://github.com/stackitcloud/stackit-sdk-python/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stackit-core"
3
- version = "v0.3.0"
3
+ version = "v0.4.0"
4
4
  description = "Core functionality for the STACKIT SDK for Python"
5
5
  authors = [{ name = "STACKIT Developer Tools", email = "developer-tools@stackit.cloud" }]
6
6
  requires-python = ">=3.10,<4.0"
@@ -54,6 +54,8 @@ class KeyAuth(AuthBase):
54
54
  executor: ThreadPoolExecutor
55
55
  refresh_future: Optional[futures.Future]
56
56
  service_account_key: ServiceAccountKey
57
+ _stop: threading.Event
58
+ _refresh_thread: Optional[threading.Thread]
57
59
 
58
60
  def __init__(
59
61
  self,
@@ -66,6 +68,8 @@ class KeyAuth(AuthBase):
66
68
  self.executor = ThreadPoolExecutor(max_workers=1)
67
69
  self.access_token = None
68
70
  self.refresh_future = None
71
+ self._stop = threading.Event()
72
+ self._refresh_thread = None
69
73
  self.__create_initial_token()
70
74
  self.__fetch_token_from_endpoint()
71
75
  self.__start_token_refresh_task()
@@ -79,9 +83,37 @@ class KeyAuth(AuthBase):
79
83
  if self.__is_token_expired(self.access_token):
80
84
  if self.refresh_future is None or self.refresh_future.done():
81
85
  self.refresh_future = self.executor.submit(self.__refresh_token)
86
+ refresh_future = self.refresh_future
87
+ else:
88
+ refresh_future = None
89
+
90
+ # Do not hold the lock while waiting. The refresh worker acquires it when
91
+ # updating the access token.
92
+ if refresh_future is not None:
93
+ refresh_future.result()
94
+
95
+ with self.lock:
82
96
  r.headers["Authorization"] = f"Bearer {self.access_token}"
83
97
  return r
84
98
 
99
+ def close(self) -> None:
100
+ """Stop the background token refresh and release its worker.
101
+
102
+ Call this when the instance is no longer needed, for example after
103
+ replacing it during key rotation in a long-running process. Without it
104
+ the refresh thread keeps running and keeps requesting tokens. When it
105
+ returns, no refresh is running and none will start. Safe to call more
106
+ than once.
107
+ """
108
+ # Set under the lock so the refresh loop cannot submit after shutdown.
109
+ with self.lock:
110
+ self._stop.set()
111
+ atexit.unregister(self.__shutdown)
112
+ thread = self._refresh_thread
113
+ if thread is not None and thread is not threading.current_thread():
114
+ thread.join()
115
+ self.executor.shutdown(wait=True, cancel_futures=True)
116
+
85
117
  def __create_initial_token(self) -> None:
86
118
  payload = {
87
119
  "iss": self.service_account_key.credentials.issuer,
@@ -108,16 +140,18 @@ class KeyAuth(AuthBase):
108
140
  response = requests.post(self.token_endpoint, data=body, timeout=self.timeout)
109
141
  response.raise_for_status()
110
142
  response_json = response.json()
111
- self.access_token = response_json["access_token"]
112
- self.refresh_token = response_json["refresh_token"]
143
+ with self.lock:
144
+ self.access_token = response_json["access_token"]
145
+ self.refresh_token = response_json["refresh_token"]
113
146
  except requests.RequestException as e:
114
147
  raise requests.RequestException("Initial token fetch failed") from e
115
148
 
116
149
  def __start_token_refresh_task(self):
117
150
  def token_refresh_task():
118
- while True:
119
- time.sleep(self.TOKEN_EXPIRY_CHECK_INTERVAL.total_seconds())
151
+ while not self._stop.wait(self.TOKEN_EXPIRY_CHECK_INTERVAL.total_seconds()):
120
152
  with self.lock:
153
+ if self._stop.is_set():
154
+ return
121
155
  if self.refresh_future is not None and self.refresh_future.done():
122
156
  self.refresh_future.result()
123
157
  if self.__is_token_expired(self.access_token) and (
@@ -128,43 +162,48 @@ class KeyAuth(AuthBase):
128
162
  thread = threading.Thread(target=token_refresh_task)
129
163
  thread.daemon = True
130
164
  thread.start()
165
+ self._refresh_thread = thread
131
166
 
132
167
  def __refresh_token(self):
133
- if self.__is_token_expired(self.refresh_token):
168
+ with self.lock:
169
+ refresh_token = self.refresh_token
170
+
171
+ if self.__is_token_expired(refresh_token):
134
172
  self.__create_initial_token()
135
173
  self.__fetch_token_from_endpoint()
136
174
  return
137
175
 
138
176
  body = {
139
177
  "grant_type": "refresh_token",
140
- "refresh_token": self.refresh_token,
178
+ "refresh_token": refresh_token,
141
179
  }
142
180
 
143
181
  last_exception = None
144
182
  for _ in range(self.MAX_REFRESH_RETRIES):
183
+ if self._stop.is_set():
184
+ return
145
185
  try:
146
186
  response = requests.post(self.token_endpoint, data=body, timeout=self.timeout)
147
187
  response.raise_for_status()
148
188
  response_data = response.json()
149
189
  new_token = response_data.get("access_token")
150
- self.access_token = new_token
190
+ with self.lock:
191
+ self.access_token = new_token
151
192
  return
152
193
  except requests.RequestException as e:
153
194
  last_exception = e
154
195
 
155
196
  raise requests.RequestException("Token refresh failed after retries") from last_exception
156
197
 
157
- def __is_token_expired(self, token: str) -> bool:
198
+ def __is_token_expired(self, token: Optional[str]) -> bool:
158
199
  try:
159
200
  decoded_token = jwt.decode(token, options={"verify_signature": False})
160
201
  exp = decoded_token.get("exp")
161
- if exp:
162
- return time.time() > (exp + self.EXPIRATION_LEEWAY.total_seconds())
163
- except jwt.ExpiredSignatureError:
164
- return True
165
- except jwt.DecodeError:
202
+ if exp is None:
203
+ return True
204
+ return time.time() > (float(exp) - self.EXPIRATION_LEEWAY.total_seconds())
205
+ except (jwt.InvalidTokenError, TypeError, ValueError):
166
206
  return True
167
- return False
168
207
 
169
208
  def __shutdown(self):
170
209
  self.executor.shutdown(wait=False)
File without changes
File without changes
File without changes
File without changes