diff --git a/CLAUDE.md b/CLAUDE.md index 040354a..6eba381 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ Every helper is extracted from a real consumer, not speculated. ## Architecture -Single Python package, hatchling-built, published to PyPI as `agentscore-commerce`. Per-framework identity adapters expose the same surface — `AgentScoreGate` (or `agentscore_gate(app, ...)` for Flask/Sanic), `capture_wallet`, `verify_wallet_signer_match`, `get_assess_data` — with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim). +Single Python package, hatchling-built, published to PyPI as `agentscore-commerce`. Per-framework identity adapters expose the same surface — `AgentScoreGate` (or `agentscore_gate(app, ...)` for Flask/Sanic), `capture_wallet`, `verify_wallet_signer_match`, `get_assess_data`, `get_gate_degraded_state`, `get_gate_quota_info` — with network-aware address normalization (EVM lowercased, Solana base58 preserved verbatim). | Directory | Contents | |---|---| @@ -56,6 +56,12 @@ Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator- Captured wallets: `capture_wallet(...)` is fire-and-forget — reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests. +Wallet-signer-match: `verify_wallet_signer_match` / `averify_wallet_signer_match` makes a single `/v1/assess` call with `resolve_signer` set; the API resolves both wallets and emits a `signer_match` verdict in the same response — collapses the legacy 2 follow-up assess calls into one round trip. Repeat lookups for the same `(claimed, signer)` pair hit a per-cache-entry `signer_match_by_signer` sub-dict and skip the API entirely. Falls back to a 2-resolve path when the API doesn't emit `signer_match` (canary rollout safety). + +### Fail-open (opt-in) + +`fail_open=True` on `AgentScoreGate(...)` (or `agentscore_gate(app, ...)`) flips infra-failure handling: 429 / 5xx / network-timeout pass through to the handler with the gate state stamped `degraded=True` + `infra_reason="quota_exceeded" | "api_error" | "network_timeout"`. `get_gate_degraded_state(request)` (Flask: `get_gate_degraded_state()` — reads from `g`) returns `{"degraded": bool, "infra_reason"?: str}` for merchant logging/alerting. Default stays `fail_open=False` — regulated commerce should keep it. Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of the flag. The gate's `try` wraps only the AgentScore call — never the downstream user handler. + ### Mount posture: gate-first vs gate-conditional `AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic) is mounted directly when the route is AgentScore-only — every request runs identity + policy. To support **anonymous discovery by any spec-compliant x402 wallet** (Coinbase awal, Phantom, Solflare, …), wrap the gate so it fires only when a payment credential is attached: diff --git a/README.md b/README.md index 60e4f5e..146c382 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,32 @@ async def purchase(request: Request): return JSONResponse(result.body, status_code=result.status, headers=result.headers) ``` +## Fail-open behavior + +By default AgentScore Gate fails closed: any AgentScore-side infrastructure failure (HTTP 429, 5xx, network timeout) returns 503 to the buyer. Set `fail_open=True` on `AgentScoreGate(...)` to opt in to graceful degradation: + +```python +from fastapi import Depends, FastAPI, Request +from agentscore_commerce.identity.fastapi import AgentScoreGate, get_gate_degraded_state + +app = FastAPI() +gate = AgentScoreGate(api_key=os.environ["AGENTSCORE_API_KEY"], fail_open=True) + +@app.post("/purchase", dependencies=[Depends(gate)]) +async def purchase(request: Request): + state = get_gate_degraded_state(request) + if state["degraded"]: + # Compliance was NOT enforced this request — log/alert/refund-async/etc. + logger.warning("gate degraded: %s", state["infra_reason"]) + # ...rest of handler +``` + +When `fail_open=True` AND the failure is infra-shape, the gate state carries `degraded=True` + `infra_reason="quota_exceeded" | "api_error" | "network_timeout"` so merchants can log/alert without parsing console output. **Compliance denials (sanctions, age, jurisdiction, signer-mismatch) still deny regardless of `fail_open`** — `fail_open` only covers "AgentScore couldn't tell us," never "AgentScore said no." + +For regulated commerce (alcohol, age-gated, sanctioned-jurisdiction-relevant) keep the default `fail_open=False` — outage is the correct posture; bypassing compliance on infra failure is a compliance gap. For low-stakes commerce or high-uptime SLAs, opt in and use the `degraded` flag as the audit trail. + +The `get_gate_degraded_state` helper is exported by every framework adapter (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI middleware) and reads from the framework-appropriate request state. The signature takes a request argument everywhere except Flask, which reads from `g` and takes no arguments. + ## Examples The [examples/](./examples) directory has 7 runnable single-file FastAPI apps covering common merchant scenarios. See [examples/README.md](./examples/README.md) for the full table. diff --git a/agentscore_commerce/identity/_response.py b/agentscore_commerce/identity/_response.py index f3ce699..3ee15b4 100644 --- a/agentscore_commerce/identity/_response.py +++ b/agentscore_commerce/identity/_response.py @@ -164,20 +164,19 @@ "action": "contact_merchant", "steps": [ ( - "The merchant's AgentScore tier does not include the assess feature, so " - "agent identity cannot be evaluated. This is a merchant-side configuration " - "gap — there is no agent-side recovery." + "The merchant's AgentScore account does not have the assess endpoint " + "enabled, so agent identity cannot be evaluated. This is a merchant-side " + "configuration gap — there is no agent-side recovery." ), ( "Contact the merchant (their support channel — typically listed in " - "/llms.txt or the OpenAPI servers metadata) and request they upgrade " - "their AgentScore plan." + "/llms.txt or the OpenAPI servers metadata) so they can resolve the " + "configuration on their side." ), ], "user_message": ( - "This merchant's identity gate is misconfigured (AgentScore tier doesn't " - "support assess). Contact the merchant — there's nothing to fix on the " - "agent side." + "This merchant's identity gate is misconfigured. Contact the merchant — " + "there's nothing to fix on the agent side." ), } ) @@ -224,10 +223,46 @@ } ) +_API_ERROR_INSTRUCTIONS = json.dumps( + { + "action": "retry_with_backoff", + "steps": [ + "Verification is temporarily unavailable. Retry the request after 5-30 seconds with exponential backoff.", + "This is NOT a compliance denial — the user does not need to re-verify their " + "identity. Send the same identity headers (X-Wallet-Address or X-Operator-Token) " + "on retry.", + "If the request continues to fail after 3+ retries (~60 seconds total), surface the " + "error to the user with the merchant's support contact.", + ], + "user_message": ( + "Verification is temporarily unavailable. Please try again in a moment — this is a " + "transient issue, not a problem with your account." + ), + } +) + +QUOTA_EXCEEDED_INSTRUCTIONS = json.dumps( + { + "action": "contact_merchant", + "steps": [ + "AgentScore identity verification is unavailable for this merchant. This is a " + "merchant-side issue and is NOT recoverable via retry.", + "Do not retry: the same 503 will be returned until the merchant resolves the issue on their side.", + "Surface to the user with the merchant's support contact. The merchant (not the agent) needs to act.", + ], + "user_message": ( + "This merchant's identity verification is temporarily unavailable. Try again later, " + "or contact the merchant directly." + ), + } +) + + # Default agent_instructions per denial code. Adapters can override by passing # ``agent_instructions=`` on the DenialReason; otherwise the body emitter looks # up this map so every denial carries a machine-readable next step. _DEFAULT_AGENT_INSTRUCTIONS: dict[str, str] = { + "api_error": _API_ERROR_INSTRUCTIONS, "missing_identity": _MISSING_IDENTITY_INSTRUCTIONS, "wallet_signer_mismatch": WALLET_SIGNER_MISMATCH_INSTRUCTIONS, "wallet_auth_requires_wallet_signing": WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS, @@ -260,7 +295,7 @@ def build_missing_identity_reason() -> DenialReason: ), "wallet_not_trusted": "The wallet does not meet the merchant compliance policy.", "api_error": "AgentScore is unreachable. This is transient — retry in a few seconds.", - "payment_required": "AgentScore tier does not support assess. Contact support.", + "payment_required": "Assess endpoint not enabled for this merchant. Contact support.", "wallet_signer_mismatch": ( "Payment signer does not match the wallet claimed via X-Wallet-Address. The signer and the " "claimed wallet must both resolve to the same AgentScore operator." @@ -321,10 +356,6 @@ def denial_reason_to_body(reason: DenialReason) -> dict[str, Any]: body["actual_signer"] = reason.actual_signer if reason.linked_wallets: body["linked_wallets"] = reason.linked_wallets - # api_error denials get a default retry hint so agents know it's transient. Vendors can - # override by spreading their own next_steps into a custom on_denied body. - if reason.code == "api_error" and not (reason.extra and reason.extra.get("next_steps")): - body["next_steps"] = {"action": "retry", "retry_after_seconds": 5} # Merchant-supplied fields from on_before_session hook. Guard against collision # with reserved fields — the gate owns those and can't let a hook override them. if reason.extra: diff --git a/agentscore_commerce/identity/aiohttp.py b/agentscore_commerce/identity/aiohttp.py index 114db8c..4fa0e86 100644 --- a/agentscore_commerce/identity/aiohttp.py +++ b/agentscore_commerce/identity/aiohttp.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING, Any +import httpx + from agentscore_commerce.identity._denial import ( FIXABLE_DENIAL_REASONS, build_contact_support_next_steps, @@ -12,11 +14,16 @@ is_fixable_denial, verification_agent_instructions, ) -from agentscore_commerce.identity._response import build_missing_identity_reason, denial_reason_to_body +from agentscore_commerce.identity._response import ( + QUOTA_EXCEEDED_INSTRUCTIONS, + build_missing_identity_reason, + denial_reason_to_body, +) from agentscore_commerce.identity.client import ( GateClient, InvalidCredentialError, PaymentRequiredError, + QuotaExceededError, TokenDeniedError, build_invalid_credential_reason, build_token_denied_reason, @@ -25,9 +32,11 @@ from agentscore_commerce.identity.types import ( AgentIdentity, DenialReason, + GateQuotaInfo, Network, VerifyWalletSignerMatchOptions, VerifyWalletSignerResult, + apply_degraded, ) from agentscore_commerce.payment.signer import ( extract_payment_signer, @@ -45,6 +54,12 @@ GATE_STATE_KEY = "__agentscore_gate" ASSESS_STATE_KEY = "agentscore" + +def _mark_degraded_aiohttp(request: web.Request, infra_reason: str) -> None: + """Stamp the gate state on an aiohttp request as fail-open'd.""" + apply_degraded(request.get(GATE_STATE_KEY), infra_reason) + + __all__ = [ "FIXABLE_DENIAL_REASONS", "CreateSessionOnMissing", @@ -57,6 +72,8 @@ "extract_payment_signer", "extract_payment_signer_address", "get_assess_data", + "get_gate_degraded_state", + "get_gate_quota_info", "is_fixable_denial", "read_x402_payment_header", "verification_agent_instructions", @@ -73,6 +90,31 @@ def get_assess_data(request: web.Request) -> dict[str, Any] | None: return request.get(ASSESS_STATE_KEY) +def get_gate_degraded_state(request: web.Request) -> dict[str, Any]: + """Return whether the gate fail-open'd due to AgentScore-side infra failure. + + Returns ``{"degraded": False}`` for normal allows; ``{"degraded": True, + "infra_reason": "quota_exceeded" | "api_error" | "network_timeout"}`` when bypassed. + """ + state = request.get(GATE_STATE_KEY) + if isinstance(state, dict) and state.get("degraded"): + return {"degraded": True, "infra_reason": state.get("infra_reason")} + return {"degraded": False} + + +def get_gate_quota_info(request: web.Request) -> GateQuotaInfo | None: + """Read AgentScore assess quota observability for this request. + + Captured from ``X-Quota-*`` response headers on this request's gate evaluate. + """ + state = request.get(GATE_STATE_KEY) + if isinstance(state, dict): + quota = state.get("quota") + if isinstance(quota, GateQuotaInfo): + return quota + return None + + def _default_extract_identity(request: web.Request) -> AgentIdentity | None: token = request.headers.get(DEFAULT_TOKEN_HEADER) addr = request.headers.get(DEFAULT_ADDRESS_HEADER) @@ -172,39 +214,11 @@ async def _agentscore_middleware( chain_override = _extract_chain(request) + # Only acheck_identity is wrapped — the downstream handler call must NOT be in the + # try, otherwise an exception in the user's route would be misclassified as an + # AgentScore infra failure and (under fail_open) re-invoke their handler. try: result = await client.acheck_identity(identity, chain_override) - - if result.allow: - request["agentscore"] = result.raw - return await handler(request) - - # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the - # same UX as missing_identity: the gate mints a fresh verification session, - # the agent polls until status=verified, gets a fresh opc_..., and retries - # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, - # jurisdiction_restricted) keep the bare wallet_not_trusted denial. - # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is - # verified (the user's KYC'd country is in the blocked list — re-doing KYC - # won't change the country). - if is_fixable_denial(result.reasons) and create_session_on_missing is not None: - session_reason = await try_create_session_denial_reason( - create_session_on_missing, - client.user_agent, - request, - ) - if session_reason is not None: - body, status = _on_denied(request, session_reason) - return web.json_response(body, status=status) - - reason = DenialReason( - code="wallet_not_trusted", - decision=result.decision, - reasons=result.reasons, - verify_url=result.verify_url, - ) - body, status = _on_denied(request, reason) - return web.json_response(body, status=status) except PaymentRequiredError: if client.fail_open: return await handler(request) @@ -218,12 +232,63 @@ async def _agentscore_middleware( # Permanent — no auto-session, agent should switch tokens or restart. body, status = _on_denied(request, build_invalid_credential_reason()) return web.json_response(body, status=status) + except QuotaExceededError: + if client.fail_open: + _mark_degraded_aiohttp(request, "quota_exceeded") + return await handler(request) + body, status = _on_denied( + request, + DenialReason(code="api_error", agent_instructions=QUOTA_EXCEEDED_INSTRUCTIONS), + ) + return web.json_response(body, status=status) + except httpx.TimeoutException: + if client.fail_open: + _mark_degraded_aiohttp(request, "network_timeout") + return await handler(request) + body, status = _on_denied(request, DenialReason(code="api_error")) + return web.json_response(body, status=status) except Exception: if client.fail_open: + _mark_degraded_aiohttp(request, "api_error") return await handler(request) body, status = _on_denied(request, DenialReason(code="api_error")) return web.json_response(body, status=status) + if result.allow: + request["agentscore"] = result.raw + if result.quota is not None: + state = request.get(GATE_STATE_KEY) + if isinstance(state, dict): + state["quota"] = result.quota + return await handler(request) + + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + # same UX as missing_identity: the gate mints a fresh verification session, + # the agent polls until status=verified, gets a fresh opc_..., and retries + # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, + # jurisdiction_restricted) keep the bare wallet_not_trusted denial. + # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is + # verified (the user's KYC'd country is in the blocked list — re-doing KYC + # won't change the country). + if is_fixable_denial(result.reasons) and create_session_on_missing is not None: + session_reason = await try_create_session_denial_reason( + create_session_on_missing, + client.user_agent, + request, + ) + if session_reason is not None: + body, status = _on_denied(request, session_reason) + return web.json_response(body, status=status) + + reason = DenialReason( + code="wallet_not_trusted", + decision=result.decision, + reasons=result.reasons, + verify_url=result.verify_url, + ) + body, status = _on_denied(request, reason) + return web.json_response(body, status=status) + return _agentscore_middleware diff --git a/agentscore_commerce/identity/client.py b/agentscore_commerce/identity/client.py index 6a1803e..7962ce6 100644 --- a/agentscore_commerce/identity/client.py +++ b/agentscore_commerce/identity/client.py @@ -5,9 +5,28 @@ import json import logging from importlib.metadata import version as _pkg_version -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, cast import httpx +from agentscore import ( + AgentScore, + AgentScoreError, +) +from agentscore import ( + InvalidCredentialError as SdkInvalidCredentialError, +) +from agentscore import ( + PaymentRequiredError as SdkPaymentRequiredError, +) +from agentscore import ( + QuotaExceededError as SdkQuotaExceededError, +) +from agentscore import ( + TimeoutError as SdkTimeoutError, +) +from agentscore import ( + TokenExpiredError as SdkTokenExpiredError, +) from agentscore_commerce.identity._response import ( WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS, @@ -18,6 +37,7 @@ from agentscore_commerce.identity.types import ( AgentIdentity, AssessResult, + GateQuotaInfo, Network, OperatorVerification, VerifyWalletSignerMatchOptions, @@ -25,6 +45,8 @@ ) if TYPE_CHECKING: + from agentscore.types import DecisionPolicy, ResolveSigner + from agentscore_commerce.identity.types import DenialReason _log = logging.getLogger(__name__) @@ -37,6 +59,8 @@ class GateClient: """Shared client for calling the AgentScore assess API. Manages caching and policy construction. Used by all framework adapters. + Wraps the official ``agentscore`` SDK so HTTP/retry/quota/typed-error logic + stays consistent across consumers. """ def __init__( @@ -80,8 +104,27 @@ def __init__( if allowed_jurisdictions is not None: self._policy["allowed_jurisdictions"] = allowed_jurisdictions - self._async_client = httpx.AsyncClient(timeout=10.0) - self._sync_client = httpx.Client(timeout=10.0) + self._sdk = AgentScore( + api_key=api_key, + base_url=base_url, + user_agent=self.user_agent, + ) + + @property + def _sync_client(self) -> Any: + """Underlying httpx Client used by the wrapped SDK. + + Exposed for tests that patch transport behavior directly via ``unittest.mock.patch.object``. + """ + return self._sdk._get_sync_client() + + @property + def _async_client(self) -> Any: + """Underlying httpx AsyncClient used by the wrapped SDK. + + Exposed for tests that patch transport behavior directly via ``unittest.mock.patch.object``. + """ + return self._sdk._get_async_client() def _cache_key(self, address: str | None = None, operator_token: str | None = None) -> str: # operator_token is opaque ASCII — lowercasing is safe. Wallet addresses go through @@ -93,6 +136,11 @@ def _cache_key(self, address: str | None = None, operator_token: str | None = No def _build_body( self, address: str | None = None, chain: str | None = None, operator_token: str | None = None ) -> dict[str, Any]: + """Construct the assess request body. + + Testable helper for the policy/chain wiring contract — pinned so a future SDK + body-shape regression would fail the gate's own tests as well. + """ body: dict[str, Any] = {} if address: body["address"] = address @@ -106,6 +154,11 @@ def _build_body( return body def _headers(self) -> dict[str, str]: + """Construct the canonical assess request headers. + + Testable helper for the X-API-Key + User-Agent contract — pinned independently + so a regression on either header would fail the gate's own tests. + """ return { "X-API-Key": self._api_key, "Content-Type": "application/json", @@ -113,23 +166,21 @@ def _headers(self) -> dict[str, str]: "User-Agent": self.user_agent, } - def _parse_response(self, resp: httpx.Response) -> AssessResult: - if resp.status_code == 402: - raise PaymentRequiredError + def _parse_response(self, resp: Any) -> AssessResult: + """Parse a raw httpx Response into an AssessResult. - if resp.status_code == 401: - # Pass through the API's credential-state 401s. Two distinct cases: - # - token_expired: revoked or TTL-expired (the API unifies them). Body - # carries an auto-minted session so the agent recovers without an - # API key. - # - invalid_credential: the token doesn't exist at all (typo, never - # minted). No auto-session — the agent likely has another token to - # try first, or should drop the header to bootstrap. + Testable helper for the gate's status-code → typed-error mapping contract. + """ + status = resp.status_code + if status == 402: + raise PaymentRequiredError + if status == 429: + _log.warning("[gate] /v1/assess returned 429") + raise QuotaExceededError("quota_exceeded") + if status == 401: try: err_body = resp.json() except (ValueError, json.JSONDecodeError) as parse_err: - # Don't silently swallow — schema drift on /v1/assess used to mask - # itself this way for hours. Log and keep falling through. _log.warning("[gate] /v1/assess 401 body parse failed: %s", parse_err) err_body = {} error = err_body.get("error") if isinstance(err_body, dict) else None @@ -143,14 +194,15 @@ def _parse_response(self, resp: httpx.Response) -> AssessResult: "[gate] /v1/assess returned 401 %s — no specific handler, surfacing as RuntimeError.", code, ) - msg = f"AgentScore API returned {resp.status_code}" + msg = f"AgentScore API returned {status}" raise RuntimeError(msg) - if not resp.is_success: - msg = f"AgentScore API returned {resp.status_code}" + msg = f"AgentScore API returned {status}" raise RuntimeError(msg) - data: dict[str, Any] = resp.json() + return self._project(data) + + def _project(self, data: dict[str, Any]) -> AssessResult: decision = data.get("decision") reasons: list[str] = data.get("decision_reasons", []) allow = decision == "allow" or decision is None @@ -166,6 +218,19 @@ def _parse_response(self, resp: httpx.Response) -> AssessResult: else None ) + # SDK populates `quota` on the AssessResponse from X-Quota-* headers. Surface up + # to adapters so merchants can monitor approach-to-cap proactively. + quota_raw = data.get("quota") + quota = ( + GateQuotaInfo( + limit=quota_raw.get("limit"), + used=quota_raw.get("used"), + reset=quota_raw.get("reset"), + ) + if isinstance(quota_raw, dict) + else None + ) + return AssessResult( allow=allow, decision=decision, @@ -175,6 +240,7 @@ def _parse_response(self, resp: httpx.Response) -> AssessResult: resolved_operator=data.get("resolved_operator"), verify_url=data.get("verify_url"), policy_result=data.get("policy_result"), + quota=quota, raw=data, ) @@ -188,12 +254,45 @@ def check( if cached is not None: return cached - resp = self._sync_client.post( - f"{self._base_url}/v1/assess", - headers=self._headers(), - content=json.dumps(self._build_body(address, chain, operator_token)), - ) - result = self._parse_response(resp) + effective_chain = chain or self._chain + # SDK typed errors map onto commerce's bespoke 401/402/429 exception surface. + try: + data = self._sdk.assess( + address=address, + operator_token=operator_token, + chain=effective_chain, + policy=cast("DecisionPolicy | None", self._policy or None), + ) + except SdkPaymentRequiredError as exc: + raise PaymentRequiredError from exc + except SdkQuotaExceededError as exc: + _log.warning("[gate] /v1/assess returned 429") + raise QuotaExceededError("quota_exceeded") from exc + except SdkTokenExpiredError as exc: + raise TokenDeniedError(getattr(exc, "details", {}) or {}) from exc + except SdkInvalidCredentialError as exc: + raise InvalidCredentialError() from exc + except SdkTimeoutError as exc: + # Re-raise as httpx.TimeoutException so adapters keep their existing + # `except httpx.TimeoutException` clauses for `infra_reason='network_timeout'` + # without each having to learn about the SDK's typed timeout class. + raise httpx.TimeoutException(str(exc)) from exc + except AgentScoreError as exc: + # Defensive: SDK only routes 429 → QuotaExceededError when body has + # `error.code='quota_exceeded'`. Real API always emits the code, but a + # mock or proxy returning bare `429` falls through to generic. Reroute by + # status_code so the gate's fail_open path still surfaces 'quota_exceeded'. + if exc.status_code == 429: + _log.warning("[gate] /v1/assess returned 429 (untyped — defensive)") + raise QuotaExceededError("quota_exceeded") from exc + # Wraps any other 401 (schema drift), 5xx, network errors, body-parse failures. + # Surface code so ops notice schema-drift cases instead of a silent 503. + _log.warning("[gate] /v1/assess call failed (%s): %s", exc.code, exc) + # Message format pinned for downstream merchant log scrapers. + status = exc.status_code or 0 + raise RuntimeError(f"AgentScore API returned {status}: {exc}") from exc + + result = self._project(cast("dict[str, Any]", data)) self._cache.set(key, result) return result @@ -207,12 +306,36 @@ async def acheck( if cached is not None: return cached - resp = await self._async_client.post( - f"{self._base_url}/v1/assess", - headers=self._headers(), - content=json.dumps(self._build_body(address, chain, operator_token)), - ) - result = self._parse_response(resp) + effective_chain = chain or self._chain + try: + data = await self._sdk.aassess( + address=address, + operator_token=operator_token, + chain=effective_chain, + policy=cast("DecisionPolicy | None", self._policy or None), + ) + except SdkPaymentRequiredError as exc: + raise PaymentRequiredError from exc + except SdkQuotaExceededError as exc: + _log.warning("[gate] /v1/assess returned 429") + raise QuotaExceededError("quota_exceeded") from exc + except SdkTokenExpiredError as exc: + raise TokenDeniedError(getattr(exc, "details", {}) or {}) from exc + except SdkInvalidCredentialError as exc: + raise InvalidCredentialError() from exc + except SdkTimeoutError as exc: + # Same re-raise pattern as the sync path; see :meth:`check`. + raise httpx.TimeoutException(str(exc)) from exc + except AgentScoreError as exc: + if exc.status_code == 429: + _log.warning("[gate] /v1/assess returned 429 (untyped — defensive)") + raise QuotaExceededError("quota_exceeded") from exc + _log.warning("[gate] /v1/assess call failed (%s): %s", exc.code, exc) + # Message format pinned for downstream merchant log scrapers. + status = exc.status_code or 0 + raise RuntimeError(f"AgentScore API returned {status}: {exc}") from exc + + result = self._project(cast("dict[str, Any]", data)) self._cache.set(key, result) return result @@ -236,20 +359,12 @@ def capture_wallet( Fire-and-forget: silently swallows non-fatal errors. ``idempotency_key`` (payment intent id, tx hash, …) lets the server dedupe agent retries of the same logical payment. """ - body: dict[str, Any] = { - "operator_token": operator_token, - "wallet_address": wallet_address, - "network": network, - } - if idempotency_key: - body["idempotency_key"] = idempotency_key - # Fire-and-forget: don't raise. Log so a persistent capture outage is visible - # to merchant ops — otherwise wallet↔operator linkage silently stops. try: - self._sync_client.post( - f"{self._base_url}/v1/credentials/wallets", - headers=self._headers(), - content=json.dumps(body), + self._sdk.associate_wallet( + operator_token=operator_token, + wallet_address=wallet_address, + network=network, + idempotency_key=idempotency_key, ) except Exception as err: _log.warning("capture_wallet failed: %s", err) @@ -262,19 +377,12 @@ async def acapture_wallet( idempotency_key: str | None = None, ) -> None: """Async variant of :meth:`capture_wallet`.""" - body: dict[str, Any] = { - "operator_token": operator_token, - "wallet_address": wallet_address, - "network": network, - } - if idempotency_key: - body["idempotency_key"] = idempotency_key - # Fire-and-forget: don't raise. Log so a persistent capture outage is visible. try: - await self._async_client.post( - f"{self._base_url}/v1/credentials/wallets", - headers=self._headers(), - content=json.dumps(body), + await self._sdk.aassociate_wallet( + operator_token=operator_token, + wallet_address=wallet_address, + network=network, + idempotency_key=idempotency_key, ) except Exception as err: _log.warning("acapture_wallet failed: %s", err) @@ -322,19 +430,13 @@ def _resolve_wallet_to_operator(self, wallet_address: str) -> tuple[bool, str | if hit: return True, op, links try: - resp = self._sync_client.post( - f"{self._base_url}/v1/assess", - headers=self._headers(), - content=json.dumps({"address": wallet}), - ) - except httpx.HTTPError: - return False, None, [] - if not resp.is_success: + data = self._sdk.assess(address=wallet) + except AgentScoreError: return False, None, [] - data: dict[str, Any] = resp.json() - self._cache.set(f"resolve:{wallet}", AssessResult(allow=True, raw=data)) - op_value = data.get("resolved_operator") - linked_raw = data.get("linked_wallets") + data_dict = cast("dict[str, Any]", data) + self._cache.set(f"resolve:{wallet}", AssessResult(allow=True, raw=data_dict)) + op_value = data_dict.get("resolved_operator") + linked_raw = data_dict.get("linked_wallets") linked = [w for w in linked_raw if isinstance(w, str)] if isinstance(linked_raw, list) else [] return True, (op_value if isinstance(op_value, str) else None), linked @@ -345,46 +447,73 @@ async def _aresolve_wallet_to_operator(self, wallet_address: str) -> tuple[bool, if hit: return True, op, links try: - resp = await self._async_client.post( - f"{self._base_url}/v1/assess", - headers=self._headers(), - content=json.dumps({"address": wallet}), - ) - except httpx.HTTPError: + data = await self._sdk.aassess(address=wallet) + except AgentScoreError: return False, None, [] - if not resp.is_success: - return False, None, [] - data: dict[str, Any] = resp.json() - self._cache.set(f"resolve:{wallet}", AssessResult(allow=True, raw=data)) - op_value = data.get("resolved_operator") - linked_raw = data.get("linked_wallets") + data_dict = cast("dict[str, Any]", data) + self._cache.set(f"resolve:{wallet}", AssessResult(allow=True, raw=data_dict)) + op_value = data_dict.get("resolved_operator") + linked_raw = data_dict.get("linked_wallets") linked = [w for w in linked_raw if isinstance(w, str)] if isinstance(linked_raw, list) else [] return True, (op_value if isinstance(op_value, str) else None), linked def _report_signer_event_sync(self, kind: str) -> None: - """Fire-and-forget telemetry post. Never raises.""" - try: - self._sync_client.post( - f"{self._base_url}/v1/telemetry/signer-match", - headers=self._headers(), - content=json.dumps({"kind": kind}), - ) - except Exception as err: - _log.warning("signer-match telemetry failed: %s", err) + """Fire-and-forget telemetry post. Never raises. + + The SDK's ``telemetry_signer_match`` already swallows all errors internally — + this method is just the commerce-side dispatch. + """ + self._sdk.telemetry_signer_match({"kind": kind}) async def _report_signer_event_async(self, kind: str) -> None: - try: - await self._async_client.post( - f"{self._base_url}/v1/telemetry/signer-match", - headers=self._headers(), - content=json.dumps({"kind": kind}), + """Async variant. SDK swallows all errors internally.""" + await self._sdk.atelemetry_signer_match({"kind": kind}) + + def _project_signer_match( + self, sm: dict[str, Any], claimed_norm: str, signer_norm: str + ) -> VerifyWalletSignerResult: + """Project the API's ``signer_match`` block onto :class:`VerifyWalletSignerResult`. + + The API authors agent_instructions, claimed/signer operators, and the linked-wallet + set (deny-guarded server-side); commerce just shapes those fields. + """ + kind = sm.get("kind") + if kind == "pass": + return VerifyWalletSignerResult( + kind="pass", + claimed_operator=sm.get("claimed_operator"), + signer_operator=sm.get("signer_operator"), ) - except Exception as err: - _log.warning("signer-match telemetry failed: %s", err) + if kind == "wallet_auth_requires_wallet_signing": + return VerifyWalletSignerResult( + kind="wallet_auth_requires_wallet_signing", + claimed_wallet=sm.get("claimed_wallet") or claimed_norm, + agent_instructions=sm.get("agent_instructions") or WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS, + ) + # Default: wallet_signer_mismatch + linked_raw = sm.get("linked_wallets") + linked = [w for w in linked_raw if isinstance(w, str)] if isinstance(linked_raw, list) else [] + return VerifyWalletSignerResult( + kind="wallet_signer_mismatch", + claimed_operator=sm.get("claimed_operator"), + actual_signer_operator=sm.get("signer_operator"), + expected_signer=sm.get("expected_signer") or claimed_norm, + actual_signer=sm.get("actual_signer") or signer_norm, + linked_wallets=linked, + agent_instructions=sm.get("agent_instructions") or WALLET_SIGNER_MISMATCH_INSTRUCTIONS, + ) + + def _infer_signer_network(self, signer: str) -> str: + return "evm" if signer.startswith("0x") else "solana" def verify_wallet_signer_match(self, options: VerifyWalletSignerMatchOptions) -> VerifyWalletSignerResult: """Verify payment signer resolves to the same operator as the claimed wallet. + Single-call path: makes one ``/v1/assess`` request with ``resolve_signer`` set; + the response carries a ``signer_match`` verdict the gate projects directly. Falls + back to a two-resolve path when the response has no ``signer_match`` so the gate + still produces a verdict. + Returns: ``kind='pass'`` when the signer is the claimed wallet (byte-equal) or both resolve to the same operator. ``kind='wallet_signer_mismatch'`` when operators differ. @@ -401,13 +530,54 @@ def verify_wallet_signer_match(self, options: VerifyWalletSignerMatchOptions) -> agent_instructions=WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS, ) # Network-aware normalization: lowercase EVM, preserve Solana base58. Both the - # byte-equal short-circuit AND the resolve-cache key derive from this — lowercasing + # byte-equal short-circuit AND the cache key derive from this — lowercasing # Solana would corrupt both and make every Solana signer-match return api_error. claimed = normalize_address(options.claimed_wallet) signer_norm = normalize_address(signer) if claimed == signer_norm: self._report_signer_event_sync("pass") return VerifyWalletSignerResult(kind="pass") + + # Cache hit: a prior call for this same (claimed, signer) pair populated signer_match. + # Skip both the round trip AND the SDK telemetry post (the API recorded it last time). + cached_entry = self._cache.get(claimed) + if cached_entry is not None: + cached_match = cached_entry.signer_match_by_signer.get(signer_norm) + if cached_match is not None: + return self._project_signer_match(cached_match, claimed, signer_norm) + + # Single resolve_signer-aware assess call — server-side resolves both wallets and + # returns a verdict in one round trip. + network = options.network or self._infer_signer_network(signer_norm) + try: + data = self._sdk.assess( + address=claimed, + resolve_signer=cast("ResolveSigner", {"address": signer_norm, "network": network}), + ) + except AgentScoreError as exc: + _log.warning("[gate] verify_wallet_signer_match assess failed: %s", exc) + self._report_signer_event_sync("api_error") + return VerifyWalletSignerResult(kind="api_error", claimed_wallet=claimed) + + data_dict = cast("dict[str, Any]", data) + sm = data_dict.get("signer_match") + if isinstance(sm, dict): + if cached_entry is not None: + # Mutate in place — TTLCache.get() returns a reference, so the stored + # entry sees the new sub-dict without a set() call. This preserves the + # gate's original cache TTL window (set() would reset it forward, + # causing the gate verdict to be served past its intended freshness). + cached_entry.signer_match_by_signer[signer_norm] = sm + else: + # No prior gate cache for this wallet — create a fresh entry with the + # verdict attached so a subsequent same-pair call hits cache. + entry = AssessResult(allow=True, raw=data_dict) + entry.signer_match_by_signer[signer_norm] = sm + self._cache.set(claimed, entry) + return self._project_signer_match(sm, claimed, signer_norm) + + # API response had no signer_match (server didn't compute one). Two-resolve + # path produces a verdict from the same operator graph. claimed_ok, claimed_op, claimed_links = self._resolve_wallet_to_operator(claimed) signer_ok, signer_op, _ = self._resolve_wallet_to_operator(signer_norm) if not claimed_ok or not signer_ok: @@ -437,12 +607,42 @@ async def averify_wallet_signer_match(self, options: VerifyWalletSignerMatchOpti claimed_wallet=options.claimed_wallet, agent_instructions=WALLET_AUTH_REQUIRES_WALLET_SIGNING_INSTRUCTIONS, ) - # Same network-aware normalization as the sync path. claimed = normalize_address(options.claimed_wallet) signer_norm = normalize_address(signer) if claimed == signer_norm: await self._report_signer_event_async("pass") return VerifyWalletSignerResult(kind="pass") + + cached_entry = self._cache.get(claimed) + if cached_entry is not None: + cached_match = cached_entry.signer_match_by_signer.get(signer_norm) + if cached_match is not None: + return self._project_signer_match(cached_match, claimed, signer_norm) + + network = options.network or self._infer_signer_network(signer_norm) + try: + data = await self._sdk.aassess( + address=claimed, + resolve_signer=cast("ResolveSigner", {"address": signer_norm, "network": network}), + ) + except AgentScoreError as exc: + _log.warning("[gate] averify_wallet_signer_match assess failed: %s", exc) + await self._report_signer_event_async("api_error") + return VerifyWalletSignerResult(kind="api_error", claimed_wallet=claimed) + + data_dict = cast("dict[str, Any]", data) + sm = data_dict.get("signer_match") + if isinstance(sm, dict): + if cached_entry is not None: + # Async mirror of the in-place-mutate optimization in :meth:`verify_wallet_signer_match`. + cached_entry.signer_match_by_signer[signer_norm] = sm + else: + entry = AssessResult(allow=True, raw=data_dict) + entry.signer_match_by_signer[signer_norm] = sm + self._cache.set(claimed, entry) + return self._project_signer_match(sm, claimed, signer_norm) + + # Legacy fallback — async mirror of the sync path. claimed_ok, claimed_op, claimed_links = await self._aresolve_wallet_to_operator(claimed) signer_ok, signer_op, _ = await self._aresolve_wallet_to_operator(signer_norm) if not claimed_ok or not signer_ok: @@ -463,10 +663,28 @@ async def averify_wallet_signer_match(self, options: VerifyWalletSignerMatchOpti ) +# Re-export the timeout error class so adapters can recognize SDK-side timeouts +# without having to import it from the underlying SDK directly. +__all_sdk_timeout__ = SdkTimeoutError + + class PaymentRequiredError(Exception): """Raised when the AgentScore API returns 402.""" +class QuotaExceededError(RuntimeError): + """Raised when /v1/assess returns 429. + + Distinct from a generic 5xx so adapters with ``fail_open=True`` can surface + ``infra_reason='quota_exceeded'`` to merchant logs/alerts. Compliance denials + are unaffected — those still deny regardless of fail_open. + + Subclasses ``RuntimeError`` so a broad ``except RuntimeError`` still catches the + 429 case; specific code that wants to distinguish 429 from generic 5xx catches + ``QuotaExceededError`` directly. + """ + + class TokenDeniedError(Exception): """Raised when /v1/assess returns 401 token_expired. diff --git a/agentscore_commerce/identity/django.py b/agentscore_commerce/identity/django.py index 1d39024..bc584eb 100644 --- a/agentscore_commerce/identity/django.py +++ b/agentscore_commerce/identity/django.py @@ -4,6 +4,7 @@ from typing import Any +import httpx from django.http import HttpRequest, JsonResponse from agentscore_commerce.identity._denial import ( @@ -14,11 +15,16 @@ is_fixable_denial, verification_agent_instructions, ) -from agentscore_commerce.identity._response import build_missing_identity_reason, denial_reason_to_body +from agentscore_commerce.identity._response import ( + QUOTA_EXCEEDED_INSTRUCTIONS, + build_missing_identity_reason, + denial_reason_to_body, +) from agentscore_commerce.identity.client import ( GateClient, InvalidCredentialError, PaymentRequiredError, + QuotaExceededError, TokenDeniedError, build_invalid_credential_reason, build_token_denied_reason, @@ -27,9 +33,11 @@ from agentscore_commerce.identity.types import ( AgentIdentity, DenialReason, + GateQuotaInfo, Network, VerifyWalletSignerMatchOptions, VerifyWalletSignerResult, + apply_degraded, ) from agentscore_commerce.payment.signer import ( extract_payment_signer, @@ -37,6 +45,12 @@ read_x402_payment_header, ) + +def _mark_degraded_django(request: HttpRequest, infra_reason: str) -> None: + """Stamp the gate state on a Django request as fail-open'd.""" + apply_degraded(getattr(request, "_agentscore_gate", None), infra_reason) + + DEFAULT_ADDRESS_HEADER = "HTTP_X_WALLET_ADDRESS" DEFAULT_TOKEN_HEADER = "HTTP_X_OPERATOR_TOKEN" @@ -53,6 +67,8 @@ "extract_payment_signer", "extract_payment_signer_address", "get_assess_data", + "get_gate_degraded_state", + "get_gate_quota_info", "is_fixable_denial", "read_x402_payment_header", "verification_agent_instructions", @@ -69,6 +85,31 @@ def get_assess_data(request: HttpRequest) -> dict[str, Any] | None: return getattr(request, ASSESS_STATE_KEY, None) +def get_gate_degraded_state(request: HttpRequest) -> dict[str, Any]: + """Return whether the gate fail-open'd due to AgentScore-side infra failure. + + Returns ``{"degraded": False}`` for normal allows; ``{"degraded": True, + "infra_reason": "quota_exceeded" | "api_error" | "network_timeout"}`` when bypassed. + """ + state = getattr(request, "_agentscore_gate", None) + if isinstance(state, dict) and state.get("degraded"): + return {"degraded": True, "infra_reason": state.get("infra_reason")} + return {"degraded": False} + + +def get_gate_quota_info(request: HttpRequest) -> GateQuotaInfo | None: + """Read AgentScore assess quota observability for this request. + + Captured from ``X-Quota-*`` response headers on this request's gate evaluate. + """ + state = getattr(request, "_agentscore_gate", None) + if isinstance(state, dict): + quota = state.get("quota") + if isinstance(quota, GateQuotaInfo): + return quota + return None + + class AgentScoreMiddleware: """Django middleware that gates requests based on AgentScore wallet reputation. @@ -163,37 +204,11 @@ def __call__(self, request: HttpRequest) -> Any: chain_override = self._extract_chain(request) + # Only check_identity is wrapped — get_response (which runs the downstream view) must + # NOT be in the try, otherwise an exception in the user's view would be misclassified + # as an AgentScore infra failure and (under fail_open) re-invoke their view. try: result = self._client.check_identity(identity, chain_override) - - if result.allow: - setattr(request, "agentscore", result.raw) # noqa: B010 — dynamic attribute attach on HttpRequest - return self.get_response(request) - - # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the - # same UX as missing_identity: the gate mints a fresh verification session, - # the agent polls until status=verified, gets a fresh opc_..., and retries - # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, - # jurisdiction_restricted) keep the bare wallet_not_trusted denial. - # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is - # verified (the user's KYC'd country is in the blocked list — re-doing KYC - # won't change the country). - if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None: - session_reason = try_create_session_denial_reason_sync( - self._create_session_on_missing, - self._client.user_agent, - request, - ) - if session_reason is not None: - return self._on_denied(request, session_reason) - - reason = DenialReason( - code="wallet_not_trusted", - decision=result.decision, - reasons=result.reasons, - verify_url=result.verify_url, - ) - return self._on_denied(request, reason) except PaymentRequiredError: if self._client.fail_open: return self.get_response(request) @@ -204,11 +219,58 @@ def __call__(self, request: HttpRequest) -> Any: except InvalidCredentialError: # Permanent — no auto-session, agent should switch tokens or restart. return self._on_denied(request, build_invalid_credential_reason()) + except QuotaExceededError: + if self._client.fail_open: + _mark_degraded_django(request, "quota_exceeded") + return self.get_response(request) + return self._on_denied( + request, + DenialReason(code="api_error", agent_instructions=QUOTA_EXCEEDED_INSTRUCTIONS), + ) + except httpx.TimeoutException: + if self._client.fail_open: + _mark_degraded_django(request, "network_timeout") + return self.get_response(request) + return self._on_denied(request, DenialReason(code="api_error")) except Exception: if self._client.fail_open: + _mark_degraded_django(request, "api_error") return self.get_response(request) return self._on_denied(request, DenialReason(code="api_error")) + if result.allow: + setattr(request, "agentscore", result.raw) # noqa: B010 — dynamic attribute attach on HttpRequest + if result.quota is not None: + state = getattr(request, "_agentscore_gate", None) + if isinstance(state, dict): + state["quota"] = result.quota + return self.get_response(request) + + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + # same UX as missing_identity: the gate mints a fresh verification session, + # the agent polls until status=verified, gets a fresh opc_..., and retries + # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, + # jurisdiction_restricted) keep the bare wallet_not_trusted denial. + # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is + # verified (the user's KYC'd country is in the blocked list — re-doing KYC + # won't change the country). + if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None: + session_reason = try_create_session_denial_reason_sync( + self._create_session_on_missing, + self._client.user_agent, + request, + ) + if session_reason is not None: + return self._on_denied(request, session_reason) + + reason = DenialReason( + code="wallet_not_trusted", + decision=result.decision, + reasons=result.reasons, + verify_url=result.verify_url, + ) + return self._on_denied(request, reason) + def verify_wallet_signer_match( request: HttpRequest, diff --git a/agentscore_commerce/identity/fastapi.py b/agentscore_commerce/identity/fastapi.py index ce2851f..b51bb09 100644 --- a/agentscore_commerce/identity/fastapi.py +++ b/agentscore_commerce/identity/fastapi.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, NoReturn +import httpx from starlette.requests import Request # noqa: TC002 - runtime import required for FastAPI DI from agentscore_commerce.identity._denial import ( @@ -20,11 +21,16 @@ is_fixable_denial, verification_agent_instructions, ) -from agentscore_commerce.identity._response import build_missing_identity_reason, denial_reason_to_body +from agentscore_commerce.identity._response import ( + QUOTA_EXCEEDED_INSTRUCTIONS, + build_missing_identity_reason, + denial_reason_to_body, +) from agentscore_commerce.identity.client import ( GateClient, InvalidCredentialError, PaymentRequiredError, + QuotaExceededError, TokenDeniedError, build_invalid_credential_reason, build_token_denied_reason, @@ -33,9 +39,11 @@ from agentscore_commerce.identity.types import ( AgentIdentity, DenialReason, + GateQuotaInfo, Network, VerifyWalletSignerMatchOptions, VerifyWalletSignerResult, + apply_degraded, ) from agentscore_commerce.payment.signer import ( extract_payment_signer, @@ -51,6 +59,48 @@ GATE_STATE_KEY = "__agentscore_gate" ASSESS_STATE_KEY = "agentscore" + +def _mark_degraded(request: Request, infra_reason: str) -> None: + """Stamp the per-request gate state on ``request.state`` as fail-open'd. + + Resolves the framework-specific state container; the shared mutation + contract lives in :func:`apply_degraded`. + """ + apply_degraded(getattr(request.state, GATE_STATE_KEY, None), infra_reason) + + +def get_gate_degraded_state(request: Request) -> dict[str, Any]: + """Return whether the gate fail-open'd due to AgentScore-side infra failure. + + Returns ``{"degraded": False}`` for normal allows; ``{"degraded": True, + "infra_reason": "quota_exceeded" | "api_error" | "network_timeout"}`` when the gate + was bypassed (compliance NOT enforced — log/alert). + + Only set when ``fail_open=True`` was configured AND the failure was an infra failure. + Real compliance denials never trigger fail-open and so never set this flag. + """ + state = getattr(request.state, GATE_STATE_KEY, None) + if isinstance(state, dict) and state.get("degraded"): + return {"degraded": True, "infra_reason": state.get("infra_reason")} + return {"degraded": False} + + +def get_gate_quota_info(request: Request) -> GateQuotaInfo | None: + """Read AgentScore assess quota observability for this request. + + Captured from ``X-Quota-*`` response headers on this request's gate evaluate. + Returns ``None`` when the request was a fail-open pass-through (no assess call) + or when the API didn't emit quota headers (Enterprise / unlimited tiers). + Use to monitor approach-to-cap proactively (warn at 80%, alert at 95%). + """ + state = getattr(request.state, GATE_STATE_KEY, None) + if isinstance(state, dict): + quota = state.get("quota") + if isinstance(quota, GateQuotaInfo): + return quota + return None + + __all__ = [ "FIXABLE_DENIAL_REASONS", "AgentScoreGate", @@ -63,6 +113,8 @@ "extract_payment_signer", "extract_payment_signer_address", "get_assess_data", + "get_gate_degraded_state", + "get_gate_quota_info", "is_fixable_denial", "read_x402_payment_header", "verification_agent_instructions", @@ -198,14 +250,32 @@ async def __call__(self, request: Request) -> None: except InvalidCredentialError: # Permanent — no auto-session, agent should switch tokens or restart. self._deny(request, build_invalid_credential_reason()) + except QuotaExceededError: + if self._client.fail_open: + _mark_degraded(request, "quota_exceeded") + return + self._deny(request, DenialReason(code="api_error", agent_instructions=QUOTA_EXCEEDED_INSTRUCTIONS)) + return + except httpx.TimeoutException: + if self._client.fail_open: + _mark_degraded(request, "network_timeout") + return + self._deny(request, DenialReason(code="api_error")) + return except Exception: if self._client.fail_open: + _mark_degraded(request, "api_error") return self._deny(request, DenialReason(code="api_error")) return if result.allow: setattr(request.state, ASSESS_STATE_KEY, result.raw) + # Stash quota on gate state so get_gate_quota_info(request) can read it. + if result.quota is not None: + state = getattr(request.state, GATE_STATE_KEY, None) + if isinstance(state, dict): + state["quota"] = result.quota return # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the diff --git a/agentscore_commerce/identity/flask.py b/agentscore_commerce/identity/flask.py index 9b4d2dd..cd866d6 100644 --- a/agentscore_commerce/identity/flask.py +++ b/agentscore_commerce/identity/flask.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING, Any +import httpx + from agentscore_commerce.identity._denial import ( FIXABLE_DENIAL_REASONS, build_contact_support_next_steps, @@ -12,11 +14,16 @@ is_fixable_denial, verification_agent_instructions, ) -from agentscore_commerce.identity._response import build_missing_identity_reason, denial_reason_to_body +from agentscore_commerce.identity._response import ( + QUOTA_EXCEEDED_INSTRUCTIONS, + build_missing_identity_reason, + denial_reason_to_body, +) from agentscore_commerce.identity.client import ( GateClient, InvalidCredentialError, PaymentRequiredError, + QuotaExceededError, TokenDeniedError, build_invalid_credential_reason, build_token_denied_reason, @@ -25,9 +32,11 @@ from agentscore_commerce.identity.types import ( AgentIdentity, DenialReason, + GateQuotaInfo, Network, VerifyWalletSignerMatchOptions, VerifyWalletSignerResult, + apply_degraded, ) from agentscore_commerce.payment.signer import ( extract_payment_signer, @@ -56,6 +65,8 @@ "extract_payment_signer", "extract_payment_signer_address", "get_assess_data", + "get_gate_degraded_state", + "get_gate_quota_info", "is_fixable_denial", "read_x402_payment_header", "verification_agent_instructions", @@ -74,6 +85,38 @@ def get_assess_data() -> dict[str, Any] | None: return getattr(g, ASSESS_STATE_KEY, None) +def get_gate_degraded_state() -> dict[str, Any]: + """Return whether the gate fail-open'd due to AgentScore-side infra failure. + + Returns ``{"degraded": False}`` for normal allows; ``{"degraded": True, + "infra_reason": "quota_exceeded" | "api_error" | "network_timeout"}`` when bypassed. + Only set when ``fail_open=True`` AND the failure was infra-shape. + """ + from flask import g + + state = getattr(g, "_agentscore_gate", None) + if isinstance(state, dict) and state.get("degraded"): + return {"degraded": True, "infra_reason": state.get("infra_reason")} + return {"degraded": False} + + +def get_gate_quota_info() -> GateQuotaInfo | None: + """Read AgentScore assess quota observability for this request. + + Captured from ``X-Quota-*`` response headers on this request's gate evaluate. + Returns ``None`` when the request was a fail-open pass-through or when the API + didn't emit quota headers. + """ + from flask import g + + state = getattr(g, "_agentscore_gate", None) + if isinstance(state, dict): + quota = state.get("quota") + if isinstance(quota, GateQuotaInfo): + return quota + return None + + def _default_extract_identity(request: Request) -> AgentIdentity | None: token = request.headers.get(DEFAULT_TOKEN_HEADER) addr = request.headers.get(DEFAULT_ADDRESS_HEADER) @@ -152,6 +195,10 @@ def _deny(reason: DenialReason) -> tuple[Response, int]: raise TypeError(msg) from exc return jsonify(body), status + def _mark_degraded(infra_reason: str) -> None: + """Stamp the gate state on ``g._agentscore_gate`` as fail-open'd.""" + apply_degraded(getattr(g, "_agentscore_gate", None), infra_reason) + @app.before_request def _agentscore_check() -> Response | tuple[Response, int] | None: identity = _resolve_identity(flask_request) @@ -182,6 +229,10 @@ def _agentscore_check() -> Response | tuple[Response, int] | None: if result.allow: g.agentscore = result.raw + if result.quota is not None: + state = getattr(g, "_agentscore_gate", None) + if isinstance(state, dict): + state["quota"] = result.quota return None # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the @@ -218,10 +269,21 @@ def _agentscore_check() -> Response | tuple[Response, int] | None: except InvalidCredentialError: # Permanent — no auto-session, agent should switch tokens or restart. return _deny(build_invalid_credential_reason()) + except QuotaExceededError: + if client.fail_open: + _mark_degraded("quota_exceeded") + return None + return _deny(DenialReason(code="api_error", agent_instructions=QUOTA_EXCEEDED_INSTRUCTIONS)) + except httpx.TimeoutException: + if client.fail_open: + _mark_degraded("network_timeout") + return None + return _deny(DenialReason(code="api_error")) except TypeError: raise except Exception: if client.fail_open: + _mark_degraded("api_error") return None return _deny(DenialReason(code="api_error")) diff --git a/agentscore_commerce/identity/middleware.py b/agentscore_commerce/identity/middleware.py index 439022c..30dfa1c 100644 --- a/agentscore_commerce/identity/middleware.py +++ b/agentscore_commerce/identity/middleware.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any +import httpx from starlette.requests import Request from starlette.responses import JSONResponse @@ -15,11 +16,16 @@ is_fixable_denial, verification_agent_instructions, ) -from agentscore_commerce.identity._response import build_missing_identity_reason, denial_reason_to_body +from agentscore_commerce.identity._response import ( + QUOTA_EXCEEDED_INSTRUCTIONS, + build_missing_identity_reason, + denial_reason_to_body, +) from agentscore_commerce.identity.client import ( GateClient, InvalidCredentialError, PaymentRequiredError, + QuotaExceededError, TokenDeniedError, build_invalid_credential_reason, build_token_denied_reason, @@ -28,9 +34,11 @@ from agentscore_commerce.identity.types import ( AgentIdentity, DenialReason, + GateQuotaInfo, Network, VerifyWalletSignerMatchOptions, VerifyWalletSignerResult, + apply_degraded, ) from agentscore_commerce.payment.signer import ( extract_payment_signer, @@ -48,6 +56,12 @@ GATE_STATE_KEY = "__agentscore_gate" ASSESS_STATE_KEY = "agentscore" + +def _mark_degraded_asgi(scope: Scope, infra_reason: str) -> None: + """Stamp the gate state on the ASGI scope as fail-open'd.""" + apply_degraded(scope.get("state", {}).get(GATE_STATE_KEY), infra_reason) + + __all__ = [ "FIXABLE_DENIAL_REASONS", "AgentScoreGate", @@ -60,6 +74,8 @@ "extract_payment_signer", "extract_payment_signer_address", "get_assess_data", + "get_gate_degraded_state", + "get_gate_quota_info", "is_fixable_denial", "read_x402_payment_header", "verification_agent_instructions", @@ -77,6 +93,31 @@ def get_assess_data(request: Request) -> dict[str, Any] | None: return state.get(ASSESS_STATE_KEY) +def get_gate_degraded_state(request: Request) -> dict[str, Any]: + """Return whether the gate fail-open'd due to AgentScore-side infra failure. + + Returns ``{"degraded": False}`` for normal allows; ``{"degraded": True, + "infra_reason": "quota_exceeded" | "api_error" | "network_timeout"}`` when bypassed. + """ + state = (request.scope.get("state") or {}).get(GATE_STATE_KEY) + if isinstance(state, dict) and state.get("degraded"): + return {"degraded": True, "infra_reason": state.get("infra_reason")} + return {"degraded": False} + + +def get_gate_quota_info(request: Request) -> GateQuotaInfo | None: + """Read AgentScore assess quota observability for this request. + + Captured from ``X-Quota-*`` response headers on this request's gate evaluate. + """ + state = (request.scope.get("state") or {}).get(GATE_STATE_KEY) + if isinstance(state, dict): + quota = state.get("quota") + if isinstance(quota, GateQuotaInfo): + return quota + return None + + def _default_extract_identity(request: Request) -> AgentIdentity | None: token = request.headers.get(DEFAULT_TOKEN_HEADER) addr = request.headers.get(DEFAULT_ADDRESS_HEADER) @@ -182,42 +223,13 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await response(scope, receive, send) return + chain_override = self._extract_chain(request) if self._extract_chain else None + + # Only acheck_identity is wrapped — `await self.app(...)` (which runs the downstream + # ASGI app) must NOT be in the try, otherwise an exception in the user's app would + # be misclassified as an AgentScore infra failure and (under fail_open) re-invoke it. try: - chain_override = self._extract_chain(request) if self._extract_chain else None result = await self._client.acheck_identity(identity, chain_override) - - if result.allow: - scope["state"] = {**scope.get("state", {}), "agentscore": result.raw} - await self.app(scope, receive, send) - return - - # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the - # same UX as missing_identity: the gate mints a fresh verification session, - # the agent polls until status=verified, gets a fresh opc_..., and retries - # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, - # jurisdiction_restricted) keep the bare wallet_not_trusted denial. - # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is - # verified (the user's KYC'd country is in the blocked list — re-doing KYC - # won't change the country). - if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None: - session_reason = await try_create_session_denial_reason( - self._create_session_on_missing, - self._client.user_agent, - request, - ) - if session_reason is not None: - response = await self._on_denied(request, session_reason) - await response(scope, receive, send) - return - - reason = DenialReason( - code="wallet_not_trusted", - decision=result.decision, - reasons=result.reasons, - verify_url=result.verify_url, - ) - response = await self._on_denied(request, reason) - await response(scope, receive, send) except PaymentRequiredError: if self._client.fail_open: await self.app(scope, receive, send) @@ -225,22 +237,82 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: reason = DenialReason(code="payment_required") response = await self._on_denied(request, reason) await response(scope, receive, send) + return except TokenDeniedError as err: reason = build_token_denied_reason(err) response = await self._on_denied(request, reason) await response(scope, receive, send) + return except InvalidCredentialError: # Permanent — no auto-session, agent should switch tokens or restart. reason = build_invalid_credential_reason() response = await self._on_denied(request, reason) await response(scope, receive, send) + return + except QuotaExceededError: + if self._client.fail_open: + _mark_degraded_asgi(scope, "quota_exceeded") + await self.app(scope, receive, send) + return + reason = DenialReason(code="api_error", agent_instructions=QUOTA_EXCEEDED_INSTRUCTIONS) + response = await self._on_denied(request, reason) + await response(scope, receive, send) + return + except httpx.TimeoutException: + if self._client.fail_open: + _mark_degraded_asgi(scope, "network_timeout") + await self.app(scope, receive, send) + return + reason = DenialReason(code="api_error") + response = await self._on_denied(request, reason) + await response(scope, receive, send) + return except Exception: if self._client.fail_open: + _mark_degraded_asgi(scope, "api_error") await self.app(scope, receive, send) return reason = DenialReason(code="api_error") response = await self._on_denied(request, reason) await response(scope, receive, send) + return + + if result.allow: + scope["state"] = {**scope.get("state", {}), "agentscore": result.raw} + if result.quota is not None: + state = scope["state"].get(GATE_STATE_KEY) + if isinstance(state, dict): + state["quota"] = result.quota + await self.app(scope, receive, send) + return + + # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the + # same UX as missing_identity: the gate mints a fresh verification session, + # the agent polls until status=verified, gets a fresh opc_..., and retries + # with X-Operator-Token. Unfixable reasons (sanctions_flagged, age_insufficient, + # jurisdiction_restricted) keep the bare wallet_not_trusted denial. + # `jurisdiction_restricted` is unfixable: the API only emits it after KYC is + # verified (the user's KYC'd country is in the blocked list — re-doing KYC + # won't change the country). + if is_fixable_denial(result.reasons) and self._create_session_on_missing is not None: + session_reason = await try_create_session_denial_reason( + self._create_session_on_missing, + self._client.user_agent, + request, + ) + if session_reason is not None: + response = await self._on_denied(request, session_reason) + await response(scope, receive, send) + return + + reason = DenialReason( + code="wallet_not_trusted", + decision=result.decision, + reasons=result.reasons, + verify_url=result.verify_url, + ) + response = await self._on_denied(request, reason) + await response(scope, receive, send) async def verify_wallet_signer_match( diff --git a/agentscore_commerce/identity/sanic.py b/agentscore_commerce/identity/sanic.py index 7126142..7827512 100644 --- a/agentscore_commerce/identity/sanic.py +++ b/agentscore_commerce/identity/sanic.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING, Any +import httpx + from agentscore_commerce.identity._denial import ( FIXABLE_DENIAL_REASONS, build_contact_support_next_steps, @@ -12,11 +14,16 @@ is_fixable_denial, verification_agent_instructions, ) -from agentscore_commerce.identity._response import build_missing_identity_reason, denial_reason_to_body +from agentscore_commerce.identity._response import ( + QUOTA_EXCEEDED_INSTRUCTIONS, + build_missing_identity_reason, + denial_reason_to_body, +) from agentscore_commerce.identity.client import ( GateClient, InvalidCredentialError, PaymentRequiredError, + QuotaExceededError, TokenDeniedError, build_invalid_credential_reason, build_token_denied_reason, @@ -25,9 +32,11 @@ from agentscore_commerce.identity.types import ( AgentIdentity, DenialReason, + GateQuotaInfo, Network, VerifyWalletSignerMatchOptions, VerifyWalletSignerResult, + apply_degraded, ) from agentscore_commerce.payment.signer import ( extract_payment_signer, @@ -45,6 +54,12 @@ GATE_STATE_ATTR = "_agentscore_gate" ASSESS_STATE_ATTR = "agentscore" + +def _mark_degraded_sanic(request: Request, infra_reason: str) -> None: + """Stamp the gate state on a Sanic request as fail-open'd.""" + apply_degraded(getattr(request.ctx, GATE_STATE_ATTR, None), infra_reason) + + __all__ = [ "FIXABLE_DENIAL_REASONS", "CreateSessionOnMissing", @@ -57,6 +72,8 @@ "extract_payment_signer", "extract_payment_signer_address", "get_assess_data", + "get_gate_degraded_state", + "get_gate_quota_info", "is_fixable_denial", "read_x402_payment_header", "verification_agent_instructions", @@ -73,6 +90,31 @@ def get_assess_data(request: Request) -> dict[str, Any] | None: return getattr(request.ctx, ASSESS_STATE_ATTR, None) +def get_gate_degraded_state(request: Request) -> dict[str, Any]: + """Return whether the gate fail-open'd due to AgentScore-side infra failure. + + Returns ``{"degraded": False}`` for normal allows; ``{"degraded": True, + "infra_reason": "quota_exceeded" | "api_error" | "network_timeout"}`` when bypassed. + """ + state = getattr(request.ctx, GATE_STATE_ATTR, None) + if isinstance(state, dict) and state.get("degraded"): + return {"degraded": True, "infra_reason": state.get("infra_reason")} + return {"degraded": False} + + +def get_gate_quota_info(request: Request) -> GateQuotaInfo | None: + """Read AgentScore assess quota observability for this request. + + Captured from ``X-Quota-*`` response headers on this request's gate evaluate. + """ + state = getattr(request.ctx, GATE_STATE_ATTR, None) + if isinstance(state, dict): + quota = state.get("quota") + if isinstance(quota, GateQuotaInfo): + return quota + return None + + def _default_extract_identity(request: Request) -> AgentIdentity | None: token = request.headers.get(DEFAULT_TOKEN_HEADER) addr = request.headers.get(DEFAULT_ADDRESS_HEADER) @@ -179,6 +221,10 @@ async def _agentscore_check(request: Request) -> HTTPResponse | None: if result.allow: request.ctx.agentscore = result.raw + if result.quota is not None: + state = getattr(request.ctx, GATE_STATE_ATTR, None) + if isinstance(state, dict): + state["quota"] = result.quota return None # Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the @@ -220,8 +266,24 @@ async def _agentscore_check(request: Request) -> HTTPResponse | None: # Permanent — no auto-session, agent should switch tokens or restart. body, status = _on_denied(request, build_invalid_credential_reason()) return response.json(body, status=status) + except QuotaExceededError: + if client.fail_open: + _mark_degraded_sanic(request, "quota_exceeded") + return None + body, status = _on_denied( + request, + DenialReason(code="api_error", agent_instructions=QUOTA_EXCEEDED_INSTRUCTIONS), + ) + return response.json(body, status=status) + except httpx.TimeoutException: + if client.fail_open: + _mark_degraded_sanic(request, "network_timeout") + return None + body, status = _on_denied(request, DenialReason(code="api_error")) + return response.json(body, status=status) except Exception: if client.fail_open: + _mark_degraded_sanic(request, "api_error") return None body, status = _on_denied(request, DenialReason(code="api_error")) return response.json(body, status=status) diff --git a/agentscore_commerce/identity/sessions.py b/agentscore_commerce/identity/sessions.py index 846ce9c..bab4f64 100644 --- a/agentscore_commerce/identity/sessions.py +++ b/agentscore_commerce/identity/sessions.py @@ -6,10 +6,10 @@ import json import logging from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, cast -import httpx +from agentscore import AgentScore, AgentScoreError from agentscore_commerce.identity.types import DenialReason, build_agent_memory_hint @@ -62,29 +62,16 @@ async def _maybe_await(value: _Hookable) -> Any: return value -def _session_headers(cfg: CreateSessionOnMissing, user_agent: str) -> dict[str, str]: - return { - "X-API-Key": cfg.api_key, - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": user_agent, - } - - -def _session_url(cfg: CreateSessionOnMissing) -> str: - return f"{cfg.base_url.rstrip('/')}/v1/sessions" - - -def _base_session_body(cfg: CreateSessionOnMissing) -> dict[str, Any]: - body: dict[str, Any] = {} - if cfg.context is not None: - body["context"] = cfg.context - if cfg.product_name is not None: - body["product_name"] = cfg.product_name - return body +def _build_sdk(cfg: CreateSessionOnMissing, user_agent: str) -> AgentScore: + return AgentScore(api_key=cfg.api_key, base_url=cfg.base_url, user_agent=user_agent) def _apply_dynamic_options(body: dict[str, Any], dynamic: Any) -> dict[str, Any]: + """Merge a per-request override dict over a base body. + + Non-dict ``dynamic`` is treated as a no-op so hooks may return ``None`` without + crashing the path. + """ if not isinstance(dynamic, dict): return body if dynamic.get("context") is not None: @@ -97,6 +84,16 @@ def _apply_dynamic_options(body: dict[str, Any], dynamic: Any) -> dict[str, Any] return body +def _resolved_session_options(cfg: CreateSessionOnMissing, dynamic: Any) -> dict[str, Any]: + """Merge static cfg fields with any dynamic per-request override dict.""" + options: dict[str, Any] = {} + if cfg.context is not None: + options["context"] = cfg.context + if cfg.product_name is not None: + options["product_name"] = cfg.product_name + return _apply_dynamic_options(options, dynamic) + + def _session_denial_reason( data: dict[str, Any], extra: dict[str, Any] | None = None, @@ -150,25 +147,21 @@ async def try_create_session_denial_reason( if set — both may be sync or async. """ try: - body = _base_session_body(cfg) + dynamic: Any = None if cfg.get_session_options is not None and ctx is not None: try: dynamic = await _maybe_await(cfg.get_session_options(ctx)) - body = _apply_dynamic_options(body, dynamic) except Exception as err: logger.warning("get_session_options hook failed: %s", err) + dynamic = None - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post( - _session_url(cfg), - headers=_session_headers(cfg, user_agent), - json=body, - ) - if not resp.is_success: + options = _resolved_session_options(cfg, dynamic) + sdk = _build_sdk(cfg, user_agent) + try: + data = dict(await sdk.acreate_session(**options)) + except AgentScoreError: return None - data = resp.json() - extra: dict[str, Any] | None = None if cfg.on_before_session is not None and ctx is not None: try: @@ -194,29 +187,26 @@ def try_create_session_denial_reason_sync( async hook is passed in a sync adapter config, it's skipped with a warning. """ try: - body = _base_session_body(cfg) + dynamic: Any = None if cfg.get_session_options is not None and ctx is not None: try: - dynamic = cfg.get_session_options(ctx) - if inspect.iscoroutine(dynamic): + hook_dynamic = cfg.get_session_options(ctx) + if inspect.iscoroutine(hook_dynamic): logger.warning("get_session_options returned a coroutine in a sync adapter — skipping") - dynamic.close() + hook_dynamic.close() else: - body = _apply_dynamic_options(body, dynamic) + dynamic = hook_dynamic except Exception as err: logger.warning("get_session_options hook failed: %s", err) + dynamic = None - with httpx.Client(timeout=10.0) as client: - resp = client.post( - _session_url(cfg), - headers=_session_headers(cfg, user_agent), - json=body, - ) - if not resp.is_success: + options = _resolved_session_options(cfg, dynamic) + sdk = _build_sdk(cfg, user_agent) + try: + data = dict(sdk.create_session(**options)) + except AgentScoreError: return None - data = resp.json() - extra: dict[str, Any] | None = None if cfg.on_before_session is not None and ctx is not None: try: @@ -232,8 +222,3 @@ def try_create_session_denial_reason_sync( return _session_denial_reason(data, extra) except Exception: return None - - -# Backwards-compat placeholder: old call sites that don't pass ctx still work because -# the parameter defaults to None. Adapters updated in this change always pass ctx. -_ = field # keep import stable for downstream tools that inspect the module diff --git a/agentscore_commerce/identity/types.py b/agentscore_commerce/identity/types.py index f1b4587..2832100 100644 --- a/agentscore_commerce/identity/types.py +++ b/agentscore_commerce/identity/types.py @@ -95,7 +95,10 @@ class VerifyWalletSignerMatchOptions: claimed_wallet: str signer: str | None - network: Network = "evm" + # Optional explicit network. When omitted, the gate infers from the signer's address + # shape (EVM `0x...` → "evm", base58 → "solana"). Parity with node-commerce, where + # this field is optional with no default. + network: Network | None = None VerifyWalletSignerKind = Literal[ @@ -266,6 +269,37 @@ class PolicyResult: checks: list[PolicyCheck] = field(default_factory=list) +FailOpenInfraReason = Literal["quota_exceeded", "api_error", "network_timeout"] + + +def apply_degraded(state: dict[str, Any] | None, infra_reason: FailOpenInfraReason | str) -> None: + """Mark a per-request gate state dict as degraded due to AgentScore-side infra failure. + + Per-adapter helpers resolve the state container in the framework's request-scoped store + (``request.state`` on FastAPI, ``g`` on Flask, attribute on Django, mapping on aiohttp, + ``request.ctx`` on Sanic, ``scope["state"]`` on ASGI) and hand that dict here. Keeps the + contract — `degraded: True` + `infra_reason` — in one place across all 6 adapters. + """ + if isinstance(state, dict): + state["degraded"] = True + state["infra_reason"] = infra_reason + + +@dataclass +class GateQuotaInfo: + """Per-account assess quota observability captured from ``X-Quota-*`` response headers. + + Mirrors the SDK's ``QuotaInfo`` shape. Use to monitor approach-to-cap proactively + (warn at 80%, alert at 95%). Numeric fields are ``None`` when the API didn't include + the header (Enterprise / unlimited tiers). + """ + + limit: int | None + used: int | None + # ISO-8601 timestamp, or the literal string "never" for unlimited tiers. + reset: str | None + + @dataclass class AssessResult: """Result from the AgentScore assess API.""" @@ -279,3 +313,13 @@ class AssessResult: verify_url: str | None = None policy_result: PolicyResult | None = None raw: dict[str, Any] | None = None + # Per-account assess quota captured from X-Quota-* response headers. Absent on + # Enterprise / unlimited tiers, or when the gate didn't call assess. + quota: GateQuotaInfo | None = None + # Per-signer wallet-match verdicts cached from prior verify_wallet_signer_match() calls + # for this same claimed wallet. Each signer gets its own slot so two payments under the + # same claimed identity but from different signer wallets don't serve stale verdicts to + # each other. Verdicts come from the API's ``signer_match`` response field (populated + # when the assess request carried ``resolve_signer``), so reading a hit skips the round + # trip altogether. + signer_match_by_signer: dict[str, dict[str, Any]] = field(default_factory=dict) diff --git a/examples/README.md b/examples/README.md index eb509a1..0878f5f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,7 +29,7 @@ All seven examples follow the same rough shape: 2. **Discovery routes:** `/openapi.json` + `/.well-known/mpp.json` + `/llms.txt` (omitted in these focused examples; see node-commerce for the discovery wiring) 3. **Per-request:** identity gate → validate body → 402 challenge (built via commerce/challenge helpers) → settle payment → return result -The commerce SDK keeps every step ~5–15 lines instead of ~50–150 lines. Vendors compose; commerce wraps the protocol-correctness boilerplate. +AgentScore Commerce keeps every step ~5–15 lines instead of ~50–150 lines. Vendors compose; the SDK wraps the protocol-correctness boilerplate. ## What stays vendor-specific @@ -41,7 +41,7 @@ These examples are intentionally thin on domain logic. Vendors plug in their own - Tax / shipping calculators - Frontend UI (none of these examples include one — they're agent-only APIs) -Commerce handles the agent commerce protocol layer; everything else is your business. +AgentScore Commerce handles the agent commerce protocol layer; everything else is your business. ## Differences from node-commerce examples diff --git a/pyproject.toml b/pyproject.toml index a0c54f2..d299654 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "1.0.3" +version = "1.1.0" description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce." readme = "README.md" license = "MIT" @@ -12,7 +12,7 @@ requires-python = ">=3.11" keywords = ["agentscore", "agent-commerce", "agentic-payments", "402", "x402", "mpp", "machine-payments-protocol", "fastapi", "starlette", "flask", "django", "aiohttp", "sanic", "middleware", "trust", "reputation", "kyc", "identity", "stripe", "tempo", "solana", "base", "ai-agent"] dependencies = [ "httpx>=0.25.0,<1.0.0", - "agentscore-py>=2.0.0", + "agentscore-py>=2.1.0", ] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/tests/test_aiohttp.py b/tests/test_aiohttp.py index c7b045f..929be3f 100644 --- a/tests/test_aiohttp.py +++ b/tests/test_aiohttp.py @@ -11,7 +11,12 @@ from aiohttp import web from aiohttp.test_utils import TestClient, TestServer -from agentscore_commerce.identity.aiohttp import agentscore_gate_middleware, capture_wallet, get_assess_data +from agentscore_commerce.identity.aiohttp import ( + GATE_STATE_KEY, + agentscore_gate_middleware, + capture_wallet, + get_assess_data, +) from agentscore_commerce.identity.sessions import CreateSessionOnMissing ASSESS_URL = "https://api.agentscore.sh/v1/assess" @@ -155,6 +160,111 @@ async def test_fail_open_allows_through_on_api_error(self): resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"}) assert resp.status == 200 + @pytest.mark.asyncio + @respx.mock + async def test_get_gate_degraded_state_returns_default_for_normal_allow(self): + from agentscore_commerce.identity.aiohttp import get_gate_degraded_state + + _mock_assess("allow") + + captured: dict = {} + + async def _snoop(request: web.Request) -> web.Response: + captured.update(get_gate_degraded_state(request)) + return web.json_response({"ok": True}) + + client = await _client(_make_app(handler=_snoop)) + async with client: + resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 200 + assert captured == {"degraded": False} + + @pytest.mark.asyncio + @respx.mock + async def test_get_gate_degraded_state_returns_infra_reason_when_degraded(self): + from agentscore_commerce.identity.aiohttp import get_gate_degraded_state + + respx.post(ASSESS_URL).mock(return_value=httpx.Response(429)) + + captured: dict = {} + + async def _snoop(request: web.Request) -> web.Response: + captured.update(get_gate_degraded_state(request)) + return web.json_response({"ok": True}) + + client = await _client(_make_app(handler=_snoop, fail_open=True)) + async with client: + resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 200 + assert captured == {"degraded": True, "infra_reason": "quota_exceeded"} + + @pytest.mark.asyncio + @respx.mock + async def test_quota_exceeded_returns_503_when_fail_closed(self): + respx.post(ASSESS_URL).mock(return_value=httpx.Response(429)) + client = await _client(_make_app()) + async with client: + resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 503 + body = await resp.json() + assert body["error"]["code"] == "api_error" + instructions = json.loads(body["agent_instructions"]) + assert instructions["action"] == "contact_merchant" + assert "merchant-side issue" in instructions["steps"][0] + + @pytest.mark.asyncio + @respx.mock + async def test_quota_exceeded_marks_degraded_when_fail_open(self): + respx.post(ASSESS_URL).mock(return_value=httpx.Response(429)) + + async def _snoop(request: web.Request) -> web.Response: + state = request.get(GATE_STATE_KEY) or {} + return web.json_response({k: v for k, v in state.items() if k != "client"}) + + client = await _client(_make_app(handler=_snoop, fail_open=True)) + async with client: + resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 200 + data = await resp.json() + assert data.get("degraded") is True + assert data.get("infra_reason") == "quota_exceeded" + + @pytest.mark.asyncio + async def test_timeout_marks_degraded_when_fail_open(self): + async def _snoop(request: web.Request) -> web.Response: + state = request.get(GATE_STATE_KEY) or {} + return web.json_response({k: v for k, v in state.items() if k != "client"}) + + with patch( + "agentscore_commerce.identity.aiohttp.GateClient.acheck_identity", + side_effect=httpx.TimeoutException("read timeout"), + ): + client = await _client(_make_app(handler=_snoop, fail_open=True)) + async with client: + resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 200 + data = await resp.json() + assert data.get("degraded") is True + assert data.get("infra_reason") == "network_timeout" + + @pytest.mark.asyncio + async def test_generic_exception_marks_degraded_when_fail_open(self): + async def _snoop(request: web.Request) -> web.Response: + state = request.get(GATE_STATE_KEY) or {} + return web.json_response({k: v for k, v in state.items() if k != "client"}) + + with patch( + "agentscore_commerce.identity.aiohttp.GateClient.acheck_identity", + side_effect=RuntimeError("oops"), + ): + client = await _client(_make_app(handler=_snoop, fail_open=True)) + async with client: + resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 200 + data = await resp.json() + assert data.get("degraded") is True + assert data.get("infra_reason") == "api_error" + class TestChainOption: @pytest.mark.asyncio @@ -400,3 +510,61 @@ async def handler(_req): assert resp.status == 503 body = await resp.json() assert body["error"]["code"] == "api_error" + + +@respx.mock +async def test_aiohttp_handler_exception_is_not_swallowed_by_gate(): + """Regression: gate's try-block must NOT wrap downstream handler. If the user's + handler raises, the exception must propagate up — NOT be misclassified as an + AgentScore infra failure (which under fail_open would re-invoke the handler).""" + respx.post("https://api.agentscore.sh/v1/assess").mock( + return_value=httpx.Response(200, json={"decision": "allow", "decision_reasons": []}), + ) + invocations = {"count": 0} + + async def boom_handler(_req): + invocations["count"] += 1 + msg = "downstream handler failure" + raise RuntimeError(msg) + + app = web.Application( + middlewares=[agentscore_gate_middleware(api_key="ak", fail_open=True)], + ) + app.router.add_get("/", boom_handler) + + async with TestClient(TestServer(app)) as client: + resp = await client.get("/", headers={"x-wallet-address": "0xabc"}) + # aiohttp surfaces an unhandled exception as 500 — the important thing is the + # handler ran exactly once (no fail-open retry), and the gate didn't claim + # the exception was an AgentScore infra failure. + assert resp.status == 500 + assert invocations["count"] == 1 + + +@pytest.mark.asyncio +@respx.mock +async def test_aiohttp_propagates_quota_from_assess_response_headers() -> None: + """API X-Quota-* headers → SDK populates AssessResponse.quota → adapter stashes it.""" + from agentscore_commerce.identity.aiohttp import get_gate_quota_info + + respx.post(ASSESS_URL).mock( + return_value=httpx.Response( + 200, + headers={"X-Quota-Limit": "1500", "X-Quota-Used": "1200", "X-Quota-Reset": "2026-06-01T00:00:00Z"}, + json={"decision": "allow", "decision_reasons": []}, + ), + ) + + captured: dict = {} + + async def handler(request): + captured["quota"] = get_gate_quota_info(request) + return web.json_response({"ok": True}) + + app = _make_app(handler=handler) + async with TestClient(TestServer(app)) as client: + resp = await client.get("/", headers={"x-wallet-address": "0xabc"}) + assert resp.status == 200 + assert captured["quota"] is not None + assert captured["quota"].limit == 1500 + assert captured["quota"].used == 1200 diff --git a/tests/test_client.py b/tests/test_client.py index b9d918f..e39c5c8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -187,6 +187,73 @@ def test_raises_payment_required_error(self): client._parse_response(resp) +class TestParseResponseStatusCodes: + """Cover the bespoke 401/429/5xx branches in _parse_response. + + Production traffic flows through the SDK (which raises typed errors directly), + but the helper is preserved for tests that pin the gate's contract independently. + """ + + def test_429_raises_quota_exceeded(self): + from agentscore_commerce.identity.client import QuotaExceededError + + client = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.status_code = 429 + with pytest.raises(QuotaExceededError): + client._parse_response(resp) + + def test_401_token_expired_raises_token_denied(self): + from agentscore_commerce.identity.client import TokenDeniedError + + client = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.status_code = 401 + resp.json.return_value = { + "error": {"code": "token_expired"}, + "verify_url": "https://x", + "session_id": "s", + "poll_secret": "p", + } + with pytest.raises(TokenDeniedError) as info: + client._parse_response(resp) + assert info.value.body.get("verify_url") == "https://x" + + def test_401_invalid_credential_raises_invalid_credential(self): + from agentscore_commerce.identity.client import InvalidCredentialError + + client = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.status_code = 401 + resp.json.return_value = {"error": {"code": "invalid_credential"}} + with pytest.raises(InvalidCredentialError): + client._parse_response(resp) + + def test_401_unknown_code_raises_runtime_error(self): + client = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.status_code = 401 + resp.json.return_value = {"error": {"code": "future_drift"}} + with pytest.raises(RuntimeError, match="returned 401"): + client._parse_response(resp) + + def test_401_body_unparseable_raises_runtime_error(self): + client = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.status_code = 401 + resp.json.side_effect = ValueError("not json") + with pytest.raises(RuntimeError, match="returned 401"): + client._parse_response(resp) + + def test_5xx_raises_runtime_error(self): + client = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.status_code = 503 + resp.is_success = False + with pytest.raises(RuntimeError, match="returned 503"): + client._parse_response(resp) + + ASSESS_URL = "https://api.agentscore.sh/v1/assess" @@ -271,6 +338,80 @@ def test_check_raises_on_api_error(self): with pytest.raises(RuntimeError, match="AgentScore API returned 500"): client.check("0xABC") + @respx.mock + def test_check_raises_quota_exceeded_on_429(self): + """429 must be distinguishable from generic 5xx so adapters surface + ``infra_reason='quota_exceeded'`` separately when fail_open=True.""" + from agentscore_commerce.identity.client import QuotaExceededError + + client = _make_client(fail_open=False) + respx.post(ASSESS_URL).mock(return_value=httpx.Response(429)) + + with pytest.raises(QuotaExceededError, match="quota_exceeded"): + client.check("0xABC") + + @respx.mock + def test_check_raises_quota_exceeded_on_typed_429(self): + """SDK emits typed QuotaExceededError when body has error.code='quota_exceeded' — + commerce wraps it so callers get the gate's QuotaExceededError sentinel. + """ + from agentscore_commerce.identity.client import QuotaExceededError + + client = _make_client(fail_open=False) + respx.post(ASSESS_URL).mock( + return_value=httpx.Response( + 429, + json={"error": {"code": "quota_exceeded", "message": "cap exceeded"}}, + ) + ) + + with pytest.raises(QuotaExceededError): + client.check("0xABC") + + @respx.mock + def test_check_raises_payment_required_on_402(self): + """402 maps to commerce's PaymentRequiredError sentinel.""" + client = _make_client(fail_open=False) + respx.post(ASSESS_URL).mock(return_value=httpx.Response(402)) + + with pytest.raises(PaymentRequiredError): + client.check("0xABC") + + @respx.mock + def test_check_raises_token_denied_on_typed_401(self): + """401 with error.code='token_expired' surfaces TokenDeniedError carrying body.""" + from agentscore_commerce.identity.client import TokenDeniedError + + client = _make_client(fail_open=False) + respx.post(ASSESS_URL).mock( + return_value=httpx.Response( + 401, + json={ + "error": {"code": "token_expired"}, + "verify_url": "https://x", + "session_id": "s", + "poll_secret": "p", + }, + ) + ) + + with pytest.raises(TokenDeniedError) as info: + client.check(operator_token="opc_x") + assert info.value.body.get("verify_url") == "https://x" + + @respx.mock + def test_check_raises_invalid_credential_on_typed_401(self): + """401 with error.code='invalid_credential' surfaces InvalidCredentialError.""" + from agentscore_commerce.identity.client import InvalidCredentialError + + client = _make_client(fail_open=False) + respx.post(ASSESS_URL).mock( + return_value=httpx.Response(401, json={"error": {"code": "invalid_credential"}}), + ) + + with pytest.raises(InvalidCredentialError): + client.check(operator_token="opc_x") + class TestCompliancePolicyFields: def test_build_body_includes_require_kyc(self): @@ -682,3 +823,113 @@ async def test_aresolve_returns_false_on_non_success(self): assert ok is False assert op is None assert links == [] + + +class TestAcheckTypedErrors: + """Async path mirror of TestCheckFailOpen — exercises SdkXxxError → commerce-error mapping + in :meth:`acheck`. Pinned independently of the sync path because adapters wire each + path separately and a regression in one wouldn't show up via the other. + """ + + @pytest.mark.asyncio + @respx.mock + async def test_acheck_raises_quota_exceeded_on_typed_429(self): + from agentscore_commerce.identity.client import QuotaExceededError + + client = _make_client() + respx.post(ASSESS_URL).mock( + return_value=httpx.Response( + 429, + json={"error": {"code": "quota_exceeded", "message": "cap exceeded"}}, + ) + ) + + with pytest.raises(QuotaExceededError): + await client.acheck("0xABC") + + @pytest.mark.asyncio + @respx.mock + async def test_acheck_raises_quota_exceeded_on_untyped_429(self): + """Mirrors the sync defensive 429-fallback path.""" + from agentscore_commerce.identity.client import QuotaExceededError + + client = _make_client() + respx.post(ASSESS_URL).mock(return_value=httpx.Response(429)) + + with pytest.raises(QuotaExceededError): + await client.acheck("0xABC") + + @pytest.mark.asyncio + @respx.mock + async def test_acheck_raises_payment_required_on_402(self): + client = _make_client() + respx.post(ASSESS_URL).mock(return_value=httpx.Response(402)) + + with pytest.raises(PaymentRequiredError): + await client.acheck("0xABC") + + @pytest.mark.asyncio + @respx.mock + async def test_acheck_raises_token_denied_on_typed_401(self): + from agentscore_commerce.identity.client import TokenDeniedError + + client = _make_client() + respx.post(ASSESS_URL).mock( + return_value=httpx.Response( + 401, + json={ + "error": {"code": "token_expired"}, + "verify_url": "https://x", + "session_id": "s", + "poll_secret": "p", + }, + ) + ) + + with pytest.raises(TokenDeniedError) as info: + await client.acheck(operator_token="opc_x") + assert info.value.body.get("verify_url") == "https://x" + + @pytest.mark.asyncio + @respx.mock + async def test_acheck_raises_invalid_credential_on_typed_401(self): + from agentscore_commerce.identity.client import InvalidCredentialError + + client = _make_client() + respx.post(ASSESS_URL).mock( + return_value=httpx.Response(401, json={"error": {"code": "invalid_credential"}}), + ) + + with pytest.raises(InvalidCredentialError): + await client.acheck(operator_token="opc_x") + + @pytest.mark.asyncio + @respx.mock + async def test_acheck_raises_runtime_error_on_5xx(self): + client = _make_client() + respx.post(ASSESS_URL).mock(return_value=httpx.Response(500)) + + with pytest.raises(RuntimeError, match="AgentScore API returned 500"): + await client.acheck("0xABC") + + @pytest.mark.asyncio + @respx.mock + async def test_acheck_re_raises_timeout_as_httpx_timeoutexception(self): + """SDK wraps httpx timeouts in its typed TimeoutError, but commerce adapters + catch ``httpx.TimeoutException`` to map to ``infra_reason='network_timeout'``. + Client must re-raise so adapters keep their existing exception clauses. + """ + client = _make_client() + respx.post(ASSESS_URL).mock(side_effect=httpx.TimeoutException("read timeout")) + + with pytest.raises(httpx.TimeoutException): + await client.acheck("0xABC") + + @respx.mock + def test_check_re_raises_timeout_as_httpx_timeoutexception(self): + """Sync mirror of :test:`test_acheck_re_raises_timeout_as_httpx_timeoutexception`.""" + client = _make_client() + respx.post(ASSESS_URL).mock(side_effect=httpx.TimeoutException("read timeout")) + + with pytest.raises(httpx.TimeoutException): + client.check("0xABC") diff --git a/tests/test_django.py b/tests/test_django.py index 82b473a..3f40661 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -18,10 +18,11 @@ ) django.setup() +import httpx from django.http import HttpRequest, JsonResponse from django.test import RequestFactory -from agentscore_commerce.identity.client import PaymentRequiredError +from agentscore_commerce.identity.client import PaymentRequiredError, QuotaExceededError from agentscore_commerce.identity.django import AgentScoreMiddleware, get_assess_data from agentscore_commerce.identity.types import AssessResult @@ -99,6 +100,81 @@ def test_api_error_fail_closed(self) -> None: data = json.loads(resp.content) assert data["error"]["code"] == "api_error" + def test_get_gate_degraded_state_returns_default_for_normal_allow(self) -> None: + from agentscore_commerce.identity.django import get_gate_degraded_state + + mw = self._make_middleware() + request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") + with patch("agentscore_commerce.identity.django.GateClient.check", return_value=_mock_result()): + mw(request) + assert get_gate_degraded_state(request) == {"degraded": False} + + def test_get_gate_degraded_state_returns_infra_reason_when_degraded(self) -> None: + from agentscore_commerce.identity.django import get_gate_degraded_state + + mw = self._make_middleware(fail_open=True) + request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") + with patch( + "agentscore_commerce.identity.django.GateClient.check", + side_effect=QuotaExceededError("quota_exceeded"), + ): + mw(request) + assert get_gate_degraded_state(request) == {"degraded": True, "infra_reason": "quota_exceeded"} + + def test_quota_exceeded_fail_open_marks_degraded(self) -> None: + mw = self._make_middleware(fail_open=True) + request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") + with patch( + "agentscore_commerce.identity.django.GateClient.check", + side_effect=QuotaExceededError("quota_exceeded"), + ): + resp = mw(request) + assert resp.status_code == 200 + state = getattr(request, "_agentscore_gate", {}) + assert state.get("degraded") is True + assert state.get("infra_reason") == "quota_exceeded" + + def test_quota_exceeded_fail_closed_returns_api_error(self) -> None: + mw = self._make_middleware() + request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") + with patch( + "agentscore_commerce.identity.django.GateClient.check", + side_effect=QuotaExceededError("quota_exceeded"), + ): + resp = mw(request) + assert resp.status_code == 503 + body = json.loads(resp.content) + assert body["error"]["code"] == "api_error" + instructions = json.loads(body["agent_instructions"]) + assert instructions["action"] == "contact_merchant" + assert "merchant-side issue" in instructions["steps"][0] + + def test_timeout_fail_open_marks_degraded_with_network_timeout(self) -> None: + mw = self._make_middleware(fail_open=True) + request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") + with patch( + "agentscore_commerce.identity.django.GateClient.check", + side_effect=httpx.TimeoutException("read timeout"), + ): + resp = mw(request) + assert resp.status_code == 200 + state = getattr(request, "_agentscore_gate", {}) + assert state.get("degraded") is True + assert state.get("infra_reason") == "network_timeout" + + def test_generic_exception_fail_open_marks_degraded_with_api_error(self) -> None: + mw = self._make_middleware(fail_open=True) + request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") + with patch( + "agentscore_commerce.identity.django.GateClient.check", + side_effect=RuntimeError("oops"), + ): + resp = mw(request) + assert resp.status_code == 200 + state = getattr(request, "_agentscore_gate", {}) + assert state.get("degraded") is True + assert state.get("infra_reason") == "api_error" + def test_payment_required_fail_open(self) -> None: mw = self._make_middleware(fail_open=True) request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") @@ -517,3 +593,64 @@ def test_constructor_chain_stored_and_forwarded(self) -> None: mw(request) body = json.loads(route.calls[0].request.content) assert body["chain"] == "solana" + + def test_handler_exception_is_not_swallowed_by_gate(self) -> None: + """Regression: gate's try-block must NOT wrap the downstream view (`get_response`). + If the user's view raises, the exception must propagate up — NOT be misclassified as + an AgentScore infra failure (which under fail_open would re-invoke the view).""" + invocations = {"count": 0} + + def boom_view(_request: HttpRequest) -> JsonResponse: + invocations["count"] += 1 + msg = "downstream view failure" + raise RuntimeError(msg) + + settings.AGENTSCORE_GATE = {"api_key": "test-key", "fail_open": True} + mw = AgentScoreMiddleware(boom_view) + + with patch("agentscore_commerce.identity.django.GateClient.check", return_value=_mock_result()): + request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") + try: + mw(request) + except RuntimeError as exc: + assert str(exc) == "downstream view failure" + else: + msg = "expected the view's RuntimeError to propagate" + raise AssertionError(msg) + + assert invocations["count"] == 1 + + +class TestDjangoQuotaPropagation: + factory = RequestFactory() + + def test_propagates_quota_from_assess_response(self) -> None: + """API X-Quota-* → SDK populates AssessResponse.quota → adapter stashes onto request.""" + from agentscore_commerce.identity.django import get_gate_quota_info + from agentscore_commerce.identity.types import GateQuotaInfo + + captured: dict = {} + + def view(request: HttpRequest) -> JsonResponse: + captured["quota"] = get_gate_quota_info(request) + return JsonResponse({"ok": True}) + + original = settings.AGENTSCORE_GATE.copy() if hasattr(settings, "AGENTSCORE_GATE") else {} + settings.AGENTSCORE_GATE = {"api_key": "test-key"} + try: + mw = AgentScoreMiddleware(view) + result = AssessResult( + allow=True, + decision="allow", + reasons=[], + raw={"decision": "allow"}, + quota=GateQuotaInfo(limit=1500, used=1200, reset="2026-06-01T00:00:00Z"), + ) + request = self.factory.get("/", HTTP_X_WALLET_ADDRESS="0xabc") + with patch("agentscore_commerce.identity.django.GateClient.check", return_value=result): + resp = mw(request) + assert resp.status_code == 200 + assert captured["quota"] is not None + assert captured["quota"].limit == 1500 + finally: + settings.AGENTSCORE_GATE = original diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 6b60e67..4c70827 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -107,6 +107,154 @@ def test_api_error_returns_403_api_error(self): assert resp.status_code == 503 assert resp.json()["detail"]["error"]["code"] == "api_error" + @respx.mock + def test_quota_exceeded_returns_503_when_fail_closed(self): + """429 from /v1/assess gets dedicated handling; with fail_open=False (default) it + surfaces as 503 api_error to the buyer with quota-specific contact_merchant + instructions (NOT retry_with_backoff — quota won't recover from retry).""" + import json as _json + + respx.post(ASSESS_URL).mock(return_value=httpx.Response(429)) + gate = AgentScoreGate(api_key="ask_test") + client = TestClient(_make_app(gate)) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 503 + body = resp.json()["detail"] + assert body["error"]["code"] == "api_error" + instructions = _json.loads(body["agent_instructions"]) + assert instructions["action"] == "contact_merchant" + assert "merchant-side issue" in instructions["steps"][0] + + @respx.mock + def test_fail_open_marks_degraded_with_infra_reason_quota(self): + """fail_open=True + 429 → request flows through; gate state carries + degraded=True + infra_reason='quota_exceeded' for merchant logging/alerts.""" + respx.post(ASSESS_URL).mock(return_value=httpx.Response(429)) + from fastapi import FastAPI + + from agentscore_commerce.identity.fastapi import GATE_STATE_KEY + + gate = AgentScoreGate(api_key="ask_test", fail_open=True) + app = FastAPI() + captured: dict = {} + + @app.get("/", dependencies=[Depends(gate)]) + def _root(req: Request): + state = getattr(req.state, GATE_STATE_KEY, None) + captured.update(state or {}) + return {"ok": True} + + client = TestClient(app) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 200 + assert captured.get("degraded") is True + assert captured.get("infra_reason") == "quota_exceeded" + + @respx.mock + def test_fail_open_marks_degraded_with_infra_reason_api_error(self): + """fail_open=True + 5xx → request flows through; gate state carries + degraded=True + infra_reason='api_error'.""" + respx.post(ASSESS_URL).mock(return_value=httpx.Response(500)) + from fastapi import FastAPI + + from agentscore_commerce.identity.fastapi import GATE_STATE_KEY + + gate = AgentScoreGate(api_key="ask_test", fail_open=True) + app = FastAPI() + captured: dict = {} + + @app.get("/", dependencies=[Depends(gate)]) + def _root(req: Request): + state = getattr(req.state, GATE_STATE_KEY, None) + captured.update(state or {}) + return {"ok": True} + + client = TestClient(app) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 200 + assert captured.get("degraded") is True + assert captured.get("infra_reason") == "api_error" + + def test_get_gate_degraded_state_returns_default_for_normal_allow(self): + """get_gate_degraded_state returns {degraded: False} for normal compliance allows.""" + from fastapi import FastAPI + + from agentscore_commerce.identity.fastapi import get_gate_degraded_state + + gate = AgentScoreGate(api_key="ask_test") + app = FastAPI() + captured: dict = {} + + @app.get("/", dependencies=[Depends(gate)]) + def _root(req: Request): + captured.update(get_gate_degraded_state(req)) + return {"ok": True} + + with patch( + "agentscore_commerce.identity.fastapi.GateClient.acheck_identity", + new=AsyncMock( + return_value=__import__("agentscore_commerce.identity.types", fromlist=["AssessResult"]).AssessResult( + allow=True, decision="allow" + ) + ), + ): + client = TestClient(app) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 200 + assert captured == {"degraded": False} + + def test_get_gate_degraded_state_returns_infra_reason_when_degraded(self): + """get_gate_degraded_state returns {degraded: True, infra_reason: ...} when gate degraded.""" + from fastapi import FastAPI + + from agentscore_commerce.identity.client import QuotaExceededError + from agentscore_commerce.identity.fastapi import get_gate_degraded_state + + gate = AgentScoreGate(api_key="ask_test", fail_open=True) + app = FastAPI() + captured: dict = {} + + @app.get("/", dependencies=[Depends(gate)]) + def _root(req: Request): + captured.update(get_gate_degraded_state(req)) + return {"ok": True} + + with patch( + "agentscore_commerce.identity.fastapi.GateClient.acheck_identity", + new=AsyncMock(side_effect=QuotaExceededError("quota_exceeded")), + ): + client = TestClient(app) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 200 + assert captured == {"degraded": True, "infra_reason": "quota_exceeded"} + + def test_fail_open_marks_degraded_with_infra_reason_network_timeout(self): + """fail_open=True + httpx.TimeoutException → request flows through; + gate state carries degraded=True + infra_reason='network_timeout'.""" + from fastapi import FastAPI + + from agentscore_commerce.identity.fastapi import GATE_STATE_KEY + + gate = AgentScoreGate(api_key="ask_test", fail_open=True) + app = FastAPI() + captured: dict = {} + + @app.get("/", dependencies=[Depends(gate)]) + def _root(req: Request): + state = getattr(req.state, GATE_STATE_KEY, None) + captured.update(state or {}) + return {"ok": True} + + with patch( + "agentscore_commerce.identity.fastapi.GateClient.acheck_identity", + new=AsyncMock(side_effect=httpx.TimeoutException("read timeout")), + ): + client = TestClient(app) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 200 + assert captured.get("degraded") is True + assert captured.get("infra_reason") == "network_timeout" + class TestOnDenied: def test_custom_on_denied_controls_status_and_body(self): @@ -334,6 +482,61 @@ def test_returns_none_when_gate_bypassed_via_fail_open(self): assert resp.json()["assess"] is None +class TestGetGateQuotaInfo: + @respx.mock + def test_propagates_quota_from_assess_response_headers(self): + # API emits X-Quota-* on the assess response → SDK populates AssessResponse.quota → + # gate stashes onto request state → adapter exposes via get_gate_quota_info(). + from agentscore_commerce.identity.fastapi import get_gate_quota_info + + respx.post(ASSESS_URL).mock( + return_value=httpx.Response( + 200, + headers={"X-Quota-Limit": "1500", "X-Quota-Used": "1200", "X-Quota-Reset": "2026-06-01T00:00:00Z"}, + json={"decision": "allow", "decision_reasons": []}, + ), + ) + + captured = {} + gate = AgentScoreGate(api_key="ask_test") + app = FastAPI() + + @app.get("/", dependencies=[Depends(gate)]) + async def index(request: Request): + quota = get_gate_quota_info(request) + captured["quota"] = quota + return {"ok": True} + + client = TestClient(app) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 200 + assert captured["quota"] is not None + assert captured["quota"].limit == 1500 + assert captured["quota"].used == 1200 + assert captured["quota"].reset == "2026-06-01T00:00:00Z" + + @respx.mock + def test_returns_none_when_api_omits_quota_headers(self): + # Enterprise / unlimited tiers don't emit X-Quota-* headers — the gate state + # carries no quota and get_gate_quota_info returns None. + from agentscore_commerce.identity.fastapi import get_gate_quota_info + + _mock_assess("allow") # no quota headers + gate = AgentScoreGate(api_key="ask_test") + app = FastAPI() + captured = {} + + @app.get("/", dependencies=[Depends(gate)]) + async def index(request: Request): + captured["quota"] = get_gate_quota_info(request) + return {"ok": True} + + client = TestClient(app) + resp = client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status_code == 200 + assert captured["quota"] is None + + class TestUserAgent: @respx.mock def test_default_user_agent_format(self): diff --git a/tests/test_flask.py b/tests/test_flask.py index d003776..4647d90 100644 --- a/tests/test_flask.py +++ b/tests/test_flask.py @@ -4,10 +4,11 @@ from unittest.mock import patch +import httpx import pytest from flask import Flask -from agentscore_commerce.identity.client import PaymentRequiredError +from agentscore_commerce.identity.client import PaymentRequiredError, QuotaExceededError from agentscore_commerce.identity.flask import agentscore_gate, get_assess_data from agentscore_commerce.identity.types import AssessResult @@ -102,6 +103,123 @@ def test_api_error_fail_closed(self) -> None: data = resp.get_json() assert data["error"]["code"] == "api_error" + def test_get_gate_degraded_state_default_returns_not_degraded(self) -> None: + from agentscore_commerce.identity.flask import get_gate_degraded_state + + app = _make_app() + captured: dict = {} + + @app.route("/snoop") + def _snoop(): + captured.update(get_gate_degraded_state()) + return {"ok": True} + + with patch("agentscore_commerce.identity.flask.GateClient.check", return_value=_mock_result()): + resp = app.test_client().get("/snoop", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 200 + assert captured == {"degraded": False} + + def test_get_gate_degraded_state_returns_infra_reason_when_degraded(self) -> None: + from agentscore_commerce.identity.flask import get_gate_degraded_state + + app = _make_app(fail_open=True) + captured: dict = {} + + @app.route("/snoop") + def _snoop(): + captured.update(get_gate_degraded_state()) + return {"ok": True} + + with patch( + "agentscore_commerce.identity.flask.GateClient.check", + side_effect=QuotaExceededError("quota_exceeded"), + ): + resp = app.test_client().get("/snoop", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 200 + assert captured == {"degraded": True, "infra_reason": "quota_exceeded"} + + def test_quota_exceeded_fail_open_marks_degraded(self) -> None: + """fail_open=True + QuotaExceededError → request flows through; gate state on + ``g._agentscore_gate`` carries degraded=True + infra_reason='quota_exceeded'.""" + app = _make_app(fail_open=True) + + @app.route("/snoop") + def _snoop(): + from flask import g, jsonify + + state = getattr(g, "_agentscore_gate", {}) or {} + return jsonify({k: v for k, v in state.items() if k != "client"}) + + with patch( + "agentscore_commerce.identity.flask.GateClient.check", + side_effect=QuotaExceededError("quota_exceeded"), + ): + client = app.test_client() + resp = client.get("/snoop", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 200 + data = resp.get_json() + assert data.get("degraded") is True + assert data.get("infra_reason") == "quota_exceeded" + + def test_quota_exceeded_fail_closed_returns_api_error(self) -> None: + import json as _json + + app = _make_app() + with patch( + "agentscore_commerce.identity.flask.GateClient.check", + side_effect=QuotaExceededError("quota_exceeded"), + ): + client = app.test_client() + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 503 + body = resp.get_json() + assert body["error"]["code"] == "api_error" + instructions = _json.loads(body["agent_instructions"]) + assert instructions["action"] == "contact_merchant" + assert "merchant-side issue" in instructions["steps"][0] + + def test_timeout_fail_open_marks_degraded_with_network_timeout(self) -> None: + app = _make_app(fail_open=True) + + @app.route("/snoop") + def _snoop(): + from flask import g, jsonify + + state = getattr(g, "_agentscore_gate", {}) or {} + return jsonify({k: v for k, v in state.items() if k != "client"}) + + with patch( + "agentscore_commerce.identity.flask.GateClient.check", + side_effect=httpx.TimeoutException("read timeout"), + ): + client = app.test_client() + resp = client.get("/snoop", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 200 + data = resp.get_json() + assert data.get("degraded") is True + assert data.get("infra_reason") == "network_timeout" + + def test_generic_exception_fail_open_marks_degraded_with_api_error(self) -> None: + app = _make_app(fail_open=True) + + @app.route("/snoop") + def _snoop(): + from flask import g, jsonify + + state = getattr(g, "_agentscore_gate", {}) or {} + return jsonify({k: v for k, v in state.items() if k != "client"}) + + with patch( + "agentscore_commerce.identity.flask.GateClient.check", + side_effect=RuntimeError("oops"), + ): + client = app.test_client() + resp = client.get("/snoop", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 200 + data = resp.get_json() + assert data.get("degraded") is True + assert data.get("infra_reason") == "api_error" + def test_payment_required_fail_open(self) -> None: app = _make_app(fail_open=True) with patch( @@ -683,3 +801,32 @@ def test_no_op_when_gate_state_missing(self) -> None: app = Flask(__name__) with app.test_request_context("/"): capture_wallet("0xwallet", "evm") # should not raise + + +class TestFlaskQuotaPropagation: + def test_propagates_quota_from_assess_response(self) -> None: + """API X-Quota-* → SDK populates AssessResponse.quota → adapter stashes onto g.""" + from agentscore_commerce.identity.flask import get_gate_quota_info + from agentscore_commerce.identity.types import GateQuotaInfo + + app = _make_app() + captured: dict = {} + + @app.route("/quota") + def quota_route(): + captured["quota"] = get_gate_quota_info() + return {"ok": True} + + result = AssessResult( + allow=True, + decision="allow", + reasons=[], + raw={"decision": "allow"}, + quota=GateQuotaInfo(limit=1500, used=1200, reset="2026-06-01T00:00:00Z"), + ) + with patch("agentscore_commerce.identity.flask.GateClient.check", return_value=result): + client = app.test_client() + resp = client.get("/quota", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 200 + assert captured["quota"] is not None + assert captured["quota"].limit == 1500 diff --git a/tests/test_gate_quota_info.py b/tests/test_gate_quota_info.py new file mode 100644 index 0000000..f782645 --- /dev/null +++ b/tests/test_gate_quota_info.py @@ -0,0 +1,124 @@ +"""Cross-adapter `get_gate_quota_info` tests. + +Each adapter exposes a `get_gate_quota_info(request)` function that reads the per-account +assess quota stashed on gate state during evaluate. These tests exercise the +read-path-only contract: prime the framework-specific state container with a fake quota +and verify the helper returns it. Full end-to-end quota propagation through evaluate is +covered by the per-adapter integration tests' fail-open / allow paths. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from agentscore_commerce.identity.types import GateQuotaInfo + +QUOTA = GateQuotaInfo(limit=1000, used=780, reset="2026-06-01T00:00:00Z") + + +def test_fastapi_get_gate_quota_info_reads_state() -> None: + from agentscore_commerce.identity.fastapi import GATE_STATE_KEY, get_gate_quota_info + + request = MagicMock() + request.state = MagicMock() + setattr(request.state, GATE_STATE_KEY, {"quota": QUOTA}) + assert get_gate_quota_info(request) is QUOTA + + +def test_fastapi_get_gate_quota_info_returns_none_when_absent() -> None: + from agentscore_commerce.identity.fastapi import GATE_STATE_KEY, get_gate_quota_info + + request = MagicMock() + request.state = MagicMock() + setattr(request.state, GATE_STATE_KEY, {"degraded": False}) # state present, quota absent + assert get_gate_quota_info(request) is None + + +def test_flask_get_gate_quota_info_reads_g() -> None: + from flask import Flask + + from agentscore_commerce.identity.flask import get_gate_quota_info + + app = Flask(__name__) + with app.test_request_context(): + from flask import g + + g._agentscore_gate = {"quota": QUOTA} + assert get_gate_quota_info() is QUOTA + + +def test_flask_get_gate_quota_info_returns_none_when_absent() -> None: + from flask import Flask + + from agentscore_commerce.identity.flask import get_gate_quota_info + + app = Flask(__name__) + with app.test_request_context(): + assert get_gate_quota_info() is None + + +def test_django_get_gate_quota_info_reads_attr() -> None: + from agentscore_commerce.identity.django import get_gate_quota_info + + request = MagicMock() + request._agentscore_gate = {"quota": QUOTA} + assert get_gate_quota_info(request) is QUOTA + + +def test_django_get_gate_quota_info_returns_none_when_absent() -> None: + from agentscore_commerce.identity.django import get_gate_quota_info + + request = MagicMock() + request._agentscore_gate = None # type: ignore[assignment] + # Attribute may not exist at all — getattr default. + delattr(request, "_agentscore_gate") + assert get_gate_quota_info(request) is None + + +def test_aiohttp_get_gate_quota_info_reads_dict() -> None: + from agentscore_commerce.identity.aiohttp import GATE_STATE_KEY, get_gate_quota_info + + request = MagicMock() + request.get = lambda key, default=None: {GATE_STATE_KEY: {"quota": QUOTA}}.get(key, default) + assert get_gate_quota_info(request) is QUOTA + + +def test_aiohttp_get_gate_quota_info_returns_none_when_absent() -> None: + from agentscore_commerce.identity.aiohttp import get_gate_quota_info + + request = MagicMock() + request.get = lambda _key, default=None: default + assert get_gate_quota_info(request) is None + + +def test_sanic_get_gate_quota_info_reads_ctx() -> None: + from agentscore_commerce.identity.sanic import GATE_STATE_ATTR, get_gate_quota_info + + request = MagicMock() + request.ctx = MagicMock() + setattr(request.ctx, GATE_STATE_ATTR, {"quota": QUOTA}) + assert get_gate_quota_info(request) is QUOTA + + +def test_sanic_get_gate_quota_info_returns_none_when_absent() -> None: + from agentscore_commerce.identity.sanic import get_gate_quota_info + + request = MagicMock() + request.ctx = MagicMock(spec=[]) # no GATE_STATE_ATTR set + assert get_gate_quota_info(request) is None + + +def test_middleware_get_gate_quota_info_reads_scope() -> None: + from agentscore_commerce.identity.middleware import GATE_STATE_KEY, get_gate_quota_info + + request = MagicMock() + request.scope = {"state": {GATE_STATE_KEY: {"quota": QUOTA}}} + assert get_gate_quota_info(request) is QUOTA + + +def test_middleware_get_gate_quota_info_returns_none_when_absent() -> None: + from agentscore_commerce.identity.middleware import get_gate_quota_info + + request = MagicMock() + request.scope = {} + assert get_gate_quota_info(request) is None diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 973ed75..2a9007e 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -353,6 +353,129 @@ def test_middleware_fail_open_on_unexpected_exception_lets_request_through(): assert resp.json()["ok"] is True +@respx.mock +def test_middleware_get_gate_degraded_state_returns_default_for_normal_allow(): + from agentscore_commerce.identity.middleware import get_gate_degraded_state + + _mock_assess("allow") + + captured: dict = {} + + def _snoop(request: Request) -> JSONResponse: + captured.update(get_gate_degraded_state(request)) + return JSONResponse({"ok": True}) + + app = AgentScoreGate( + Starlette(routes=[Route("/", _snoop)]), + api_key="ask_test_key", + ) + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 200 + assert captured == {"degraded": False} + + +@respx.mock +def test_middleware_get_gate_degraded_state_returns_infra_reason_when_degraded(): + from agentscore_commerce.identity.middleware import get_gate_degraded_state + + respx.post(ASSESS_URL).mock(return_value=httpx.Response(429)) + + captured: dict = {} + + def _snoop(request: Request) -> JSONResponse: + captured.update(get_gate_degraded_state(request)) + return JSONResponse({"ok": True}) + + app = AgentScoreGate( + Starlette(routes=[Route("/", _snoop)]), + api_key="ask_test_key", + fail_open=True, + ) + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 200 + assert captured == {"degraded": True, "infra_reason": "quota_exceeded"} + + +@respx.mock +def test_middleware_quota_exceeded_returns_503_when_fail_closed(): + """429 from /v1/assess gets dedicated handling; with fail_open=False (default) it + surfaces as 503 api_error to the buyer with quota-specific contact_merchant + instructions (NOT retry_with_backoff — quota won't recover from retry).""" + respx.post(ASSESS_URL).mock(return_value=httpx.Response(429)) + + app = _make_app() + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + + assert resp.status_code == 503 + body = resp.json() + assert body["error"]["code"] == "api_error" + instructions = json.loads(body["agent_instructions"]) + assert instructions["action"] == "contact_merchant" + assert "merchant-side issue" in instructions["steps"][0] + + +@respx.mock +def test_middleware_quota_exceeded_marks_degraded_when_fail_open(): + """fail_open=True + 429 → request flows through; ASGI scope state carries + degraded=True + infra_reason='quota_exceeded'.""" + from agentscore_commerce.identity.middleware import GATE_STATE_KEY + + respx.post(ASSESS_URL).mock(return_value=httpx.Response(429)) + + captured: dict = {} + + def _snoop(request: Request) -> JSONResponse: + state = (request.scope.get("state") or {}).get(GATE_STATE_KEY) or {} + captured.update(state) + return JSONResponse({k: v for k, v in state.items() if k != "client"}) + + app = AgentScoreGate( + Starlette(routes=[Route("/", _snoop)]), + api_key="ask_test_key", + fail_open=True, + ) + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + + assert resp.status_code == 200 + data = resp.json() + assert data.get("degraded") is True + assert data.get("infra_reason") == "quota_exceeded" + + +def test_middleware_timeout_marks_degraded_when_fail_open(): + """fail_open=True + httpx.TimeoutException → request flows through; scope state + carries degraded=True + infra_reason='network_timeout'.""" + from unittest.mock import AsyncMock, patch + + from agentscore_commerce.identity.middleware import GATE_STATE_KEY + + def _snoop(request: Request) -> JSONResponse: + state = (request.scope.get("state") or {}).get(GATE_STATE_KEY) or {} + return JSONResponse({k: v for k, v in state.items() if k != "client"}) + + app = AgentScoreGate( + Starlette(routes=[Route("/", _snoop)]), + api_key="ask_test_key", + fail_open=True, + ) + + with patch( + "agentscore_commerce.identity.middleware.GateClient.acheck_identity", + new=AsyncMock(side_effect=httpx.TimeoutException("read timeout")), + ): + client = TestClient(app, raise_server_exceptions=False) + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + + assert resp.status_code == 200 + data = resp.json() + assert data.get("degraded") is True + assert data.get("infra_reason") == "network_timeout" + + @respx.mock def test_middleware_passes_through_token_expired_with_auto_session(): # Revoked and expired credentials both surface as token_expired from the API with an @@ -483,3 +606,57 @@ def test_middleware_fail_open_on_402_lets_request_through(): assert resp.status_code == 200 assert resp.json()["ok"] is True + + +@respx.mock +def test_middleware_handler_exception_is_not_swallowed_by_gate(): + """Regression: gate's try-block must NOT wrap the downstream ASGI app. If the user's + app raises, the exception must propagate up — NOT be misclassified as an AgentScore + infra failure (which under fail_open would re-invoke the app).""" + _mock_assess(decision="allow") + + invocations = {"count": 0} + + def boom_route(_request: Request) -> JSONResponse: + invocations["count"] += 1 + msg = "downstream app failure" + raise RuntimeError(msg) + + inner = Starlette(routes=[Route("/", boom_route)]) + app = AgentScoreGate(inner, api_key="ask_test_key", fail_open=True) + client = TestClient(app, raise_server_exceptions=False) + + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + # Starlette surfaces unhandled exceptions as 500 — the important thing is the route + # ran exactly once (no fail-open retry) and the gate didn't claim the exception was + # an AgentScore infra failure. + assert resp.status_code == 500 + assert invocations["count"] == 1 + + +@respx.mock +def test_middleware_propagates_quota_from_assess_response_headers(): + """API X-Quota-* → SDK populates AssessResponse.quota → adapter stashes onto scope state.""" + from agentscore_commerce.identity.middleware import get_gate_quota_info + + respx.post(ASSESS_URL).mock( + return_value=httpx.Response( + 200, + headers={"X-Quota-Limit": "1500", "X-Quota-Used": "1200", "X-Quota-Reset": "2026-06-01T00:00:00Z"}, + json={"decision": "allow", "decision_reasons": []}, + ), + ) + + captured: dict = {} + + def quota_route(request: Request) -> JSONResponse: + captured["quota"] = get_gate_quota_info(request) + return JSONResponse({"ok": True}) + + inner = Starlette(routes=[Route("/", quota_route)]) + app = AgentScoreGate(inner, api_key="ask_test_key") + client = TestClient(app) + resp = client.get("/", headers={"x-wallet-address": "0xabc"}) + assert resp.status_code == 200 + assert captured["quota"] is not None + assert captured["quota"].limit == 1500 diff --git a/tests/test_response.py b/tests/test_response.py index 5b0bcea..29be385 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -64,10 +64,24 @@ def test_explicit_agent_instructions_takes_precedence_over_default() -> None: assert body["agent_instructions"] == custom -def test_codes_without_a_default_keep_no_instructions() -> None: - # api_error has its own next_steps fallback (not agent_instructions), - # so denial_reason_to_body should NOT inject agent_instructions for it. +def test_api_error_emits_retry_with_backoff_instructions() -> None: + # api_error denials get a structured agent_instructions block with retry-with-backoff + # guidance so agents distinguish transient AgentScore-side issues from compliance denials. + # agent_instructions is the single retry channel — no separate next_steps block. body = denial_reason_to_body(DenialReason(code="api_error")) - assert "agent_instructions" not in body - # But it still gets the default retry next_steps shape. - assert body.get("next_steps") == {"action": "retry", "retry_after_seconds": 5} + assert "agent_instructions" in body + instructions = json.loads(body["agent_instructions"]) + assert instructions["action"] == "retry_with_backoff" + assert "Verification is temporarily unavailable" in instructions["steps"][0] + assert "next_steps" not in body + + +def test_api_error_with_quota_instructions_overrides_retry_default() -> None: + # Adapters explicitly pass QUOTA_EXCEEDED_INSTRUCTIONS on the DenialReason for the 429 + # path so the agent gets contact_merchant guidance instead of an infinite retry loop. + from agentscore_commerce.identity._response import QUOTA_EXCEEDED_INSTRUCTIONS + + body = denial_reason_to_body(DenialReason(code="api_error", agent_instructions=QUOTA_EXCEEDED_INSTRUCTIONS)) + instructions = json.loads(body["agent_instructions"]) + assert instructions["action"] == "contact_merchant" + assert "merchant-side issue" in instructions["steps"][0] diff --git a/tests/test_sanic.py b/tests/test_sanic.py index f661789..6a9c9fc 100644 --- a/tests/test_sanic.py +++ b/tests/test_sanic.py @@ -136,6 +136,110 @@ def test_fail_open_allows_through_on_402(self): _, resp = app.test_client.get("/", headers={"X-Wallet-Address": "0xabc"}) assert resp.status == 200 + def test_get_gate_degraded_state_returns_default_for_normal_allow(self): + from agentscore_commerce.identity.sanic import get_gate_degraded_state + + app = Sanic.get_app("sanic_get_state_default", force_create=True) + agentscore_gate(app, api_key="ask_test") + captured: dict = {} + + @app.get("/snoop") + async def _snoop(request): + captured.update(get_gate_degraded_state(request)) + return response.json({"ok": True}) + + with patch( + "agentscore_commerce.identity.sanic.GateClient.acheck_identity", + new=AsyncMock(return_value=_allow_result()), + ): + _, resp = app.test_client.get("/snoop", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 200 + assert captured == {"degraded": False} + + def test_get_gate_degraded_state_returns_infra_reason_when_degraded(self): + from agentscore_commerce.identity.client import QuotaExceededError + from agentscore_commerce.identity.sanic import get_gate_degraded_state + + app = Sanic.get_app("sanic_get_state_degraded", force_create=True) + agentscore_gate(app, api_key="ask_test", fail_open=True) + captured: dict = {} + + @app.get("/snoop") + async def _snoop(request): + captured.update(get_gate_degraded_state(request)) + return response.json({"ok": True}) + + with patch( + "agentscore_commerce.identity.sanic.GateClient.acheck_identity", + new=AsyncMock(side_effect=QuotaExceededError("quota_exceeded")), + ): + _, resp = app.test_client.get("/snoop", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 200 + assert captured == {"degraded": True, "infra_reason": "quota_exceeded"} + + def test_quota_exceeded_returns_503_when_fail_closed(self): + import json as _json + + from agentscore_commerce.identity.client import QuotaExceededError + + app = _make_app("sanic_quota_closed") + with patch( + "agentscore_commerce.identity.sanic.GateClient.acheck_identity", + new=AsyncMock(side_effect=QuotaExceededError("quota_exceeded")), + ): + _, resp = app.test_client.get("/", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 503 + assert resp.json["error"]["code"] == "api_error" + instructions = _json.loads(resp.json["agent_instructions"]) + assert instructions["action"] == "contact_merchant" + assert "merchant-side issue" in instructions["steps"][0] + + def test_quota_exceeded_marks_degraded_when_fail_open(self): + from agentscore_commerce.identity.client import QuotaExceededError + from agentscore_commerce.identity.sanic import GATE_STATE_ATTR + + # Build a fresh app to inspect gate state. + app = Sanic.get_app("sanic_quota_open", force_create=True) + agentscore_gate(app, api_key="ask_test", fail_open=True) + + @app.get("/snoop") + async def _snoop(request): + state = getattr(request.ctx, GATE_STATE_ATTR, {}) or {} + return response.json({k: v for k, v in state.items() if k != "client"}) + + with patch( + "agentscore_commerce.identity.sanic.GateClient.acheck_identity", + new=AsyncMock(side_effect=QuotaExceededError("quota_exceeded")), + ): + _, resp = app.test_client.get("/snoop", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 200 + data = resp.json + assert data.get("degraded") is True + assert data.get("infra_reason") == "quota_exceeded" + + def test_timeout_marks_degraded_when_fail_open(self): + import httpx + + from agentscore_commerce.identity.sanic import GATE_STATE_ATTR + + app = Sanic.get_app("sanic_timeout_open", force_create=True) + agentscore_gate(app, api_key="ask_test", fail_open=True) + + @app.get("/snoop") + async def _snoop(request): + state = getattr(request.ctx, GATE_STATE_ATTR, {}) or {} + return response.json({k: v for k, v in state.items() if k != "client"}) + + with patch( + "agentscore_commerce.identity.sanic.GateClient.acheck_identity", + new=AsyncMock(side_effect=httpx.TimeoutException("read timeout")), + ): + _, resp = app.test_client.get("/snoop", headers={"X-Wallet-Address": "0xabc"}) + assert resp.status == 200 + data = resp.json + assert data.get("degraded") is True + assert data.get("infra_reason") == "network_timeout" + def test_fail_open_allows_through_on_api_error(self): app = _make_app("sanic_fail_open_api", fail_open=True) with patch( @@ -369,3 +473,35 @@ async def index(_request): assert resp.status == 503 assert resp.json["error"]["code"] == "api_error" + + +def test_sanic_propagates_quota_from_assess_response(): + """API X-Quota-* → SDK populates AssessResponse.quota → adapter stashes onto ctx.""" + from agentscore_commerce.identity.sanic import get_gate_quota_info + from agentscore_commerce.identity.types import GateQuotaInfo + + app = Sanic("sanic_quota_test") + agentscore_gate(app, api_key="ak") + captured: dict = {} + + @app.get("/") + async def index(request): + captured["quota"] = get_gate_quota_info(request) + return response.json({"ok": True}) + + result = AssessResult( + allow=True, + decision="allow", + reasons=[], + raw={"decision": "allow"}, + quota=GateQuotaInfo(limit=1500, used=1200, reset="2026-06-01T00:00:00Z"), + ) + with patch( + "agentscore_commerce.identity.sanic.GateClient.acheck_identity", + new=AsyncMock(return_value=result), + ): + _req, resp = app.test_client.get("/", headers={"x-wallet-address": "0xabc"}) + + assert resp.status == 200 + assert captured["quota"] is not None + assert captured["quota"].limit == 1500 diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 7fd2ee6..2f50df5 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -104,7 +104,9 @@ def test_forwards_api_key_and_user_agent(self): ) headers = route.calls[0].request.headers assert headers["X-API-Key"] == "ask_my_key" - assert headers["User-Agent"] == "myapp/1.0" + # SDK appends its own UA segment to the chain: ` (agentscore-py/)` + assert headers["User-Agent"].startswith("myapp/1.0") + assert "agentscore-py/" in headers["User-Agent"] @respx.mock def test_respects_custom_base_url(self): diff --git a/tests/test_signer_match.py b/tests/test_signer_match.py index f1925f5..6d6e1cd 100644 --- a/tests/test_signer_match.py +++ b/tests/test_signer_match.py @@ -125,15 +125,33 @@ def fake_post(*_args: object, **_kwargs: object) -> MagicMock: def test_verify_wallet_signer_match_different_operator_rejects() -> None: + """New 1-call path: API returns signer_match in the assess response.""" client = GateClient(api_key=API_KEY) - operators = iter(["op_claimed", "op_attacker"]) def fake_post(*_args: object, **_kwargs: object) -> MagicMock: resp = MagicMock() resp.is_success = True resp.status_code = 200 - op = next(operators) - resp.json = lambda op=op: {"resolved_operator": op} + resp.json = lambda: { + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_claimed", + "signer_match": { + "kind": "wallet_signer_mismatch", + "claimed_operator": "op_claimed", + "signer_operator": "op_attacker", + "expected_signer": WALLET_A.lower(), + "actual_signer": WALLET_B.lower(), + "linked_wallets": [WALLET_A.lower()], + "agent_instructions": json.dumps( + { + "action": "resign_or_switch_to_operator_token", + "steps": ["re-sign with linked_wallets"], + "user_message": "Different operator detected", + } + ), + }, + } return resp with patch.object(client._sync_client, "post", side_effect=fake_post): @@ -176,6 +194,9 @@ async def test_averify_wallet_signer_match_transient_error_emits_api_error() -> async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: resp = MagicMock() + # SDK gates response handling on status_code >= 400 — set 503 so the SDK + # raises AgentScoreError, which commerce maps to api_error. + resp.status_code = 503 resp.is_success = False return resp @@ -187,15 +208,25 @@ async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: def test_verify_wallet_signer_match_unlinked_signer_rejects() -> None: + """New 1-call path: API returns signer_match with signer_operator=None for unlinked wallet.""" client = GateClient(api_key=API_KEY) - operators = iter(["op_claimed", None]) def fake_post(*_args: object, **_kwargs: object) -> MagicMock: resp = MagicMock() resp.is_success = True resp.status_code = 200 - op = next(operators) - resp.json = lambda op=op: {"resolved_operator": op} + resp.json = lambda: { + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_claimed", + "signer_match": { + "kind": "wallet_signer_mismatch", + "claimed_operator": "op_claimed", + "signer_operator": None, + "expected_signer": WALLET_A.lower(), + "actual_signer": WALLET_B.lower(), + }, + } return resp with patch.object(client._sync_client, "post", side_effect=fake_post): @@ -217,23 +248,31 @@ async def test_averify_wallet_signer_match_byte_equal_pass() -> None: @pytest.mark.asyncio async def test_averify_wallet_signer_match_linked_wallets_threaded_through() -> None: - """Async path surfaces linked_wallets from the claimed wallet's /v1/assess response.""" + """Async path surfaces linked_wallets from the server-side signer_match response.""" from unittest.mock import AsyncMock client = GateClient(api_key=API_KEY) extra_wallet = "0xcccc000000000000000000000000000000000000" - responses = iter( - [ - {"resolved_operator": "op_claimed", "linked_wallets": [WALLET_A.lower(), extra_wallet]}, - {"resolved_operator": "op_signer", "linked_wallets": []}, - ] - ) async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: resp = MagicMock() resp.is_success = True resp.status_code = 200 - resp.json = MagicMock(return_value=next(responses)) + resp.json = MagicMock( + return_value={ + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_claimed", + "signer_match": { + "kind": "wallet_signer_mismatch", + "claimed_operator": "op_claimed", + "signer_operator": "op_signer", + "expected_signer": WALLET_A.lower(), + "actual_signer": WALLET_B.lower(), + "linked_wallets": [WALLET_A.lower(), extra_wallet], + }, + } + ) return resp client._async_client.post = AsyncMock(side_effect=fake_apost) @@ -241,7 +280,7 @@ async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), ) assert result.kind == "wallet_signer_mismatch" - assert result.linked_wallets == [WALLET_A.lower(), "0xcccc000000000000000000000000000000000000"] + assert result.linked_wallets == [WALLET_A.lower(), extra_wallet] @pytest.mark.asyncio @@ -528,20 +567,27 @@ async def test_sanic_verify_wallet_signer_match_no_op_when_both_headers_sent() - def test_verify_wallet_signer_match_linked_wallets_threaded_through_sync() -> None: - """Sync path surfaces linked_wallets from the claimed wallet's /v1/assess response.""" + """Sync path surfaces linked_wallets from the server-side signer_match response.""" client = GateClient(api_key=API_KEY) extra_wallet = "0xcccc000000000000000000000000000000000000" - responses = iter( - [ - {"resolved_operator": "op_claimed", "linked_wallets": [WALLET_A.lower(), extra_wallet]}, - {"resolved_operator": "op_signer", "linked_wallets": []}, - ] - ) def fake_post(*_args: object, **_kwargs: object) -> MagicMock: resp = MagicMock() + resp.status_code = 200 resp.is_success = True - resp.json.return_value = next(responses) + resp.json.return_value = { + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_claimed", + "signer_match": { + "kind": "wallet_signer_mismatch", + "claimed_operator": "op_claimed", + "signer_operator": "op_signer", + "expected_signer": WALLET_A.lower(), + "actual_signer": WALLET_B.lower(), + "linked_wallets": [WALLET_A.lower(), extra_wallet], + }, + } return resp with patch.object(client._sync_client, "post", side_effect=fake_post): @@ -714,8 +760,13 @@ def test_verify_wallet_signer_match_posts_pass_telemetry() -> None: def capture(url: str, **kwargs: object) -> MagicMock: if "/v1/telemetry/signer-match" in url: - body = json.loads(kwargs["content"]) # type: ignore[arg-type] - telemetry_calls.append(body["kind"]) + # SDK uses httpx's `json=` kwarg (auto-serializes dict). Older tests + # patched a raw httpx call that took `content=`; both shapes + # carry the same payload — try `json` first, fall back to `content`. + payload = kwargs.get("json") + if payload is None and "content" in kwargs: + payload = json.loads(kwargs["content"]) # type: ignore[arg-type] + telemetry_calls.append(payload["kind"]) # type: ignore[index] resp = MagicMock() resp.is_success = True resp.status_code = 201 @@ -737,8 +788,13 @@ def test_verify_wallet_signer_match_posts_requires_signing_telemetry() -> None: def capture(url: str, **kwargs: object) -> MagicMock: if "/v1/telemetry/signer-match" in url: - body = json.loads(kwargs["content"]) # type: ignore[arg-type] - telemetry_calls.append(body["kind"]) + # SDK uses httpx's `json=` kwarg (auto-serializes dict). Older tests + # patched a raw httpx call that took `content=`; both shapes + # carry the same payload — try `json` first, fall back to `content`. + payload = kwargs.get("json") + if payload is None and "content" in kwargs: + payload = json.loads(kwargs["content"]) # type: ignore[arg-type] + telemetry_calls.append(payload["kind"]) # type: ignore[index] resp = MagicMock() resp.is_success = True resp.status_code = 201 @@ -766,3 +822,336 @@ def raiser(*_args: object, **_kwargs: object) -> MagicMock: VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_A), ) assert result.kind == "pass" + + +@pytest.mark.asyncio +async def test_averify_wallet_signer_match_telemetry_failure_does_not_raise() -> None: + """Async path mirror — telemetry outage must not break gate decisions.""" + client = GateClient(api_key=API_KEY) + + async def araiser(*_args: object, **_kwargs: object) -> MagicMock: + raise httpx.HTTPError("telemetry outage") + + from unittest.mock import AsyncMock + + client._async_client.post = AsyncMock(side_effect=araiser) + result = await client.averify_wallet_signer_match( + VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_A), + ) + assert result.kind == "pass" + + +@pytest.mark.asyncio +async def test_averify_wallet_signer_match_pass_via_shared_operator() -> None: + """Different signer wallet but same operator → pass. + + Mirrors the sync ``test_verify_wallet_signer_match_pass_via_shared_operator`` + branch — pinned independently because the async path's signer_match decode + + telemetry chain is wired separately and a regression in either wouldn't show + up via the sync test. + """ + client = GateClient(api_key=API_KEY) + + async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: + resp = MagicMock() + resp.status_code = 200 + resp.is_success = True + resp.json.return_value = { + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_shared", + "signer_match": { + "kind": "pass", + "claimed_operator": "op_shared", + "signer_operator": "op_shared", + }, + } + return resp + + from unittest.mock import AsyncMock + + client._async_client.post = AsyncMock(side_effect=fake_apost) + result = await client.averify_wallet_signer_match( + VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), + ) + assert result.kind == "pass" + assert result.claimed_operator == "op_shared" + assert result.signer_operator == "op_shared" + + +def test_verify_wallet_signer_match_cache_hit_skips_assess() -> None: + """Repeat (claimed, signer) lookup hits the signer_match cache — no fresh assess.""" + client = GateClient(api_key=API_KEY) + call_count = 0 + + def fake_post(*_args: object, **_kwargs: object) -> MagicMock: + nonlocal call_count + call_count += 1 + resp = MagicMock() + resp.is_success = True + resp.status_code = 200 + resp.json = lambda: { + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_shared", + "signer_match": {"kind": "pass", "claimed_operator": "op_shared", "signer_operator": "op_shared"}, + } + return resp + + opts = VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B) + with patch.object(client._sync_client, "post", side_effect=fake_post): + client.verify_wallet_signer_match(opts) + first = call_count + client.verify_wallet_signer_match(opts) + assert call_count == first # second run reads cached signer_match + + +def test_verify_wallet_signer_match_legacy_fallback_when_signer_match_absent() -> None: + """Old API responses lack signer_match — commerce falls back to the 2-resolve path.""" + client = GateClient(api_key=API_KEY) + call_index = 0 + + def fake_post(*_args: object, **_kwargs: object) -> MagicMock: + nonlocal call_index + call_index += 1 + resp = MagicMock() + resp.is_success = True + resp.status_code = 200 + bodies = [ + {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_claimed"}, + { + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_claimed", + "linked_wallets": [WALLET_A.lower()], + }, + {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_signer"}, + ] + resp.json = lambda i=call_index - 1: bodies[i] if i < len(bodies) else {} + return resp + + with patch.object(client._sync_client, "post", side_effect=fake_post): + result = client.verify_wallet_signer_match( + VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), + ) + assert result.kind == "wallet_signer_mismatch" + assert result.claimed_operator == "op_claimed" + assert result.actual_signer_operator == "op_signer" + + +def test_verify_wallet_signer_match_assess_failure_returns_api_error() -> None: + """SDK raising AgentScoreError on the resolve_signer-aware assess → api_error.""" + from agentscore import AgentScoreError as SdkErr + + client = GateClient(api_key=API_KEY) + + def raise_err(*_args: object, **_kwargs: object) -> object: + raise SdkErr("network_error", "DNS failure", 0) + + with patch.object(client._sdk, "assess", side_effect=raise_err): + result = client.verify_wallet_signer_match( + VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), + ) + assert result.kind == "api_error" + assert result.claimed_wallet == WALLET_A.lower() + + +@pytest.mark.asyncio +async def test_averify_wallet_signer_match_assess_failure_returns_api_error() -> None: + """Async mirror — SDK raising AgentScoreError on aassess → api_error.""" + from unittest.mock import AsyncMock + + from agentscore import AgentScoreError as SdkErr + + client = GateClient(api_key=API_KEY) + + async def raise_err(*_args: object, **_kwargs: object) -> object: + raise SdkErr("network_error", "DNS failure", 0) + + client._sdk.aassess = AsyncMock(side_effect=raise_err) + result = await client.averify_wallet_signer_match( + VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), + ) + assert result.kind == "api_error" + assert result.claimed_wallet == WALLET_A.lower() + + +@pytest.mark.asyncio +async def test_averify_wallet_signer_match_cache_hit_skips_assess() -> None: + """Async mirror of cache hit test.""" + from unittest.mock import AsyncMock + + client = GateClient(api_key=API_KEY) + call_count = 0 + + async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: + nonlocal call_count + call_count += 1 + resp = MagicMock() + resp.is_success = True + resp.status_code = 200 + resp.json = MagicMock( + return_value={ + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_shared", + "signer_match": {"kind": "pass", "claimed_operator": "op_shared", "signer_operator": "op_shared"}, + } + ) + return resp + + client._async_client.post = AsyncMock(side_effect=fake_apost) + opts = VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B) + await client.averify_wallet_signer_match(opts) + first = call_count + await client.averify_wallet_signer_match(opts) + assert call_count == first + + +def test_verify_wallet_signer_match_solana_signer_normalizes_correctly() -> None: + """Solana base58 signers must NOT be lowercased — `_infer_signer_network` returns 'solana'.""" + client = GateClient(api_key=API_KEY) + solana_claimed = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy" + solana_signer = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM" + + captured_body: dict[str, object] = {} + + def fake_post(*_args: object, **kwargs: object) -> MagicMock: + # SDK serializes the body via httpx `json=` kwarg. + if "json" in kwargs: + payload = kwargs["json"] + if isinstance(payload, dict): + captured_body.update(payload) + resp = MagicMock() + resp.is_success = True + resp.status_code = 200 + resp.json = lambda: { + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_x", + "signer_match": {"kind": "pass", "claimed_operator": "op_x", "signer_operator": "op_x"}, + } + return resp + + with patch.object(client._sync_client, "post", side_effect=fake_post): + result = client.verify_wallet_signer_match( + VerifyWalletSignerMatchOptions(claimed_wallet=solana_claimed, signer=solana_signer), + ) + assert result.kind == "pass" + # Solana detection: base58 (no 0x prefix) → network='solana' on the request body. + rs = captured_body.get("resolve_signer", {}) + if isinstance(rs, dict): + assert rs.get("network") == "solana" + + +def test_verify_wallet_signer_match_signer_match_with_wallet_auth_requires_signing_kind() -> None: + """API-side `wallet_auth_requires_wallet_signing` verdict (e.g. signer null on the API + side) projects onto VerifyWalletSignerResult of the same kind. Distinct from the + client-side null short-circuit because the API may emit this for other reasons. + """ + client = GateClient(api_key=API_KEY) + + def fake_post(*_args: object, **_kwargs: object) -> MagicMock: + resp = MagicMock() + resp.is_success = True + resp.status_code = 200 + resp.json = lambda: { + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_x", + "signer_match": { + "kind": "wallet_auth_requires_wallet_signing", + "claimed_wallet": WALLET_A.lower(), + "agent_instructions": json.dumps( + { + "action": "switch_to_operator_token", + "steps": [], + "user_message": "test", + } + ), + }, + } + return resp + + with patch.object(client._sync_client, "post", side_effect=fake_post): + result = client.verify_wallet_signer_match( + VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), + ) + assert result.kind == "wallet_auth_requires_wallet_signing" + assert result.claimed_wallet == WALLET_A.lower() + + +def test_verify_wallet_signer_match_legacy_fallback_uses_resolve_cache() -> None: + """Legacy fallback path reads the resolve: cache when a previous resolveWalletToOperator + call already populated it — saves the second /v1/assess on the legacy branch. + """ + from agentscore_commerce.identity.types import AssessResult + + client = GateClient(api_key=API_KEY) + # Pre-warm the resolve: cache for both wallets so the legacy fallback hits cache, + # not the network. Mirrors what would happen if a prior signer-match-disabled + # API response had already left these entries in cache from a different gate path. + client._cache.set( + f"resolve:{WALLET_A.lower()}", + AssessResult( + allow=True, raw={"resolved_operator": "op_shared", "linked_wallets": [WALLET_A.lower(), WALLET_B.lower()]} + ), + ) + client._cache.set( + f"resolve:{WALLET_B.lower()}", + AssessResult( + allow=True, raw={"resolved_operator": "op_shared", "linked_wallets": [WALLET_A.lower(), WALLET_B.lower()]} + ), + ) + + def fake_post(*_args: object, **_kwargs: object) -> MagicMock: + # Only the resolveSigner-aware assess hits the wire; both legacy resolves hit cache. + resp = MagicMock() + resp.is_success = True + resp.status_code = 200 + # No signer_match in body → falls through to legacy. Cache hits for both resolves. + resp.json = lambda: {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_shared"} + return resp + + with patch.object(client._sync_client, "post", side_effect=fake_post): + result = client.verify_wallet_signer_match( + VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), + ) + assert result.kind == "pass" + assert result.claimed_operator == "op_shared" + + +@pytest.mark.asyncio +async def test_averify_wallet_signer_match_legacy_fallback_when_signer_match_absent() -> None: + """Async mirror of legacy fallback path.""" + from unittest.mock import AsyncMock + + client = GateClient(api_key=API_KEY) + call_index = 0 + + async def fake_apost(*_args: object, **_kwargs: object) -> MagicMock: + nonlocal call_index + call_index += 1 + resp = MagicMock() + resp.is_success = True + resp.status_code = 200 + bodies = [ + {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_claimed"}, + { + "decision": "allow", + "decision_reasons": [], + "resolved_operator": "op_claimed", + "linked_wallets": [WALLET_A.lower()], + }, + {"decision": "allow", "decision_reasons": [], "resolved_operator": "op_signer"}, + ] + resp.json = MagicMock(return_value=bodies[call_index - 1] if call_index <= len(bodies) else {}) + return resp + + client._async_client.post = AsyncMock(side_effect=fake_apost) + result = await client.averify_wallet_signer_match( + VerifyWalletSignerMatchOptions(claimed_wallet=WALLET_A, signer=WALLET_B), + ) + assert result.kind == "wallet_signer_mismatch" + assert result.claimed_operator == "op_claimed" + assert result.actual_signer_operator == "op_signer" diff --git a/uv.lock b/uv.lock index 2496124..972b0c4 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "1.0.3" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "agentscore-py" }, @@ -72,7 +72,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agentscore-py", specifier = ">=2.0.0" }, + { name = "agentscore-py", specifier = ">=2.1.0" }, { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.8.0" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" }, @@ -110,14 +110,14 @@ dev = [ [[package]] name = "agentscore-py" -version = "2.0.2" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/b4/198df9e3f774ab9366b34f68cddfb0ababde0bf3c1bb58ee7c6ca9d10a68/agentscore_py-2.0.2.tar.gz", hash = "sha256:16b16af6a93e56db00dee17db3d1c17772c99a4c98ccc5c544895c7abfce1747", size = 50747, upload-time = "2026-05-01T04:15:08.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/aa/d857b03cd36cbc44cdce45f180fb639762a658bec492767c11aa97936bf1/agentscore_py-2.1.0.tar.gz", hash = "sha256:4fc811703ccc463dcd689b90fede67b016ea62d6a344e209d9669cd66ec5463c", size = 57894, upload-time = "2026-05-02T04:51:19.978Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/b4/ee74a2f1baa6863ac09d8b0c6a72e67d4e2b2bc20f386b2607ad04085e39/agentscore_py-2.0.2-py3-none-any.whl", hash = "sha256:95447ae29cdb490f988534ac53fabc49d6029b7605cb24f5b3bc0e3b524e98df", size = 14483, upload-time = "2026-05-01T04:15:07.475Z" }, + { url = "https://files.pythonhosted.org/packages/6d/85/cccf18bbf9752edbc39f5dcc718afcdf4973a8be62067582a3d385085894/agentscore_py-2.1.0-py3-none-any.whl", hash = "sha256:11e6fb0c346540d68c11526b01046b6bdbdb0db66ae3a424490587206ce03af1", size = 19086, upload-time = "2026-05-02T04:51:18.503Z" }, ] [[package]] @@ -2764,26 +2764,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.33" +version = "0.0.34" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/44/9478c50c266826c1bf30d1692e589755bffa8f1c0a3eb7af8a346c255991/ty-0.0.33.tar.gz", hash = "sha256:46d63bda07403322cb6c28ccfdd5536be916e13df725c29f7ccd0a21f06bd9e8", size = 5559373, upload-time = "2026-04-28T10:45:13.18Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/69/e24eefe2c35c0fdbdec9b60e162727af669bb76d64d993d982eb67b24c38/ty-0.0.34.tar.gz", hash = "sha256:a6efe66b0f13c03a65e6c72ec9abfe2792e2fd063c74fa67e2c4930e29d661be", size = 5585933, upload-time = "2026-05-01T23:06:46.388Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/24/e287388c63a19191be26b32ff4dbd06029834068150ebe2532939bc4c851/ty-0.0.33-py3-none-linux_armv6l.whl", hash = "sha256:94d0a9d2234261a8911396d59e506b5923fe0971dbda43b9dcea287936887fcc", size = 11021308, upload-time = "2026-04-28T10:45:43.34Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/ba1eed819895bd239fba8ee35dfcd5fcb266c203b0914a17a59579096bb5/ty-0.0.33-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4a2b5ba078f90de342f56b5f7979bb77c9b9b1d8625a041352ffc6ee93c4073", size = 10777272, upload-time = "2026-04-28T10:45:32.905Z" }, - { url = "https://files.pythonhosted.org/packages/25/a8/c3131d37b44b3fea1d6654a1c929a0cd0873822f77a90482b8ec28f6fbbd/ty-0.0.33-py3-none-macosx_11_0_arm64.whl", hash = "sha256:84ff5707825e9af9668d2bcf66975f93e520a63b524ab494e3a8265735be2563", size = 10201078, upload-time = "2026-04-28T10:45:23.374Z" }, - { url = "https://files.pythonhosted.org/packages/7b/db/d8e37ff0045810cc65e1ff36aa0da0a2253c05659787ac987df8a16c7897/ty-0.0.33-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e375285736f57886868e7af0b11c7b0ec5b6543fa15e7ad2a714fed9f077d4e0", size = 10732347, upload-time = "2026-04-28T10:45:21.444Z" }, - { url = "https://files.pythonhosted.org/packages/e0/1a/20e83a412506a918e4684fc67b567cf7cc13b105470b3428cb23c3d5aa13/ty-0.0.33-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5680f6350c3b4e46b8bff6d7bb132366ea239463d6cad4892725d06046e65464", size = 10808238, upload-time = "2026-04-28T10:45:38.565Z" }, - { url = "https://files.pythonhosted.org/packages/5d/4b/d0a39f4464dc6cb4cc2c159473ce216bd1846bfb684c0323a3cb36dce5c6/ty-0.0.33-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5535538bad8d0f7e62bcdff02197cdb30e41451d80b35d27e17d128f2e1dc5d", size = 11288348, upload-time = "2026-04-28T10:45:08.419Z" }, - { url = "https://files.pythonhosted.org/packages/35/7e/f1745e0f9583363d7a83d9a4990fc244f76ecc30840ddad83dc16a33c52d/ty-0.0.33-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:da196c42bbbc069e1e21e3e52107c061aa9660352dae57a41930690b56e2c02d", size = 11789907, upload-time = "2026-04-28T10:45:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a5/71/25f39f46a12d662859d45bc648555d0661044eb43db6b5648c9947487da9/ty-0.0.33-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9281672921ef6d4460e03146b5e6c18cb1a3e3a3b8a1a88f6f33226d05a469b7", size = 11500774, upload-time = "2026-04-28T10:45:48.012Z" }, - { url = "https://files.pythonhosted.org/packages/94/ec/136959ecbb7c71cb90537f5aea441c73f4ab24612868a6ecdc9d7444d32d/ty-0.0.33-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c1b8f303f82da64e878108e764be3ecbcd7c9903ac0a7f7031614ed00b97ab", size = 11360314, upload-time = "2026-04-28T10:45:05.402Z" }, - { url = "https://files.pythonhosted.org/packages/cf/95/32809575c222f00beed498cb728e9290a0f5009f930025381bb7253b2206/ty-0.0.33-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:efe3af412c9ff67bce5fa37d0a2b0d8555c24072b145a5bac6c79637f1c83abe", size = 10707785, upload-time = "2026-04-28T10:45:10.836Z" }, - { url = "https://files.pythonhosted.org/packages/13/89/c8e9531f7aa4a093359e15fa32c8e1277fbbe90d16894d7c6032d29f4b34/ty-0.0.33-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aeec29c91ea768601747da546c3efc20b72c2fb1bd52bcc786a5c6eeff51d27b", size = 10834987, upload-time = "2026-04-28T10:45:40.738Z" }, - { url = "https://files.pythonhosted.org/packages/31/16/9835fbcf5338af1a1917bd28fdb8a7193c210b83f243aa286fa9f79cb3ad/ty-0.0.33-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a535977c52bbb5f7e96b8b70a6ad375ad077f4a9ff2492508ea3816a2b403819", size = 10968968, upload-time = "2026-04-28T10:45:30.26Z" }, - { url = "https://files.pythonhosted.org/packages/36/69/64c76aabc1bc70c7f24b686cd93c3407f8ea430905e395f59bf9603ef571/ty-0.0.33-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1d732facf39fcb221ba279d469c5040d37883e964f123b1563888efd34818180", size = 11458077, upload-time = "2026-04-28T10:45:45.971Z" }, - { url = "https://files.pythonhosted.org/packages/91/84/fae27b0c4718776a298690d31ca4cc1995f2e3e1c63a7b59e84c41498e9a/ty-0.0.33-py3-none-win32.whl", hash = "sha256:d90960b574428dc252f85e8598ec5fcb7f619794196b2fc95a90da075ed4681c", size = 10345364, upload-time = "2026-04-28T10:45:16.836Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a0/a2938b23ae3e1a09a2d7c189e2ac5f7113676bae4e0e23948b568e18e5f8/ty-0.0.33-py3-none-win_amd64.whl", hash = "sha256:c1c3aec62c44de610c6e95f0a4e97ac3dbc07934bfdbf1fd90d758c9ff72f48e", size = 11342470, upload-time = "2026-04-28T10:45:26.455Z" }, - { url = "https://files.pythonhosted.org/packages/ab/62/7fb948aace38d2f6329261bb33c035a8484549c74f1db28649c7a4c6fed9/ty-0.0.33-py3-none-win_arm64.whl", hash = "sha256:0d44f99ba1b441e55e2aa301b2ac0a21112784931b46a5f66f4ea9efe5620d97", size = 10742673, upload-time = "2026-04-28T10:45:35.555Z" }, + { url = "https://files.pythonhosted.org/packages/83/7b/8b85003d6639ef17a97dcbb31f4511cfe78f1c81a964470db100c8c883e7/ty-0.0.34-py3-none-linux_armv6l.whl", hash = "sha256:9ecc3d14f07a95a6ceb88e07f8e62358dbd37325d3d5bd56da7217ff1fef7fb8", size = 11067094, upload-time = "2026-05-01T23:06:21.133Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/b0098f65b020b015c40567c763fc66fffbec88b2ba6f584bca1e92f05ebb/ty-0.0.34-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0dccffd8a9d02321cd2dee3249df205e26d62694e741f4eeca36b157fd8b419f", size = 10840909, upload-time = "2026-05-01T23:06:18.409Z" }, + { url = "https://files.pythonhosted.org/packages/e4/55/5e4adcf7d2a1006b844903b27cb81244a9b748d850433a46a6c21776c401/ty-0.0.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0ea47a2998e167ab3b21d2f4b5309a9cf33c297809f6d7e3e753252223174d0", size = 10279378, upload-time = "2026-05-01T23:06:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/f537dca0db8fe2558e8ab04d8941d687b384fcc1df5eb9023b2db75ac26c/ty-0.0.34-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37da00b41a118a459ae56d8947e70651073fb33ebfbceb820e4a10b22d5023", size = 10817423, upload-time = "2026-05-01T23:06:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c4/55a3ad1da2815af1009bdc1b8c90dc11a364cd314e4b48c5128ba9d38859/ty-0.0.34-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81cbbb93c2342fe3de43e625d3a9eb149633e9f485e816ebf6395d08685355d8", size = 10851826, upload-time = "2026-05-01T23:06:24.198Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/9c7606af22d73fb43ea4369472d9c66ece11231be73b0efe8e3c61655559/ty-0.0.34-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c5b4dea1594a021289e172582df9cde7089dce14b276fc650e7b212b1772e12", size = 11356318, upload-time = "2026-05-01T23:06:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/20/54/bb423f663721ab4138b216425c6b55eaefd3a068243b24d6d8fe988f4e13/ty-0.0.34-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:030fb00aa2d2a5b5ae9d9183d574e0c82dae80566700a7490c43669d8ece40cd", size = 11902968, upload-time = "2026-05-01T23:06:35.82Z" }, + { url = "https://files.pythonhosted.org/packages/b6/22/01122b21ab6b534a2f618c6bbe5f1f7f49fd56f4b2ec8887cd6d40d08fb3/ty-0.0.34-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ae9555e24e36c63a8218e037a5a63f15579eb6aa94f41017e57cd41d335cfb5", size = 11548860, upload-time = "2026-05-01T23:06:42.155Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/86008b1392ec64bed1957bbcc7aaa43b466b50dfc91bb131841c21d7c5c3/ty-0.0.34-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99eb23df9ed129fc26d1ab00d6f0b8dfe5253b09c2ac6abdb11523fa70d67f10", size = 11457097, upload-time = "2026-05-01T23:06:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/92/3e/4558b2296963ba99c58d8409c57d7db4f3061b656c3613cb21c02c1ef4c2/ty-0.0.34-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85de45382016eceae69e104815eb2cfa200787df104002e262a86cbd43ed2c02", size = 10798192, upload-time = "2026-05-01T23:06:40.004Z" }, + { url = "https://files.pythonhosted.org/packages/76/bf/650d24402be2ef678528d60caac1d9477a40fc37e3792ecef07834fd7a4a/ty-0.0.34-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:14cb575fb8fa5131f5129d100cfe23c1575d23faf5dfc5158432749a3e38c9b5", size = 10890390, upload-time = "2026-05-01T23:06:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ef/ccd2ca13906079f7935fd7e067661b24233017f57d987d51d6a121d85bb5/ty-0.0.34-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c6fc0b69d8450e6910ba9db34572b959b81329a97ae273c391f70e9fb6c1aade", size = 11031564, upload-time = "2026-05-01T23:06:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2d/d27b72005b6f43599e3bcabab0d7135ac0c230b7a307bb99f9eea02c1cda/ty-0.0.34-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:30dfcec2f0fde3993f4f912ed0e057dcbebc8615299f610a4c2ddb7b5a3e1e06", size = 11553430, upload-time = "2026-05-01T23:06:31.096Z" }, + { url = "https://files.pythonhosted.org/packages/a7/12/20812e1ad930b8d4af70eebf19ad23cff6e31efcfa613ef884531fcdbaa1/ty-0.0.34-py3-none-win32.whl", hash = "sha256:97b77ddf007271b812a313a8f0a14929bc5590958433e1fb83ef585676f53342", size = 10436048, upload-time = "2026-05-01T23:06:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/afa095c5987868fbda27c0f731146ac8e3d07b357adfa83daccaee5b1a16/ty-0.0.34-py3-none-win_amd64.whl", hash = "sha256:1f543968accb952705134028d1fda8656882787dbbc667ad4d6c3ba23791d604", size = 11462526, upload-time = "2026-05-01T23:06:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/63/8f/bf041a06260d77662c0605e56dacfe90b786bf824cbe1aed238d15fe5e84/ty-0.0.34-py3-none-win_arm64.whl", hash = "sha256:ea09108cbcb16b6b06d7596312b433bf49681e78d30e4dc7fb3c1b248a95e09a", size = 10846945, upload-time = "2026-05-01T23:06:44.428Z" }, ] [[package]] @@ -3069,7 +3069,7 @@ wheels = [ [[package]] name = "web3" -version = "7.15.0" +version = "7.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -3087,9 +3087,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/84/1516ee335e89e4d7d0837895d21091244ee3189d1ffe76fa435d2bd15f4e/web3-7.15.0.tar.gz", hash = "sha256:2a2bcbab8fcf120c2256ddbdc88fcc80a47c100ad758659a980e9fab66609171", size = 2211838, upload-time = "2026-04-02T18:20:40.355Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/d9/bdfa9e715804020c3f3676346065c18adbc207c9343a3458246d7430f45c/web3-7.16.0.tar.gz", hash = "sha256:b4a75a3fa94fef4d23d502eb3c2244146ef9a1ee0082cf1cb0a91586ba0510c3", size = 2211469, upload-time = "2026-05-01T21:22:20.666Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/f0/ef9b82557b22b1cdb95ec7863d1d9897ed3b9e096a1af5e046fb892efdc0/web3-7.15.0-py3-none-any.whl", hash = "sha256:bbeba510aaa4eb3c99662d911db9d2a648ab40f608cf0269065f7db1d1d0b0c0", size = 1373052, upload-time = "2026-04-02T18:20:36.757Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f9/5345c13f8469f3ce344b4d9934c0387b83a49420192482111e9c1fa95ec2/web3-7.16.0-py3-none-any.whl", hash = "sha256:760b2718c473980d70708c3593d9d28395db4b482f45e38a63a36fa028178f51", size = 1372181, upload-time = "2026-05-01T21:22:17.015Z" }, ] [[package]] diff --git a/vulture_whitelist.py b/vulture_whitelist.py index dcb965d..ef2b8b7 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -20,3 +20,7 @@ ScoreDetail # noqa: F821 ScoreStatus # noqa: F821 OperatorVerification # noqa: F821 + +# TYPE_CHECKING imports referenced inside string-literal cast() calls +DecisionPolicy # noqa: F821 +ResolveSigner # noqa: F821