Skip to content

Commit f652b7d

Browse files
authored
Merge branch 'main' into dependabot/uv/services/cdn/cryptography-50.0.0
2 parents 5693362 + 6050f28 commit f652b7d

265 files changed

Lines changed: 10990 additions & 324 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/stale.yaml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ on:
99
paths: [".github/workflows/stale.yaml"]
1010

1111
env:
12-
DAYS_BEFORE_PR_STALE: 7
13-
DAYS_BEFORE_PR_CLOSE: 7
12+
DAYS_BEFORE_PR_STALE: 14
13+
DAYS_BEFORE_PR_CLOSE: 14
1414
EXEMPT_PR_LABELS: "ignore-stale"
1515

1616
permissions:

‎CHANGELOG.md‎

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

3+
- `core`:
4+
- [v0.3.1](core/CHANGELOG.md#v031)
5+
- **Fix:** Refresh access tokens five minutes before expiration instead of after, and wait for in-progress refreshes before authorizing requests
6+
- `alb`:
7+
- [v0.13.0](services/alb/CHANGELOG.md#v0130)
8+
- **Feature:** Add `ip_block_list_name` field to `LoadbalancerOptionAccessControl` model
9+
- `automation`:
10+
- [v0.1.0](services/automation/CHANGELOG.md#v010)
11+
- Initial onboarding of STACKIT Python SDK for Automation service
12+
- `dns`:
13+
- [v0.10.0](services/dns/CHANGELOG.md#v0100)
14+
- **Feature:** New `labels_map` field (`Dict[str, str]`) on `CreateZonePayload`, `PartialUpdateZonePayload`, and `Zone` to manage zone labels as a map (max 64 items)
15+
- **Deprecation:** `create_label`, `delete_label`, `list_labels` operations are now deprecated and scheduled for sunset on 2027-10-01; use the `labels_map` field on the `Zone` resource instead
16+
- **Improvement:** `list_record_sets`/`list_zones` docs clarify that records/zones in the `DELETE_SUCCEEDED` state are excluded by default; pass `state[eq]=DELETE_SUCCEEDED` to retrieve them
17+
- **Improvement:** `Label.key`/`CreateLabelPayload.key` now accepts up to 314 characters (previously 63), supporting prefixed label keys (e.g. `example.com/environment`)
18+
- **Breaking Change:** `PartialUpdateZonePayload.extensions` type changed from `ZoneExtensions` to `DomainExtensions`, now also exposing `observability_extension.state`
19+
- `objectstorage`:
20+
- [v1.6.0](services/objectstorage/CHANGELOG.md#v160)
21+
- **Feature:** New model class `RateLimitError`
22+
- **Improvement:** Add HTTP 429 rate limit error responses in API operations
23+
- **Breaking Change:** Field `expires` in `AccessKey` model is now optional (`Optional[StrictStr]`)
24+
- **Improvement:** Field `expires` in `CreateAccessKeyPayload` model now supports explicit `None` serialization
25+
- `rabbitmq`:
26+
- [v1.4.0](services/rabbitmq/CHANGELOG.md#v140)
27+
- **Breaking Change:** `rabbitmq_tracing` removed from the allowed values for `InstanceParameters.plugins`; passing it now raises a `ValueError`
28+
- `resourcemanager`
29+
- [v0.11.1](services/resourcemanager/CHANGELOG.md#0111)
30+
- Update descriptions of `labels` attributes in model classes
31+
- `telemetryrouter`:
32+
- [v0.6.0](services/telemetryrouter/CHANGELOG.md#v060)
33+
- **Feature:** Add `disabled` field to `ConfigFilter` to allow disabling a filter without removing it
34+
- `valkey`:
35+
- [v0.2.2](services/valkey/CHANGELOG.md#v022)
36+
- **Docs:** `InstanceParameters.min_replicas_to_write` doc comment now notes it should not be greater than the number of nodes (e.g. 1 or 3)
37+
- `vpn`:
38+
- [v0.7.3](services/vpn/CHANGELOG.md#v073)
39+
- **Improvement:** Relax maximum `rekey_time` validation to `86400` (previously `28800`) in `TunnelConfigurationPhase1`
40+
- **Improvement:** Relax maximum `rekey_time` validation to `14400` (previously `3600`) in `TunnelConfigurationPhase2`
41+
- [v0.7.2](services/vpn/CHANGELOG.md#v072)
42+
- **Breaking Change:** `predefined_network_prefix` attribute in `NetworkConfig` model class changed type from `Optional[List[str]]` to `Optional[str]` to match actual API behavior
43+
- **Improvement:** Relax minimum ASN validation to `1` (previously `64512`) for `as_path_contains_any` and `first_asn` in `BGPFilterRuleMatch` and `remote_asn` in `BGPTunnelConfig`
44+
45+
## Release (2026-09-14)
46+
347
- `albwaf`:
448
- [v0.1.2](services/albwaf/CHANGELOG.md#v012)
549
- **Docs:** Update module description and description of `update_waf` operation

‎core/CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## v0.3.1
2+
- **Fix:** Refresh access tokens five minutes before expiration instead of after, and wait for in-progress refreshes before authorizing requests
3+
14
## v0.3.0
25
- **Chore:** Bump minimum Python version to 3.10
36
- **Chore:** Update dependencies

‎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.0"
3+
version = "v0.3.1"
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: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ def __call__(self, r: Request) -> Request:
7979
if self.__is_token_expired(self.access_token):
8080
if self.refresh_future is None or self.refresh_future.done():
8181
self.refresh_future = self.executor.submit(self.__refresh_token)
82+
refresh_future = self.refresh_future
83+
else:
84+
refresh_future = None
85+
86+
# Do not hold the lock while waiting. The refresh worker acquires it when
87+
# updating the access token.
88+
if refresh_future is not None:
89+
refresh_future.result()
90+
91+
with self.lock:
8292
r.headers["Authorization"] = f"Bearer {self.access_token}"
8393
return r
8494

@@ -108,8 +118,9 @@ def __fetch_token_from_endpoint(self) -> None:
108118
response = requests.post(self.token_endpoint, data=body, timeout=self.timeout)
109119
response.raise_for_status()
110120
response_json = response.json()
111-
self.access_token = response_json["access_token"]
112-
self.refresh_token = response_json["refresh_token"]
121+
with self.lock:
122+
self.access_token = response_json["access_token"]
123+
self.refresh_token = response_json["refresh_token"]
113124
except requests.RequestException as e:
114125
raise requests.RequestException("Initial token fetch failed") from e
115126

@@ -130,14 +141,17 @@ def token_refresh_task():
130141
thread.start()
131142

132143
def __refresh_token(self):
133-
if self.__is_token_expired(self.refresh_token):
144+
with self.lock:
145+
refresh_token = self.refresh_token
146+
147+
if self.__is_token_expired(refresh_token):
134148
self.__create_initial_token()
135149
self.__fetch_token_from_endpoint()
136150
return
137151

138152
body = {
139153
"grant_type": "refresh_token",
140-
"refresh_token": self.refresh_token,
154+
"refresh_token": refresh_token,
141155
}
142156

143157
last_exception = None
@@ -147,24 +161,23 @@ def __refresh_token(self):
147161
response.raise_for_status()
148162
response_data = response.json()
149163
new_token = response_data.get("access_token")
150-
self.access_token = new_token
164+
with self.lock:
165+
self.access_token = new_token
151166
return
152167
except requests.RequestException as e:
153168
last_exception = e
154169

155170
raise requests.RequestException("Token refresh failed after retries") from last_exception
156171

157-
def __is_token_expired(self, token: str) -> bool:
172+
def __is_token_expired(self, token: Optional[str]) -> bool:
158173
try:
159174
decoded_token = jwt.decode(token, options={"verify_signature": False})
160175
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:
176+
if exp is None:
177+
return True
178+
return time.time() > (float(exp) - self.EXPIRATION_LEEWAY.total_seconds())
179+
except (jwt.InvalidTokenError, TypeError, ValueError):
166180
return True
167-
return False
168181

169182
def __shutdown(self):
170183
self.executor.shutdown(wait=False)

‎core/tests/core/test_auth.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
from concurrent.futures import ThreadPoolExecutor
12
from pathlib import Path, PurePath
3+
from threading import Event, Lock, Thread
24

35
import pytest
46
import json
57
import jwt
68
import requests
79
from unittest.mock import patch, mock_open, Mock
810

11+
from requests import Request
912
from requests.auth import HTTPBasicAuth
1013

1114
from stackit.core.auth_methods.key_auth import KeyAuth, ServiceAccountKey
@@ -294,3 +297,88 @@ def set_initial_token(auth):
294297
auth._KeyAuth__refresh_token()
295298

296299
assert mock_post.call_count == KeyAuth.MAX_REFRESH_RETRIES
300+
301+
302+
class TestKeyAuth:
303+
@pytest.mark.parametrize("refresh_already_failed", [False, True])
304+
def test_call_propagates_token_refresh_failure(self, refresh_already_failed):
305+
auth = object.__new__(KeyAuth)
306+
auth.lock = Lock()
307+
auth.access_token = jwt.encode({"exp": 0}, "x" * 32, algorithm="HS256")
308+
auth.refresh_token = jwt.encode({"exp": 4_000_000_000}, "x" * 32, algorithm="HS256")
309+
auth.token_endpoint = KeyAuth.DEFAULT_TOKEN_ENDPOINT
310+
auth.refresh_future = None
311+
original_token = auth.access_token
312+
request = Request("GET", "https://example.com")
313+
refresh_error = requests.RequestException("refresh failed")
314+
315+
with (
316+
ThreadPoolExecutor(max_workers=1) as executor,
317+
patch("stackit.core.auth_methods.key_auth.requests.post", side_effect=refresh_error) as mock_post,
318+
):
319+
auth.executor = executor
320+
if refresh_already_failed:
321+
auth.refresh_future = executor.submit(auth._KeyAuth__refresh_token)
322+
with pytest.raises(requests.RequestException, match="Token refresh failed after retries"):
323+
auth.refresh_future.result(timeout=1)
324+
325+
with pytest.raises(requests.RequestException, match="Token refresh failed after retries") as exc_info:
326+
auth(request)
327+
328+
assert exc_info.value.__cause__ is refresh_error
329+
assert auth.refresh_future.done()
330+
assert mock_post.call_count == KeyAuth.MAX_REFRESH_RETRIES
331+
mock_post.assert_called_with(
332+
auth.token_endpoint,
333+
data={"grant_type": "refresh_token", "refresh_token": auth.refresh_token},
334+
timeout=auth.timeout,
335+
)
336+
assert auth.access_token == original_token
337+
assert "Authorization" not in request.headers
338+
339+
def test_token_is_expired_before_expiration_with_leeway(self):
340+
auth = object.__new__(KeyAuth)
341+
secret = "x" * 32
342+
343+
with patch("stackit.core.auth_methods.key_auth.time.time", return_value=1_000):
344+
token_inside_leeway = jwt.encode({"exp": 1_240}, secret, algorithm="HS256")
345+
token_outside_leeway = jwt.encode({"exp": 1_360}, secret, algorithm="HS256")
346+
token_without_expiration = jwt.encode({}, secret, algorithm="HS256")
347+
token_with_zero_expiration = jwt.encode({"exp": 0}, secret, algorithm="HS256")
348+
349+
assert auth._KeyAuth__is_token_expired(token_inside_leeway)
350+
assert not auth._KeyAuth__is_token_expired(token_outside_leeway)
351+
assert auth._KeyAuth__is_token_expired(token_without_expiration)
352+
assert auth._KeyAuth__is_token_expired(token_with_zero_expiration)
353+
354+
def test_call_waits_for_an_in_progress_refresh_before_setting_header(self):
355+
auth = object.__new__(KeyAuth)
356+
auth.lock = Lock()
357+
auth.access_token = jwt.encode({"exp": 0}, "x" * 32, algorithm="HS256")
358+
fresh_token = jwt.encode({"exp": 4_000_000_000}, "x" * 32, algorithm="HS256")
359+
refresh_started = Event()
360+
release_refresh = Event()
361+
362+
class BlockingFuture:
363+
def done(self):
364+
return False
365+
366+
def result(self):
367+
refresh_started.set()
368+
release_refresh.wait(timeout=1)
369+
with auth.lock:
370+
auth.access_token = fresh_token
371+
372+
auth.refresh_future = BlockingFuture()
373+
request = Request("GET", "https://example.com")
374+
call_thread = Thread(target=auth, args=(request,))
375+
call_thread.start()
376+
377+
assert refresh_started.wait(timeout=1)
378+
assert call_thread.is_alive()
379+
380+
release_refresh.set()
381+
call_thread.join(timeout=1)
382+
383+
assert not call_thread.is_alive()
384+
assert request.headers["Authorization"] == f"Bearer {fresh_token}"

0 commit comments

Comments
 (0)