Skip to content

Commit 52316b1

Browse files
committed
feat(core): add KeyAuth.close() to stop the token refresh thread
Each KeyAuth started a daemon thread that refreshed tokens forever, with no way to stop it, so a discarded instance kept running and requesting tokens. close() stops the loop, shuts down the refresh executor and removes the atexit hook. It is safe to call more than once.
1 parent 6050f28 commit 52316b1

6 files changed

Lines changed: 89 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
## Release (2026-MM-DD)
22

33
- `core`:
4+
- [v0.4.0](core/CHANGELOG.md#v040)
5+
- **Feature:** Add `KeyAuth.close()` to stop the background token refresh thread and its worker, so a discarded instance no longer keeps running and requesting tokens
46
- [v0.3.1](core/CHANGELOG.md#v031)
57
- **Fix:** Refresh access tokens five minutes before expiration instead of after, and wait for in-progress refreshes before authorizing requests
68
- `alb`:

‎core/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## v0.4.0
2+
- **Feature:** Add `KeyAuth.close()` to stop the background token refresh thread and its worker, so a discarded instance (for example after key rotation) no longer keeps running and requesting tokens
3+
14
## v0.3.1
25
- **Fix:** Refresh access tokens five minutes before expiration instead of after, and wait for in-progress refreshes before authorizing requests
36

‎core/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "stackit-core"
3-
version = "v0.3.1"
3+
version = "v0.4.0"
44
description = "Core functionality for the STACKIT SDK for Python"
55
authors = [{ name = "STACKIT Developer Tools", email = "developer-tools@stackit.cloud" }]
66
requires-python = ">=3.10,<4.0"

‎core/src/stackit/core/auth_methods/key_auth.py‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class KeyAuth(AuthBase):
5454
executor: ThreadPoolExecutor
5555
refresh_future: Optional[futures.Future]
5656
service_account_key: ServiceAccountKey
57+
_stop: threading.Event
58+
_refresh_thread: Optional[threading.Thread]
5759

5860
def __init__(
5961
self,
@@ -66,6 +68,8 @@ def __init__(
6668
self.executor = ThreadPoolExecutor(max_workers=1)
6769
self.access_token = None
6870
self.refresh_future = None
71+
self._stop = threading.Event()
72+
self._refresh_thread = None
6973
self.__create_initial_token()
7074
self.__fetch_token_from_endpoint()
7175
self.__start_token_refresh_task()
@@ -92,6 +96,21 @@ def __call__(self, r: Request) -> Request:
9296
r.headers["Authorization"] = f"Bearer {self.access_token}"
9397
return r
9498

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. Safe to
105+
call more than once.
106+
"""
107+
self._stop.set()
108+
self.executor.shutdown(wait=False, cancel_futures=True)
109+
atexit.unregister(self.__shutdown)
110+
thread = self._refresh_thread
111+
if thread is not None and thread is not threading.current_thread():
112+
thread.join()
113+
95114
def __create_initial_token(self) -> None:
96115
payload = {
97116
"iss": self.service_account_key.credentials.issuer,
@@ -126,8 +145,7 @@ def __fetch_token_from_endpoint(self) -> None:
126145

127146
def __start_token_refresh_task(self):
128147
def token_refresh_task():
129-
while True:
130-
time.sleep(self.TOKEN_EXPIRY_CHECK_INTERVAL.total_seconds())
148+
while not self._stop.wait(self.TOKEN_EXPIRY_CHECK_INTERVAL.total_seconds()):
131149
with self.lock:
132150
if self.refresh_future is not None and self.refresh_future.done():
133151
self.refresh_future.result()
@@ -139,6 +157,7 @@ def token_refresh_task():
139157
thread = threading.Thread(target=token_refresh_task)
140158
thread.daemon = True
141159
thread.start()
160+
self._refresh_thread = thread
142161

143162
def __refresh_token(self):
144163
with self.lock:

‎core/tests/core/test_auth.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
from pathlib import Path, PurePath
33
from threading import Event, Lock, Thread
44

5+
import time
6+
from datetime import timedelta
7+
58
import pytest
69
import json
710
import jwt
@@ -382,3 +385,61 @@ def result(self):
382385

383386
assert not call_thread.is_alive()
384387
assert request.headers["Authorization"] == f"Bearer {fresh_token}"
388+
389+
390+
class TestKeyAuthClose:
391+
"""A discarded KeyAuth must be able to stop its background token refresh."""
392+
393+
@staticmethod
394+
def _make_auth(service_account_key_file_json, mock_post, access_token):
395+
service_account_key = ServiceAccountKey.model_validate_json(service_account_key_file_json)
396+
service_account_key.credentials.private_key = "test-private-key"
397+
response = Mock()
398+
response.raise_for_status.return_value = None
399+
response.json.return_value = {
400+
"access_token": access_token,
401+
"refresh_token": jwt.encode({"exp": 4102444800}, "secret", algorithm="HS256"),
402+
}
403+
mock_post.return_value = response
404+
return KeyAuth(service_account_key)
405+
406+
def test_close_stops_the_refresh_thread_and_further_token_requests(self, service_account_key_file_json):
407+
def set_initial_token(auth):
408+
auth.initial_token = "test-initial-token"
409+
410+
expired = jwt.encode({"exp": 1}, "secret", algorithm="HS256")
411+
with (
412+
patch.object(KeyAuth, "_KeyAuth__create_initial_token", new=set_initial_token),
413+
patch.object(KeyAuth, "TOKEN_EXPIRY_CHECK_INTERVAL", timedelta(milliseconds=10)),
414+
patch("requests.post") as mock_post,
415+
):
416+
# An access token that is always expired keeps the refresh loop busy.
417+
auth = self._make_auth(service_account_key_file_json, mock_post, expired)
418+
thread = auth._refresh_thread
419+
assert thread is not None and thread.is_alive()
420+
421+
auth.close()
422+
423+
assert not thread.is_alive()
424+
requests_after_close = mock_post.call_count
425+
time.sleep(0.1) # ten refresh intervals
426+
assert mock_post.call_count == requests_after_close
427+
428+
def test_close_is_idempotent(self, service_account_key_file_json):
429+
def set_initial_token(auth):
430+
auth.initial_token = "test-initial-token"
431+
432+
valid = jwt.encode({"exp": 4102444800}, "secret", algorithm="HS256")
433+
with (
434+
patch.object(KeyAuth, "_KeyAuth__create_initial_token", new=set_initial_token),
435+
patch("requests.post") as mock_post,
436+
patch("stackit.core.auth_methods.key_auth.atexit") as mock_atexit,
437+
):
438+
auth = self._make_auth(service_account_key_file_json, mock_post, valid)
439+
440+
auth.close()
441+
auth.close()
442+
443+
assert not auth._refresh_thread.is_alive()
444+
assert auth.executor._shutdown
445+
mock_atexit.unregister.assert_called_with(auth._KeyAuth__shutdown)

‎core/uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)