fix(credentials): raise a concrete error when the keyring backend fails

The resolver caught bare `Exception` around keyring reads and writes.
That could not be narrowed at the call site: the errors originate in
python-keyring, an optional dependency, so `keyring.errors.KeyringError`
is not referenceable without importing the package unconditionally.

Move the open-ended catch to the boundary where keyring is already
imported. KeyringTokenProvider.get/set now wrap the backend calls and
re-raise as KeyringOperationError, preserving the original exception as
__cause__. The resolver catches that concrete type instead.

KeyringOperationError is deliberately distinct from KeyringUnavailableError:
"no usable backend" and "backend present but the call failed" are different
states, and the resolver treats them differently — the former warns once and
visibly, the latter degrades silently.

Both degradation paths are unchanged: a failed write still lets the token
be used for the current run, a failed read still falls through to the prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-Birkenbach
2026-07-27 16:50:10 +02:00
parent b4e0594901
commit 85d1abf61c
4 changed files with 34 additions and 15 deletions

View File

@@ -3,6 +3,7 @@
from .resolver import ResolutionOptions, TokenResolver
from .types import (
CredentialError,
KeyringOperationError,
KeyringUnavailableError,
NoCredentialsError,
TokenRequest,
@@ -11,6 +12,7 @@ from .types import (
__all__ = [
"CredentialError",
"KeyringOperationError",
"KeyringUnavailableError",
"NoCredentialsError",
"ResolutionOptions",

View File

@@ -2,10 +2,14 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from ..store_keys import build_keyring_key
from ..types import KeyringUnavailableError, TokenRequest, TokenResult
from ..types import (
KeyringOperationError,
KeyringUnavailableError,
TokenRequest,
TokenResult,
)
def _import_keyring():
@@ -41,10 +45,15 @@ class KeyringTokenProvider:
source_name: str = "keyring"
def get(self, request: TokenRequest) -> Optional[TokenResult]:
def get(self, request: TokenRequest) -> TokenResult | None:
keyring = _import_keyring()
key = build_keyring_key(request.provider_kind, request.host, request.owner)
token = keyring.get_password(key.service, key.username)
try:
token = keyring.get_password(key.service, key.username)
except Exception as exc:
raise KeyringOperationError(
f"Reading the keyring entry for {key.service!r} failed."
) from exc
if token:
return TokenResult(token=token.strip(), source=self.source_name)
return None
@@ -52,4 +61,9 @@ class KeyringTokenProvider:
def set(self, request: TokenRequest, token: str) -> None:
keyring = _import_keyring()
key = build_keyring_key(request.provider_kind, request.host, request.owner)
keyring.set_password(key.service, key.username, token)
try:
keyring.set_password(key.service, key.username, token)
except Exception as exc:
raise KeyringOperationError(
f"Writing the keyring entry for {key.service!r} failed."
) from exc

View File

@@ -3,13 +3,13 @@ from __future__ import annotations
import sys
from dataclasses import dataclass
from typing import Optional
from .providers.env import EnvTokenProvider
from .providers.gh import GhTokenProvider
from .providers.keyring import KeyringTokenProvider
from .providers.prompt import PromptTokenProvider
from .types import (
KeyringOperationError,
KeyringUnavailableError,
NoCredentialsError,
TokenRequest,
@@ -76,7 +76,7 @@ class TokenResolver:
self,
request: TokenRequest,
opts: ResolutionOptions,
) -> Optional[TokenResult]:
) -> TokenResult | None:
"""
Prompt for a token and optionally store it in keyring.
If keyring is unavailable, still return the token for this run.
@@ -93,8 +93,8 @@ class TokenResolver:
self._keyring.set(request, prompt_res.token) # overwrite is fine
except KeyringUnavailableError as exc:
self._warn_keyring_unavailable(exc)
except Exception:
# If keyring cannot store, still use token for this run.
except KeyringOperationError:
# Storing failed; the token is still valid for this run.
pass
return prompt_res
@@ -103,8 +103,8 @@ class TokenResolver:
self,
provider_kind: str,
host: str,
owner: Optional[str] = None,
options: Optional[ResolutionOptions] = None,
owner: str | None = None,
options: ResolutionOptions | None = None,
) -> TokenResult:
opts = options or ResolutionOptions()
request = TokenRequest(provider_kind=provider_kind, host=host, owner=owner)
@@ -135,8 +135,8 @@ class TokenResolver:
except KeyringUnavailableError as exc:
# Show a helpful warning once, then continue (prompt fallback).
self._warn_keyring_unavailable(exc)
except Exception:
# Unknown keyring errors: do not block prompting; still avoid hard crash.
except KeyringOperationError:
# Reading failed; fall through to the prompt.
pass
# 3) Prompt (optional)

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
class CredentialError(RuntimeError):
@@ -17,13 +16,17 @@ class KeyringUnavailableError(CredentialError):
"""Raised when keyring is requested but no backend is available."""
class KeyringOperationError(CredentialError):
"""Raised when a keyring read or write fails inside the backend."""
@dataclass(frozen=True)
class TokenRequest:
"""Parameters describing which token we need."""
provider_kind: str # e.g. "gitea", "github"
host: str # e.g. "git.example.org" or "github.com"
owner: Optional[str] = None # optional org/user
owner: str | None = None # optional org/user
@dataclass(frozen=True)