Repository navigation
Description
🔴 Required Information
Describe the Bug:
When an authenticated RestApiTool (e.g., from OpenAPIToolset) encounters an HTTP 401 Unauthorized response from an upstream API because the access token has expired or was revoked and refresh fails/is unavailable, RestApiTool.call() catches httpx.HTTPStatusError generically and returns a text error string ({"error": "Tool ... execution failed ... Status Code: 401 ..."}) to the LLM agent.
This applies equally to both OpenID Connect (OPEN_ID_CONNECT) and standard OAuth 2.0 (OAUTH2), which share the same underlying token exchange, bearer authorization, and OAuth2CredentialRefresher architecture in Google ADK.
ADK does not evict the dead token from tool_context.state. Because the dead credential remains cached in the session state (persisted across turns via DatabaseSessionService), all subsequent user messages in that chat session continue reusing the expired token. The session enters an unrecoverable 401 loop, never re-prompting the user for authorization (adk_request_credential).
Beyond trapping the individual user in a broken session, this persistent 401 loop causes trust degeneration with security firewalls, API gateways, and WAFs (e.g., automated threat detection flagging repeated unauthorized requests). In multi-tenant environments where agent runners share egress IPs or gateways, firewall-level IP penalties and rate-limiting can cascade and disrupt other active users who have valid tokens.
Crucially, in oauth2_credential_refresher.py (lines 125–127), the code explicitly states:
except (AuthlibBaseError, requests.RequestException) as e:
# Non-fatal: keep the stale token so its eventual 401
# re-triggers auth.
logger.error("Failed to refresh OAuth2 tokens: %s", e)
return auth_credentialADK framework designers explicitly intended for an eventual 401 to re-trigger auth, but RestApiTool never executes credential eviction or auth re-triggering. Furthermore, ToolContextCredentialStore.remove_credential() is dead code that calls del self.tool_context.state[key], which crashes at runtime with AttributeError: __delitem__ because google.adk.sessions.state.State does not implement __delitem__.
Steps to Reproduce:
- Configure an agent with an OpenAPI toolset (
RestApiTool) requiring OIDC or OAuth2 authentication (AuthCredentialTypes.OPEN_ID_CONNECTorAuthCredentialTypes.OAUTH2). - Run the agent using
adk api_serverwith persistent storage (e.g.DatabaseSessionService/ PostgreSQL). - Send a user query requiring the tool. ADK emits
adk_request_credential. - Supply credentials via
FunctionResponse(queryAuthConfirm). The tool executes successfully and credentials are saved in session state underopenIdConnect_<scheme_hash>_...oroauth2_<scheme_hash>_.... - Invalidate the access token (or let access and refresh tokens expire, e.g., leave the session idle overnight).
- Send another query in the same session requiring the authenticated tool.
Expected Behavior:
- On receiving HTTP 401 from an authenticated endpoint (whether OIDC or OAuth2),
RestApiTool/ToolAuthHandlershould evict or tombstone the stale credential key fromtool_context.state. - It should either attempt an immediate refresh via
OAuth2CredentialRefresher, or pause execution by callingtool_context.request_credential()and returning{"pending": True}so the runner emitsadk_request_credential. - If not re-triggered in the active turn, the subsequent turn should at least see an empty credential store and trigger re-authorization, rather than continuing to blast the protected API with invalid credentials.
Observed Behavior:
RestApiToolcatcheshttpx.HTTPStatusErrorand returns{"error": "Tool <name> execution failed... Status Code: 401..."}to the LLM.- The LLM outputs an apology to the user ("Authentication failed").
- The dead token remains cached in
tool_context.stateand in persistent database session storage. - When the user sends any follow-up message,
ToolAuthHandler._get_existing_credential()re-fetches the expired token from state and repeatedly attempts the call with the exact same invalid token. - The chat session is stuck in a permanent HTTP 401 loop until the entire session is manually deleted.
- Continuous unauthorized calls degrade the client/gateway trust score with upstream enterprise firewalls, risking IP blocking and impacting concurrent active users with valid credentials.
Environment Details:
- ADK Library Version:
google-adk 2.8.0 - Desktop OS: Windows 11 (AMD64) / Debian GNU/Linux 12 (Docker)
- Python Version:
3.13.14
Model Information:
- Are you using LiteLLM: No
- Which model is being used:
gemini-2.5-flash/gemini-2.5-pro
🟡 Optional Information
Regression:
Unknown. However, inspection of google.adk.tools.openapi_tool.openapi_spec_parser.tool_auth_handler.ToolContextCredentialStore reveals:
def remove_credential(self, key: str):
del self.tool_context.state[key]This method is dead code (never called anywhere in ADK). Moreover, State in google.adk.sessions.state does not implement __delitem__, meaning executing this method raises AttributeError: __delitem__.
Logs:
WARNING:google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool:API call failed for tool qualicision_qai_tool: Status 401 - {"detail":"Invalid or expired access token"}
DEBUG:google.adk.flows.llm_flows.functions:Function execution failed with error: Tool qualicision_qai_tool execution failed. Analyze this execution error and your inputs. Retry with adjustments if applicable. But make sure don't retry more than 3 times. Execution Error: Status Code: 401, {"detail":"Invalid or expired access token"}
Additional Context:
- Unified Impact across OIDC & OAuth2:
Because OIDC is built directly on OAuth 2.0, Google ADK uses the exact sameOAuth2Credentialmodel, the sameOAuth2CredentialRefresher, and the same bearer header injection for bothOPEN_ID_CONNECTandOAUTH2schemes. This defect affects all token-based OpenAPI/REST tools equally. - Security & Network Risk (Firewall Trust Degeneration):
Repeated 401 Unauthorized bursts from the agent container to protected APIs trigger anomaly detection and rate-limiting rules in enterprise Web Application Firewalls (WAF) and API gateways. In environments where the agent runs behind a shared gateway or egress IP, this trust score degeneration can lead to automated IP-level throttling or blocking, causing outages for other active users who have valid tokens. - Session State Key Formats:
openIdConnect_<scheme_hash>_openIdConnect_<cred_hash>_existing_exchanged_credential
oauth2_<scheme_hash>_oauth2_<cred_hash>_existing_exchanged_credential - Workaround:
In our API gateway/proxy layer, intercepting the 401 event and sendingPATCH /apps/{app_name}/users/{user_id}/sessions/{session_id}with{"state_delta": {"<credential_key>": {}}}successfully clears the cache and allows the session to recover on the next prompt. - Root Cause Code Locations:
google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py: Lines 582–600 (callmethod does not inspect 401 status or call auth eviction).google/adk/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.py: Lines 159–161 (remove_credentialnever called, and calls unsupporteddel state[key]).google/adk/sessions/state.py: Lines 61–136 (Stateclass lacks__delitem__and delta tombstone support).
Minimal Reproduction Code:
This issue consists of two coupled defects, both verified with minimal isolated scripts:
Repro 1: RestApiTool swallows HTTP 401 without evicting the token from state
import asyncio
import httpx
from unittest.mock import MagicMock
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool
from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_spec_parser import OperationEndpoint, Operation
async def test_rest_api_tool_swallows_401():
# Mock upstream API returning 401 Unauthorized
def mock_factory():
transport = httpx.MockTransport(lambda req: httpx.Response(401, json={"detail": "Token expired"}))
return httpx.AsyncClient(transport=transport)
endpoint = OperationEndpoint(base_url="http://example.com", path="/data", method="get")
operation = Operation(responses={"200": {"description": "OK"}})
tool = RestApiTool(
name="test_api_tool",
description="test tool",
endpoint=endpoint,
operation=operation,
httpx_client_factory=mock_factory
)
mock_ctx = MagicMock()
mock_ctx.state = {"openIdConnect_credential_key": {"access_token": "expired_token"}}
mock_ctx.actions = MagicMock()
mock_ctx.function_call_id = "call_abc"
result = await tool.call(args={}, tool_context=mock_ctx)
# 1. Swallowed into error dictionary for LLM
assert "error" in result
assert "Status Code: 401" in result["error"]
# 2. Defect: The dead token is NOT evicted from session state
assert "openIdConnect_credential_key" in mock_ctx.state
asyncio.run(test_rest_api_tool_swallows_401())Repro 2: ToolContextCredentialStore.remove_credential() crashes with AttributeError on State
import pytest
from google.adk.sessions.state import State
from google.adk.tools.openapi_tool.openapi_spec_parser.tool_auth_handler import ToolContextCredentialStore
from unittest.mock import MagicMock
def test_state_does_not_support_delitem():
"""Demonstrates that ToolContextCredentialStore.remove_credential() crashes on State."""
state = State(
value={"openIdConnect_foo_existing_exchanged_credential": {"access_token": "expired"}},
delta={},
)
tool_context = MagicMock()
tool_context.state = state
store = ToolContextCredentialStore(tool_context)
# This raises AttributeError: __delitem__
with pytest.raises(AttributeError):
store.remove_credential("openIdConnect_foo_existing_exchanged_credential")How often has this issue occurred?:
- Always (100%) whenever an authenticated session's access and refresh tokens expire or are revoked upstream.
Activity
willie3838 commentedon Sep 10, 2026
I can work on this - I've created a CL
willie3838 commentedon Sep 10, 2026
@surajksharma07 can you assign this to me?
surajksharma07 commentedon Sep 11, 2026
@PhorstenkampFuzzy RestApiTool swallows the 401 without touching state and remove_credential() would've thrown AttributeError anyway since State has no delitem. #7087 looks like it's targeting the same root cause so before we take it further could you pull that branch and try it against your setup to confirm it actually clears the stuck session? If it holds up on your side no need for a separate PR, we can just get that one through review.
@willie3838 CL will be reviewed internally by engineering team, thanks for your time.
PhorstenkampFuzzy commentedon Sep 11, 2026
I am the same person (Human Person) that provided the PR from my personal account and not my buisness account. We will try the fix today.
PhorstenkampFuzzy commentedon Sep 11, 2026
I can't test this this week. I will try to do so on monday. I am missing some credentials to set this up the same way. In theory #7087 should be a valid fix. But I can't test it this week.
surajksharma07 commentedon Sep 14, 2026
@PhorstenkampFuzzy no rush, Monday's totally fine, appreciate the heads up instead of just going quiet.
While that's blocked one thing worth fixing in #7087 in parallel since it doesn't depend on your E2E setup: the new State.delitem/contains/to_dict all use _delta[key] = None as the tombstone but setitem is untouched, so state['x'] = None (a real explicit None write not a delete) now makes 'x' in state come back False and state['x'] raise KeyError. Worth a distinct sentinel instead of reusing None before this goes further.
Nothing here needs your input though, that one's on the PR side, take your time with the credentials.
philipp-horstenkamp commentedon Sep 14, 2026
We tested this today and the solution seemed to work for a new chat. We are not sure why, but this didn't fix a pre-existing chat or remove the outdated token there. Not sure why—could have been a problem with the setup. So I would say cautious that this is a fix.
philipp-horstenkamp commentedon Sep 14, 2026
@surajksharma07 whats the prefered behavior. Tombstombing or deletion is up to you guys and in my opinion more a design philosophy decsion thats with maintainers and not me. Deletion is probably the smaller codebase. But I think elswhere you tombstombed tokens. I explicity asked AGY to check and cros reference... For whatever thats worth.
willie3838 commentedon Sep 14, 2026
@philipp-horstenkamp hey, we can address the problems internally with a duplicate PR we have open. my bad - I didn't see you were working on this but I will credit your PR. when we complete the review on our end I can ping you to see if it resolved your issue. does that work with you?
philipp-horstenkamp commentedon Sep 14, 2026
Sure. I have very little ego in that. I would hovever be interest in the other solution. Just curious 😁. Thanks for the credit though.
willie3838 commentedon Sep 14, 2026
Awesome, I'll also mention you in the PR so you can see the end solution!
philipp-horstenkamp commentedon Sep 14, 2026
I will then close this PR?
willie3838 commentedon Sep 14, 2026
Yep you can close it
PhorstenkampFuzzy commentedon Sep 16, 2026
Hi,
is there any timeline on this? I would be happy with a very ruff estimate!
Regards,
Philipp
surajksharma07 commentedon Sep 16, 2026
Good timing, this just landed (063278a) so the issue's closed now. Commit message credits you by name and references #7087 directly. It calls out that your fix handles a fresh conversation but not one already in progress which matches exactly what you saw fail in your own testing, so this CL adds that missing half. It also adds a reactive-refresh attempt before falling back to re-auth plus a recursion/budget guard so a persistently-401ing endpoint can't loop forever, should help with the WAF/rate-limit concern from your original report too.
Merged to main but hasn't shipped in a tagged google-adk release yet so hold off pulling it in until the next version bump. Will drop a note here once it's out. Thanks for sticking with this one Philipp.
fix(openapi): handle HTTP 401 in RestApiTool and recover stale OAuth2…
fix(openapi): handle HTTP 401 in RestApiTool and recover stale OAuth2…