From c7bec1efe6730104c0e8f66f2a5c890da63f5ca1 Mon Sep 17 00:00:00 2001 From: Filip Hejzlar Date: Wed, 21 Oct 2026 16:17:32 +0200 Subject: [PATCH 1/9] feat: add max_stale option to JWKSetCache --- jwt/jwk_set_cache.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/jwt/jwk_set_cache.py b/jwt/jwk_set_cache.py index 887de91f..81d078c7 100644 --- a/jwt/jwk_set_cache.py +++ b/jwt/jwk_set_cache.py @@ -32,16 +32,28 @@ def put(self, jwk_set: Optional[Union[PyJWKSet, dict[str, Any]]]) -> None: self.jwk_set_with_timestamp = PyJWTSetWithTimestamp(jwk_set) - def get(self) -> Optional[PyJWKSet]: - if self.jwk_set_with_timestamp is None or self.is_expired(): + def get(self, max_stale: float = 0) -> Optional[PyJWKSet]: + """Return the cached ``PyJWKSet`` if it is not expired. + + Can return keys that are up to *max_stale* seconds + past expiration. Returns ``None`` if the cache is empty + or expired past *max_stale* tolerance. + """ + if self.jwk_set_with_timestamp is None or self.is_expired(max_stale): return None return self.jwk_set_with_timestamp.get_jwk_set() - def is_expired(self) -> bool: + def is_expired(self, max_stale: float = 0) -> bool: + """Return whether the currently cached ``PyJWKSet`` is expired. + + Returns ``True`` if the cached ``PyJWKSet`` is more than + *max_stale* seconds past its lifetime. Returns ``False`` + otherwise. Always returns ``False`` for a lifespan of ``-1``. + """ return ( self.jwk_set_with_timestamp is not None and self.lifespan > -1 and time.monotonic() - > self.jwk_set_with_timestamp.get_timestamp() + self.lifespan + > self.jwk_set_with_timestamp.get_timestamp() + self.lifespan + max_stale ) From e909ae52ed7367bde797ff3673d8fa1052614261 Mon Sep 17 00:00:00 2001 From: Filip Hejzlar Date: Wed, 21 Oct 2026 16:49:00 +0200 Subject: [PATCH 2/9] test: cover JWKSetCache max_stale window --- tests/test_jwks_client.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py index 51ddd8c2..f4daeb14 100644 --- a/tests/test_jwks_client.py +++ b/tests/test_jwks_client.py @@ -467,6 +467,18 @@ def test_cache_put_none_clears_the_cache(self) -> None: assert cache.get() is None + def test_cache_get_serves_expired_set_within_max_stale(self) -> None: + cache = JWKSetCache(300) + + clock = [0.0] + with mock.patch( + "jwt.jwk_set_cache.time.monotonic", side_effect=lambda: clock[0] + ): + cache.put(RESPONSE_DATA_WITH_MATCHING_KID) + clock[0] = 350 + assert cache.get(max_stale=49.9) is None + assert cache.get(max_stale=50) is not None + def test_get_jwk_set_raises_when_endpoint_does_not_return_an_object(self) -> None: url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" From cd617910746d656ba006173ac31ed662e2d4e163 Mon Sep 17 00:00:00 2001 From: Filip Hejzlar Date: Wed, 21 Oct 2026 19:59:53 +0200 Subject: [PATCH 3/9] feat: add stale_if_error to PyJWKClient --- jwt/jwks_client.py | 60 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index fb3712e3..b44c2431 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -43,6 +43,7 @@ def __init__( timeout: float = 30, ssl_context: SSLContext | None = None, cooldown_duration: float = 30, + stale_if_error: float = 0, ): """A client for retrieving signing keys from a JWKS endpoint. @@ -67,6 +68,15 @@ def __init__( disable this behavior. The cooldown is bypassed when ``cache_jwk_set`` is ``False``. + If refreshing an expired JWK Set fails, ``stale_if_error`` lets the + client keep serving it for up to that many seconds past expiry + instead of raising. This covers connection failures, timeouts, and + HTTP 500, 502, 503 and 504 responses; other errors still raise, as + does ``get_jwk_set(refresh=True)``. Pass ``float("inf")`` to serve it + for as long as fetching fails. Stale keys are served silently, and + a key removed from the endpoint stays usable until the stale set is + discarded. Each fallback still waits for ``timeout`` first. + **Tier 2 — Signing key cache** (disabled by default): Caches individual signing keys (looked up by ``kid``) using an LRU cache with **no time-based expiration**. Keys are evicted only when @@ -96,6 +106,10 @@ def __init__( :param cooldown_duration: Minimum time in seconds between forced refreshes after an unknown key ID. Defaults to ``30``. :type cooldown_duration: float + :param stale_if_error: Time in seconds past expiry that a cached + JWK Set may still be served when refreshing it fails. Defaults + to ``0`` (disabled). + :type stale_if_error: float """ if headers is None: headers = {} @@ -124,6 +138,24 @@ def __init__( f'Cooldown duration must be finite, the input is "{cooldown_duration}"' ) self.cooldown_duration = cooldown_duration + + if stale_if_error < 0: + raise PyJWKClientError( + "Stale if error must be greater than or equal to 0, " + f'the input is "{stale_if_error}"' + ) + + if math.isnan(stale_if_error): + raise PyJWKClientError( + f'Stale if error must not be NaN, the input is "{stale_if_error}"' + ) + + if stale_if_error > 0 and not cache_jwk_set: + raise PyJWKClientError( + "Stale if error must be 0 if JWK Set caching is disabled" + ) + self.stale_if_error = stale_if_error + self._last_successful_fetch: float | None = None self._client_lock = threading.RLock() @@ -187,6 +219,19 @@ def fetch_data(self) -> Any: self._last_successful_fetch = time.monotonic() return jwk_set + @staticmethod + def _should_serve_stale_on(cause: BaseException | None) -> bool: + if cause is None or isinstance(cause, http.client.HTTPException): + return True + + if isinstance(cause, HTTPError): + return cause.code in (500, 502, 503, 504) + + if isinstance(cause, OSError): + return True + + return False + def get_jwk_set(self, refresh: bool = False) -> PyJWKSet: """Return the JWK Set, using the cache when available. @@ -204,8 +249,19 @@ def get_jwk_set(self, refresh: bool = False) -> PyJWKSet: data = self.jwk_set_cache.get() if data is None: - data = self.fetch_data() - fetched = True + try: + data = self.fetch_data() + fetched = True + except PyJWKClientConnectionError as e: + if self.stale_if_error == 0 or refresh or self.jwk_set_cache is None: + raise + + cause = e.__cause__ + if self._should_serve_stale_on(cause): + data = self.jwk_set_cache.get(max_stale=self.stale_if_error) + + if data is None: + raise # A cache hit is already parsed, so serve it as-is. Only a fresh # fetch reaches the check below, which still matters because From 748aaf7dbb12e495ce7ebe686b2f081e477bbc01 Mon Sep 17 00:00:00 2001 From: Filip Hejzlar Date: Wed, 21 Oct 2026 20:04:35 +0200 Subject: [PATCH 4/9] test: add coverage for stale_if_error --- tests/test_jwks_client.py | 100 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py index f4daeb14..3b0364c3 100644 --- a/tests/test_jwks_client.py +++ b/tests/test_jwks_client.py @@ -738,6 +738,106 @@ def test_get_jwt_set_invalid_lifespan(self) -> None: jwks_client = PyJWKClient(url, lifespan=-1) assert jwks_client is None + def test_rejects_invalid_stale_if_error(self) -> None: + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + + with pytest.raises( + PyJWKClientError, + match="Stale if error must be greater than or equal to 0", + ): + PyJWKClient(url, stale_if_error=-1) + + with pytest.raises( + PyJWKClientError, + match="Stale if error must not be NaN", + ): + PyJWKClient(url, stale_if_error=float("nan")) + + with pytest.raises( + PyJWKClientError, + match="Stale if error must be 0 if JWK Set caching is disabled", + ): + PyJWKClient(url, cache_jwk_set=False, stale_if_error=1) + + def test_infinite_stale_if_error_serves_stale_set_indefinitely(self) -> None: + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + jwks_client = PyJWKClient(url, lifespan=300, stale_if_error=float("inf")) + + clock = [0.0] + with mock.patch("jwt.jwks_client.time.monotonic", side_effect=lambda: clock[0]): + with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID): + initial_set = jwks_client.get_jwk_set() + + clock[0] = 10**9 + with mocked_failed_response(): + assert jwks_client.get_jwk_set() is initial_set + + @pytest.mark.parametrize( + ("cause", "expected"), + [ + pytest.param(None, True, id="no-cause"), + pytest.param(URLError("refused"), True, id="url-error"), + pytest.param(TimeoutError(), True, id="timeout"), + pytest.param(http.client.IncompleteRead(b""), True, id="incomplete-read"), + pytest.param(ValueError(), False, id="other-error"), + ], + ) + def test_should_serve_stale_on_cause( + self, cause: BaseException | None, expected: bool + ) -> None: + assert PyJWKClient._should_serve_stale_on(cause) is expected + + @pytest.mark.parametrize( + ("code", "expected"), + [ + (500, True), + (502, True), + (503, True), + (504, True), + (401, False), + (404, False), + (429, False), + ], + ) + def test_stale_served_only_on_gateway_http_errors( + self, code: int, expected: bool + ) -> None: + error = HTTPError("https://example.com", code, "msg", Message(), None) + assert PyJWKClient._should_serve_stale_on(error) is expected + + def test_forced_refresh_bypasses_stale_if_error(self) -> None: + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + jwks_client = PyJWKClient(url, lifespan=300, stale_if_error=float("inf")) + + with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID): + jwks_client.get_jwk_set() + + with pytest.raises(PyJWKClientConnectionError): + with mocked_failed_response(): + jwks_client.get_jwk_set(refresh=True) + + def test_serves_expired_set_within_stale_if_error(self) -> None: + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + jwks_client = PyJWKClient(url, lifespan=300, stale_if_error=60) + + clock = [0.0] + with mock.patch("jwt.jwks_client.time.monotonic", side_effect=lambda: clock[0]): + with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID): + initial_set = jwks_client.get_jwk_set() + + clock[0] = 330.0 + with mocked_failed_response(): + assert jwks_client.get_jwk_set() is initial_set + + with pytest.raises(PyJWKClientConnectionError): + with mocked_http_error_response(): + jwks_client.get_jwk_set() + + clock[0] = 361.0 + with pytest.raises(PyJWKClientConnectionError): + with mocked_failed_response(): + jwks_client.get_jwk_set() + @pytest.mark.parametrize( "uri", [ From fe131d87c137d03b059eb03568ecda32f6f75a17 Mon Sep 17 00:00:00 2001 From: Filip Hejzlar Date: Wed, 21 Oct 2026 22:54:34 +0200 Subject: [PATCH 5/9] docs: add changelog entry for stale_if_error --- CHANGELOG.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3bc4e683..83cb3818 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,15 @@ This project adheres to `Semantic Versioning `__. `Unreleased `__ ------------------------------------------------------------------------- +Added +~~~~~ + +- Add ``stale_if_error`` to ``PyJWKClient`` to keep serving an expired JWK Set + for up to that many seconds when refreshing it fails with a connection error, + a timeout, or an HTTP 500, 502, 503 or 504 response. Disabled by default. The + ``JWKSetCache`` ``get()`` and ``is_expired()`` methods accept a matching + ``max_stale`` argument, in `#1239 `__. + Changed ~~~~~~~ From 4d65e6ac7454c4a9f7c9ededb8b6f4f0d266a2d2 Mon Sep 17 00:00:00 2001 From: Filip Hejzlar Date: Fri, 23 Oct 2026 20:01:25 +0200 Subject: [PATCH 6/9] test: cover stale_if_error through get_signing_key --- tests/test_jwks_client.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py index 3b0364c3..b8b5a4d1 100644 --- a/tests/test_jwks_client.py +++ b/tests/test_jwks_client.py @@ -133,14 +133,16 @@ def mocked_incomplete_read_response() -> Iterator[mock.Mock]: @contextlib.contextmanager -def mocked_http_error_response() -> Iterator[tuple[mock.Mock, HTTPError]]: +def mocked_http_error_response( + code: int = 401, +) -> Iterator[tuple[mock.Mock, HTTPError]]: with mock.patch("urllib.request.build_opener") as build_opener_mock: opener = mock.Mock() build_opener_mock.return_value = opener urlopen_mock = opener.open http_error = HTTPError( url="https://example.com", - code=401, + code=code, msg="Unauthorized", hdrs=Message(), fp=io.BytesIO(b""), @@ -794,6 +796,7 @@ def test_should_serve_stale_on_cause( (502, True), (503, True), (504, True), + (501, False), (401, False), (404, False), (429, False), @@ -838,6 +841,29 @@ def test_serves_expired_set_within_stale_if_error(self) -> None: with mocked_failed_response(): jwks_client.get_jwk_set() + @pytest.mark.parametrize(("code", "serves_stale"), [(503, True), (401, False)]) + def test_get_signing_key_stale_if_error_on_http_status( + self, code: int, serves_stale: bool + ) -> None: + kid = "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw" + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + jwks_client = PyJWKClient(url, lifespan=300, stale_if_error=60) + + clock = [0.0] + with mock.patch("jwt.jwks_client.time.monotonic", side_effect=lambda: clock[0]): + with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID): + initial_key = jwks_client.get_signing_key(kid) + + clock[0] = 330.0 + with mocked_http_error_response(code) as (urlopen_mock, _): + if serves_stale: + assert jwks_client.get_signing_key(kid) is initial_key + else: + with pytest.raises(PyJWKClientConnectionError): + jwks_client.get_signing_key(kid) + + assert urlopen_mock.call_count == 1 + @pytest.mark.parametrize( "uri", [ From faa0c3929999d4db142e3cd6e3de819fcf780b80 Mon Sep 17 00:00:00 2001 From: Filip Hejzlar Date: Fri, 23 Oct 2026 21:01:38 +0200 Subject: [PATCH 7/9] feat: add a backoff after a stale_if_error fallback --- jwt/jwks_client.py | 25 ++++++++++++-- tests/test_jwks_client.py | 72 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index b44c2431..f6f77e7b 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -75,7 +75,9 @@ def __init__( does ``get_jwk_set(refresh=True)``. Pass ``float("inf")`` to serve it for as long as fetching fails. Stale keys are served silently, and a key removed from the endpoint stays usable until the stale set is - discarded. Each fallback still waits for ``timeout`` first. + discarded. After a fallback, the client serves the stale set without + fetching for ``cooldown_duration`` seconds, so only one request per + cooldown waits for ``timeout``. **Tier 2 — Signing key cache** (disabled by default): Caches individual signing keys (looked up by ``kid``) using an LRU @@ -104,7 +106,8 @@ def __init__( :param ssl_context: Optional SSL context for the request. :type ssl_context: ssl.SSLContext or None :param cooldown_duration: Minimum time in seconds between forced - refreshes after an unknown key ID. Defaults to ``30``. + refreshes after an unknown key ID, and between fetches after a + ``stale_if_error`` fallback. Defaults to ``30``. :type cooldown_duration: float :param stale_if_error: Time in seconds past expiry that a cached JWK Set may still be served when refreshing it fails. Defaults @@ -157,6 +160,7 @@ def __init__( self.stale_if_error = stale_if_error self._last_successful_fetch: float | None = None + self._last_failed_fetch: float | None = None self._client_lock = threading.RLock() if cache_jwk_set: @@ -248,12 +252,25 @@ def get_jwk_set(self, refresh: bool = False) -> PyJWKSet: if self.jwk_set_cache is not None and not refresh: data = self.jwk_set_cache.get() + skip_stale_if_error = self.stale_if_error == 0 or refresh + + # After a stale_if_error fallback, keep serving the stale set + # without fetching until the cooldown has passed. + if data is None and not skip_stale_if_error: + if ( + self.jwk_set_cache is not None + and self._last_failed_fetch is not None + and time.monotonic() < self._last_failed_fetch + self.cooldown_duration + ): + data = self.jwk_set_cache.get(max_stale=self.stale_if_error) + if data is None: try: data = self.fetch_data() fetched = True + self._last_failed_fetch = None except PyJWKClientConnectionError as e: - if self.stale_if_error == 0 or refresh or self.jwk_set_cache is None: + if skip_stale_if_error or self.jwk_set_cache is None: raise cause = e.__cause__ @@ -263,6 +280,8 @@ def get_jwk_set(self, refresh: bool = False) -> PyJWKSet: if data is None: raise + self._last_failed_fetch = time.monotonic() + # A cache hit is already parsed, so serve it as-is. Only a fresh # fetch reaches the check below, which still matters because # `fetch_data()` may be overridden by a subclass. diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py index b8b5a4d1..a1dbc300 100644 --- a/tests/test_jwks_client.py +++ b/tests/test_jwks_client.py @@ -821,7 +821,9 @@ def test_forced_refresh_bypasses_stale_if_error(self) -> None: def test_serves_expired_set_within_stale_if_error(self) -> None: url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" - jwks_client = PyJWKClient(url, lifespan=300, stale_if_error=60) + jwks_client = PyJWKClient( + url, lifespan=300, stale_if_error=60, cooldown_duration=0 + ) clock = [0.0] with mock.patch("jwt.jwks_client.time.monotonic", side_effect=lambda: clock[0]): @@ -864,6 +866,74 @@ def test_get_signing_key_stale_if_error_on_http_status( assert urlopen_mock.call_count == 1 + def test_stale_if_error_backs_off_after_fallback(self) -> None: + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + jwks_client = PyJWKClient( + url, lifespan=300, stale_if_error=60, cooldown_duration=30 + ) + + clock = [0.0] + with mock.patch("jwt.jwks_client.time.monotonic", side_effect=lambda: clock[0]): + with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID): + initial_set = jwks_client.get_jwk_set() + + clock[0] = 330.0 + with mocked_failed_response() as urlopen_mock: + assert jwks_client.get_jwk_set() is initial_set + + clock[0] = 359.0 + assert jwks_client.get_jwk_set() is initial_set + assert urlopen_mock.call_count == 1 + + clock[0] = 361.0 + with mocked_success_response( + RESPONSE_DATA_WITH_MATCHING_KID + ) as urlopen_mock: + assert jwks_client.get_jwk_set() is not initial_set + assert urlopen_mock.call_count == 1 + + def test_unknown_kid_fetches_during_stale_if_error_backoff(self) -> None: + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + jwks_client = PyJWKClient( + url, lifespan=300, stale_if_error=60, cooldown_duration=30 + ) + + clock = [0.0] + with mock.patch("jwt.jwks_client.time.monotonic", side_effect=lambda: clock[0]): + with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID): + jwks_client.get_jwk_set() + + clock[0] = 330.0 + with mocked_failed_response(): + jwks_client.get_jwk_set() + + clock[0] = 340.0 + with mocked_failed_response() as urlopen_mock: + with pytest.raises(PyJWKClientConnectionError): + jwks_client.get_signing_key("unknown-kid") + assert urlopen_mock.call_count == 1 + + def test_backoff_does_not_extend_stale_if_error_window(self) -> None: + url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" + jwks_client = PyJWKClient( + url, lifespan=300, stale_if_error=30, cooldown_duration=60 + ) + + clock = [0.0] + with mock.patch("jwt.jwks_client.time.monotonic", side_effect=lambda: clock[0]): + with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID): + initial_set = jwks_client.get_jwk_set() + + clock[0] = 310.0 + with mocked_failed_response(): + assert jwks_client.get_jwk_set() is initial_set + + clock[0] = 340.0 + with mocked_failed_response() as urlopen_mock: + with pytest.raises(PyJWKClientConnectionError): + jwks_client.get_jwk_set() + assert urlopen_mock.call_count == 1 + @pytest.mark.parametrize( "uri", [ From 07144e851dae4921a32281078271e5360deace19 Mon Sep 17 00:00:00 2001 From: Filip Hejzlar Date: Fri, 23 Oct 2026 21:02:07 +0200 Subject: [PATCH 8/9] docs: add backoff mention to the changelog --- CHANGELOG.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 83cb3818..f24b685d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,9 +12,11 @@ Added - Add ``stale_if_error`` to ``PyJWKClient`` to keep serving an expired JWK Set for up to that many seconds when refreshing it fails with a connection error, - a timeout, or an HTTP 500, 502, 503 or 504 response. Disabled by default. The - ``JWKSetCache`` ``get()`` and ``is_expired()`` methods accept a matching - ``max_stale`` argument, in `#1239 `__. + a timeout, or an HTTP 500, 502, 503 or 504 response. Disabled by default. + After a fallback, fetches pause for ``cooldown_duration``. + ``JWKSetCache.get()`` and ``JWKSetCache.is_expired()`` accept a matching + ``max_stale`` argument, + in `#1239 `__. Changed ~~~~~~~ From 93aa0f782bda77e8b01d40931695de24a8760f81 Mon Sep 17 00:00:00 2001 From: Filip Hejzlar Date: Mon, 26 Oct 2026 16:33:09 +0100 Subject: [PATCH 9/9] docs: drop a potentially misleading claim from the stale_if_error backoff --- jwt/jwks_client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index f6f77e7b..bcc046fc 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -76,8 +76,7 @@ def __init__( for as long as fetching fails. Stale keys are served silently, and a key removed from the endpoint stays usable until the stale set is discarded. After a fallback, the client serves the stale set without - fetching for ``cooldown_duration`` seconds, so only one request per - cooldown waits for ``timeout``. + fetching for ``cooldown_duration`` seconds. **Tier 2 — Signing key cache** (disabled by default): Caches individual signing keys (looked up by ``kid``) using an LRU