fix: update API token last_used and log usage stats#2711
Conversation
1fdaaae to
420c276
Compare
420c276 to
967656e
Compare
|
Thanks for fixing the My main concern is performance at scale:
|
7aa61ec to
d59b551
Compare
e0600b4 to
b450386
Compare
crivetimihai
left a comment
There was a problem hiding this comment.
Thanks for this PR, @shoummu1! The separation of last_used update from usage logging and the rate-limiting approach are good ideas. A few issues:
1. Unbounded in-memory cache (auth.py:143-168)
_update_api_token_last_used_sync._cache is a plain dict that grows with every unique JTI. In deployments with many API tokens, this leaks memory indefinitely. Consider using functools.lru_cache or a bounded dict.
2. await on synchronous DB inside ASGI middleware (token_usage_middleware.py)
log_token_usage is async def but performs synchronous SQLAlchemy db.add()/db.commit() — this blocks the event loop. In auth.py you correctly used asyncio.to_thread() for the sync path; the middleware should do the same.
3. Triple commit per request
token_service.log_token_usage() calls self.db.commit(), then the middleware explicitly calls db.commit(), and then fresh_db_session.__exit__ auto-commits. Since the context manager auto-commits on success, the explicit commits are redundant.
4. Redis client never retries after failure (auth.py:75)
If _get_sync_redis_client fails, _SYNC_REDIS_CLIENT stays None permanently for the process lifetime. Redis becoming temporarily unavailable means the in-memory fallback is permanent — consider a retry mechanism or TTL on the failure state.
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
Signed-off-by: Shoumi <shoumimukherjee@gmail.com>
- Store JTI in request.state.jti for standard API tokens in _set_auth_method_from_payload, ensuring middleware can access it without re-decoding the JWT on cached/batched auth paths - Remove redundant db.commit() in TokenUsageMiddleware since log_token_usage() already commits the transaction - Add bounded eviction (max 1024 entries) to in-memory rate-limit cache in _update_api_token_last_used_sync to prevent unbounded growth - Add test coverage for cache eviction and JTI propagation - Fix formatting (missing space, unused import, black/isort) Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Two admin tests failed because .env sets MCPGATEWAY_UI_HIDE_SECTIONS which leaks into test execution, causing section-dependent code paths (resource loading, gRPC services) to be skipped. Patch settings to ensure no sections are hidden during these tests. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
b450386 to
6a8aa8c
Compare
Review ChangesRebased onto latest 1.
|
Verification Against Running Instance (localhost:8080)Tested the feature end-to-end against the Docker Compose deployment (3 gateway replicas, PostgreSQL, Redis, nginx). Setup
Results
Usage logging — Working correctly:
Auth method propagation — Confirmed JTI flows through all auth paths (cached, standard) via Rate limiting — Rate-limit cache correctly prevents excessive DB writes for |
- Fix get_current_user request param type (Optional[object] → Request) so FastAPI auto-injects it, enabling last_used and usage logging - Add 30s backoff in _get_sync_redis_client after connection failures to prevent retry storms when Redis is down - Move in-memory cache from hasattr-based function attributes to module-level globals, eliminating initialization race condition - Store user_email in request state for DB-fallback opaque tokens so usage middleware can log them without JWT decode Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
crivetimihai
left a comment
There was a problem hiding this comment.
Addressing review findings with 4 targeted fixes. See inline comments for details on each change.
| async def get_current_user( | ||
| credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), | ||
| request: Optional[object] = None, | ||
| request: Request = None, # type: ignore[assignment] |
There was a problem hiding this comment.
Fix 1 (High priority): Changed request: Optional[object] = None → request: Request = None.
FastAPI's dependency injection matches parameters by type using lenient_issubclass. With Optional[object], the type check Request ≠ object fails, so FastAPI never auto-injects the request — it was always None. This caused _set_auth_method_from_payload to bail out immediately at line 813, skipping last_used updates, auth_method/jti state propagation, and usage logging for all 9 routes using Depends(get_current_user).
With Request type, FastAPI recognizes and auto-injects it. Manual callers (RBAC middleware, AuthContextMiddleware) already pass request=request explicitly, so they're unaffected.
| if _SYNC_REDIS_CLIENT is not None or not (config_settings.redis_url and config_settings.redis_url.strip() and config_settings.cache_type == "redis"): | ||
| return _SYNC_REDIS_CLIENT | ||
|
|
||
| # Backoff after recent failure (30 seconds) |
There was a problem hiding this comment.
Fix 2 (Medium priority): Added 30-second backoff after Redis connection failures.
Previously, when Redis was down, every call to _get_sync_redis_client() would retry redis.from_url() + ping() with a 2-second socket timeout. Under load this creates a retry storm. Now, after a failure we record _SYNC_REDIS_FAILURE_TIME and skip retries for 30 seconds. On successful connection, the failure timestamp is cleared.
|
|
||
| # Module-level in-memory cache for last_used rate-limiting (fallback when Redis unavailable) | ||
| _LAST_USED_CACHE: dict = {} | ||
| _LAST_USED_CACHE_LOCK = threading.Lock() |
There was a problem hiding this comment.
Fix 3 (Low priority): Moved in-memory cache from hasattr-based lazy function attributes to module-level _LAST_USED_CACHE / _LAST_USED_CACHE_LOCK.
The previous pattern used hasattr(_update_api_token_last_used_sync, "_cache") for lazy init. Two threads could both see hasattr return False — if thread A creates _cache but hasn't created _cache_lock yet, thread B could skip init (hasattr on _cache returns True) and try to acquire the non-existent lock. Module-level initialization eliminates this race entirely.
| jti = state.get("jti") if state else None | ||
| user = state.get("user") if state else None | ||
| user_email = getattr(user, "email", None) if user else None | ||
| if not user_email: |
There was a problem hiding this comment.
Fix 4 (Low priority): Added user_email fallback from request state for DB-fallback opaque tokens.
The DB-fallback API token path (auth.py ~line 1310) sets request.state.auth_method and request.state.jti but not request.state.user. Without a user object, the middleware couldn't extract user_email and the JWT decode fallback fails for opaque tokens — silently dropping usage logs.
Now auth.py stores request.state.user_email directly for the DB-fallback path, and the middleware checks it as a fallback when the user object is absent.
* fix: update API token last_used and log usage stats Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * flake8 fix Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * additional changes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * remove duplicate last_used update in token usage logging Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * additional test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * added exception handling Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * fix coverage Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * fix: optimize token tracking with rate-limiting and raw ASGI middleware Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * additional test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * fix: address review findings in token usage tracking - Store JTI in request.state.jti for standard API tokens in _set_auth_method_from_payload, ensuring middleware can access it without re-decoding the JWT on cached/batched auth paths - Remove redundant db.commit() in TokenUsageMiddleware since log_token_usage() already commits the transaction - Add bounded eviction (max 1024 entries) to in-memory rate-limit cache in _update_api_token_last_used_sync to prevent unbounded growth - Add test coverage for cache eviction and JTI propagation - Fix formatting (missing space, unused import, black/isort) Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix: patch hidden sections env leakage in admin UI tests Two admin tests failed because .env sets MCPGATEWAY_UI_HIDE_SECTIONS which leaks into test execution, causing section-dependent code paths (resource loading, gRPC services) to be skipped. Patch settings to ensure no sections are hidden during these tests. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix: resolve DI, retry storm, race, and opaque token tracking issues - Fix get_current_user request param type (Optional[object] → Request) so FastAPI auto-injects it, enabling last_used and usage logging - Add 30s backoff in _get_sync_redis_client after connection failures to prevent retry storms when Redis is down - Move in-memory cache from hasattr-based function attributes to module-level globals, eliminating initialization race condition - Store user_email in request state for DB-fallback opaque tokens so usage middleware can log them without JWT decode Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: Shoumi <shoumimukherjee@gmail.com> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com> Signed-off-by: Vishu Bhatnagar <vishu.bhatnagar@ibm.com>
* fix: update API token last_used and log usage stats Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * flake8 fix Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * additional changes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * remove duplicate last_used update in token usage logging Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * additional test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * added exception handling Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * fix coverage Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * fix: optimize token tracking with rate-limiting and raw ASGI middleware Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * additional test fixes Signed-off-by: Shoumi <shoumimukherjee@gmail.com> * fix: address review findings in token usage tracking - Store JTI in request.state.jti for standard API tokens in _set_auth_method_from_payload, ensuring middleware can access it without re-decoding the JWT on cached/batched auth paths - Remove redundant db.commit() in TokenUsageMiddleware since log_token_usage() already commits the transaction - Add bounded eviction (max 1024 entries) to in-memory rate-limit cache in _update_api_token_last_used_sync to prevent unbounded growth - Add test coverage for cache eviction and JTI propagation - Fix formatting (missing space, unused import, black/isort) Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix: patch hidden sections env leakage in admin UI tests Two admin tests failed because .env sets MCPGATEWAY_UI_HIDE_SECTIONS which leaks into test execution, causing section-dependent code paths (resource loading, gRPC services) to be skipped. Patch settings to ensure no sections are hidden during these tests. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix: resolve DI, retry storm, race, and opaque token tracking issues - Fix get_current_user request param type (Optional[object] → Request) so FastAPI auto-injects it, enabling last_used and usage logging - Add 30s backoff in _get_sync_redis_client after connection failures to prevent retry storms when Redis is down - Move in-memory cache from hasattr-based function attributes to module-level globals, eliminating initialization race condition - Store user_email in request state for DB-fallback opaque tokens so usage middleware can log them without JWT decode Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: Shoumi <shoumimukherjee@gmail.com> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
📌 Summary
API tokens "Last Used" timestamp was always showing "Never" and "Usage Stats" showed no data, even after tokens were actively used. This PR fixes both issues by updating the last_used timestamp during JWT validation and implementing middleware to log all API token usage.
🔗 Related Issue
Closes #2572
🐞 Root Cause
Two root causes identified:
last_used not updating: API tokens created via
/tokensare JWT tokens. When used, they pass JWT validation directly (auth.py:612) and the auth flow never reaches_lookup_api_token_sync()(auth.py:836) wherelast_usedwas updated. The database lookup was designed as a fallback for non-JWT tokens, but all API tokens are JWTs.Usage Stats empty: The
log_token_usage()method inTokenCatalogServicewas defined but never called from production code.💡 Fix Description
1. Last Used Timestamp Tracking
_update_api_token_last_used_sync(jti: str)helper function inauth.py(line 325)_set_auth_method_from_payload():auth_provider == "api_token"(line 548)asyncio.to_thread()for non-blocking database updatesfresh_db_session()2. Usage Statistics Logging
TokenUsageMiddlewareinmcpgateway/middleware/token_usage_middleware.pyrequest.state.auth_method == "api_token"main.pyafterAuthContextMiddlewareto ensure auth context is available3. Test Coverage
_update_api_token_last_used_sync()function🧪 Verification
make lintmake testmake coverage📐 MCP Compliance (if relevant)
✅ Checklist
make black isort pre-commit)