@@ -386,7 +386,7 @@ def test_failed_request_should_raise_connection_error(self) -> None:
386386
387387 def test_get_jwt_set_refresh_cache (self ) -> None :
388388 url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
389- jwks_client = PyJWKClient (url )
389+ jwks_client = PyJWKClient (url , cooldown_duration = 0 )
390390
391391 kid = "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw"
392392
@@ -411,6 +411,145 @@ def test_get_jwt_set_no_matching_kid_after_second_attempt(self) -> None:
411411 ):
412412 jwks_client .get_signing_key (kid )
413413
414+ def test_unknown_kid_refresh_is_cooled_down (self ) -> None :
415+ url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
416+ kid = "unknown-kid"
417+ jwks_client = PyJWKClient (url , cooldown_duration = 30 )
418+
419+ with mock .patch ("urllib.request.build_opener" ) as build_opener_mock :
420+ opener = mock .Mock ()
421+ build_opener_mock .return_value = opener
422+ response = mock .Mock ()
423+ response .__enter__ = mock .Mock (return_value = response )
424+ response .__exit__ = mock .Mock ()
425+ response .read .return_value = json .dumps (RESPONSE_DATA_NO_MATCHING_KID )
426+ opener .open .return_value = response
427+
428+ for _ in range (2 ):
429+ with pytest .raises (PyJWKClientError , match = "matches" ):
430+ jwks_client .get_signing_key (kid )
431+
432+ assert opener .open .call_count == 1
433+
434+ def test_unknown_kid_refresh_runs_again_after_cooldown (self ) -> None :
435+ url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
436+ kid = "unknown-kid"
437+ jwks_client = PyJWKClient (url , cooldown_duration = 0.01 )
438+
439+ with mock .patch ("urllib.request.build_opener" ) as build_opener_mock :
440+ opener = mock .Mock ()
441+ build_opener_mock .return_value = opener
442+ response = mock .Mock ()
443+ response .__enter__ = mock .Mock (return_value = response )
444+ response .__exit__ = mock .Mock ()
445+ response .read .return_value = json .dumps (RESPONSE_DATA_NO_MATCHING_KID )
446+ opener .open .return_value = response
447+
448+ clock = [0.0 ]
449+ with mock .patch (
450+ "jwt.jwks_client.time.monotonic" , side_effect = lambda : clock [0 ]
451+ ):
452+ with pytest .raises (PyJWKClientError , match = "matches" ):
453+ jwks_client .get_signing_key (kid )
454+ clock [0 ] = 0.02
455+ with pytest .raises (PyJWKClientError , match = "matches" ):
456+ jwks_client .get_signing_key (kid )
457+
458+ assert opener .open .call_count == 2
459+
460+ def test_unknown_kid_refresh_serializes_concurrent_misses (self ) -> None :
461+ url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
462+ kid = "unknown-kid"
463+ jwks_client = PyJWKClient (url , cooldown_duration = 30 )
464+ refresh_started = threading .Event ()
465+ second_done = threading .Event ()
466+ release_refresh = threading .Event ()
467+
468+ with mock .patch ("urllib.request.build_opener" ) as build_opener_mock :
469+ opener = mock .Mock ()
470+ build_opener_mock .return_value = opener
471+ response = mock .Mock ()
472+ response .__enter__ = mock .Mock (return_value = response )
473+ response .__exit__ = mock .Mock ()
474+ response .read .return_value = json .dumps (RESPONSE_DATA_NO_MATCHING_KID )
475+
476+ opener .open .return_value = response
477+ jwks_client .get_jwk_set ()
478+ jwks_client ._last_successful_fetch = 0
479+
480+ def open_response (* args : object , ** kwargs : object ) -> mock .Mock :
481+ if opener .open .call_count == 1 :
482+ refresh_started .set ()
483+ release_refresh .wait (timeout = 5 )
484+ return response
485+
486+ opener .open .reset_mock ()
487+ opener .open .side_effect = open_response
488+ errors : list [Exception ] = []
489+
490+ def lookup () -> None :
491+ try :
492+ jwks_client .get_signing_key (kid )
493+ except PyJWKClientError as error :
494+ errors .append (error )
495+
496+ first = threading .Thread (target = lookup )
497+
498+ def second_lookup () -> None :
499+ lookup ()
500+ second_done .set ()
501+
502+ second = threading .Thread (target = second_lookup )
503+ with mock .patch ("jwt.jwks_client.time.monotonic" , return_value = 31 ):
504+ first .start ()
505+ assert refresh_started .wait (timeout = 5 )
506+ second .start ()
507+ assert not second_done .wait (timeout = 0.1 )
508+ release_refresh .set ()
509+ first .join (timeout = 5 )
510+ second .join (timeout = 5 )
511+
512+ assert len (errors ) == 2
513+ assert not first .is_alive ()
514+ assert not second .is_alive ()
515+ assert opener .open .call_count == 1
516+
517+ def test_unknown_kid_refresh_ignores_cooldown_when_cache_disabled (self ) -> None :
518+ url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
519+ kid = "unknown-kid"
520+ jwks_client = PyJWKClient (url , cache_jwk_set = False , cooldown_duration = 30 )
521+
522+ with mock .patch ("urllib.request.build_opener" ) as build_opener_mock :
523+ opener = mock .Mock ()
524+ build_opener_mock .return_value = opener
525+ response = mock .Mock ()
526+ response .__enter__ = mock .Mock (return_value = response )
527+ response .__exit__ = mock .Mock ()
528+ response .read .side_effect = [
529+ json .dumps (RESPONSE_DATA_NO_MATCHING_KID ),
530+ json .dumps (RESPONSE_DATA_NO_MATCHING_KID ),
531+ json .dumps (RESPONSE_DATA_NO_MATCHING_KID ),
532+ json .dumps (RESPONSE_DATA_NO_MATCHING_KID ),
533+ ]
534+ opener .open .return_value = response
535+
536+ for _ in range (2 ):
537+ with pytest .raises (PyJWKClientError , match = "matches" ):
538+ jwks_client .get_signing_key (kid )
539+
540+ assert opener .open .call_count == 4
541+
542+ @pytest .mark .parametrize (
543+ "cooldown_duration" , [float ("nan" ), float ("inf" ), float ("-inf" )]
544+ )
545+ def test_unknown_kid_refresh_rejects_non_finite_cooldown (
546+ self , cooldown_duration : float
547+ ) -> None :
548+ url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
549+
550+ with pytest .raises (PyJWKClientError , match = "Cooldown duration" ):
551+ PyJWKClient (url , cooldown_duration = cooldown_duration )
552+
414553 def test_get_jwt_set_invalid_lifespan (self ) -> None :
415554 url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json"
416555
0 commit comments