mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
fix(jwt): cache __NO_MAPPING__ sentinel before raising 403 in REJECT mode
REJECT mode was raising HTTPException immediately on a DB miss without writing the __NO_MAPPING__ sentinel, causing every subsequent rejected request to re-query the DB. Write the sentinel first so repeated rejections are served from cache within virtual_key_mapping_cache_ttl. Adds test asserting DB is not hit on the second reject after a cache-warm miss. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5daf7494c7
commit
f5fabe1fe4
2 changed files with 63 additions and 0 deletions
|
|
@ -673,6 +673,13 @@ async def _resolve_jwt_to_virtual_key(
|
|||
behavior = jwt_handler.litellm_jwtauth.unregistered_jwt_client_behavior
|
||||
|
||||
if behavior == UnregisteredJWTClientBehavior.REJECT:
|
||||
# Cache the miss before raising so repeated rejections are served from
|
||||
# cache and don't re-query the DB on every request.
|
||||
await user_api_key_cache.async_set_cache(
|
||||
key=cache_key,
|
||||
value="__NO_MAPPING__",
|
||||
ttl=jwt_handler.litellm_jwtauth.virtual_key_mapping_cache_ttl,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"JWT Key Mapping: No registered mapping for {virtual_key_claim_field}='{claim_value}'. Access denied.",
|
||||
|
|
|
|||
|
|
@ -466,6 +466,62 @@ async def test_reject_behavior_raises_403_on_no_mapping():
|
|||
assert "unknown@example.com" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_behavior_caches_sentinel_after_db_miss():
|
||||
"""
|
||||
On a fresh DB miss with REJECT, the __NO_MAPPING__ sentinel must be written
|
||||
to cache so that subsequent rejected requests are served from cache and do
|
||||
not re-query the DB.
|
||||
"""
|
||||
from litellm.proxy._types import UnregisteredJWTClientBehavior
|
||||
|
||||
jwt_handler = JWTHandler()
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(
|
||||
virtual_key_claim_field="email",
|
||||
unregistered_jwt_client_behavior=UnregisteredJWTClientBehavior.REJECT,
|
||||
virtual_key_mapping_cache_ttl=300,
|
||||
)
|
||||
jwt_claims = {"email": "unknown@example.com"}
|
||||
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.db.litellm_jwtkeymapping.find_first = AsyncMock(return_value=None)
|
||||
|
||||
user_api_key_cache = DualCache()
|
||||
|
||||
with patch("litellm.proxy.auth.user_api_key_auth.get_key_object", new_callable=AsyncMock):
|
||||
# First call — DB miss, should raise 403 and write sentinel
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _resolve_jwt_to_virtual_key(
|
||||
jwt_claims=jwt_claims,
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
# Sentinel must now be in cache
|
||||
cached = await user_api_key_cache.async_get_cache(
|
||||
"jwt_key_mapping:email:unknown@example.com"
|
||||
)
|
||||
assert cached == "__NO_MAPPING__"
|
||||
|
||||
# Second call — must raise 403 from cache, no additional DB hit
|
||||
prisma_client.db.litellm_jwtkeymapping.find_first.reset_mock()
|
||||
with pytest.raises(HTTPException) as exc_info2:
|
||||
await _resolve_jwt_to_virtual_key(
|
||||
jwt_claims=jwt_claims,
|
||||
jwt_handler=jwt_handler,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
assert exc_info2.value.status_code == 403
|
||||
prisma_client.db.litellm_jwtkeymapping.find_first.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_behavior_raises_403_on_cached_no_mapping():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue