agentapprovalprotocol 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentapprovalprotocol/__init__.py +33 -0
- agentapprovalprotocol/_client.py +201 -0
- agentapprovalprotocol/_gate.py +310 -0
- agentapprovalprotocol/_wire.py +229 -0
- agentapprovalprotocol/langgraph.py +255 -0
- agentapprovalprotocol/py.typed +0 -0
- agentapprovalprotocol/pydantic_ai.py +255 -0
- agentapprovalprotocol/receiver.py +168 -0
- agentapprovalprotocol/testing.py +173 -0
- agentapprovalprotocol-0.1.0.dist-info/METADATA +96 -0
- agentapprovalprotocol-0.1.0.dist-info/RECORD +12 -0
- agentapprovalprotocol-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Agent Approval Protocol SDK.
|
|
2
|
+
|
|
3
|
+
Put an agent's tool calls behind an AAP approval provider. ``Gate`` and
|
|
4
|
+
``AsyncGate`` decide whether one call may run; framework integrations build
|
|
5
|
+
on them. See https://agentapprovalprotocol.io.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._client import AAPError, AsyncClient, Client, ConflictError, CredentialRefused, NotFound
|
|
9
|
+
from ._gate import Allow, AsyncGate, Call, Deny, Gate, Pending, Verdict
|
|
10
|
+
from ._wire import Consumption, Decision, Outcome, ProtocolError, Request
|
|
11
|
+
from .receiver import Receiver
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AAPError",
|
|
15
|
+
"Allow",
|
|
16
|
+
"AsyncClient",
|
|
17
|
+
"AsyncGate",
|
|
18
|
+
"Call",
|
|
19
|
+
"Client",
|
|
20
|
+
"ConflictError",
|
|
21
|
+
"Consumption",
|
|
22
|
+
"CredentialRefused",
|
|
23
|
+
"Decision",
|
|
24
|
+
"Deny",
|
|
25
|
+
"Gate",
|
|
26
|
+
"NotFound",
|
|
27
|
+
"Outcome",
|
|
28
|
+
"Pending",
|
|
29
|
+
"ProtocolError",
|
|
30
|
+
"Receiver",
|
|
31
|
+
"Request",
|
|
32
|
+
"Verdict",
|
|
33
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""AAP HTTP client, in synchronous and asynchronous forms.
|
|
2
|
+
|
|
3
|
+
Both forms share the response rules below; only the transport differs.
|
|
4
|
+
Transport failures, 429 and 5xx responses are retried with the same
|
|
5
|
+
idempotency key until the retry budget runs out.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from typing import Any, Mapping
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ._wire import ProtocolError, Request, dumps, loads, parse_request
|
|
19
|
+
|
|
20
|
+
USER_AGENT = "agentapprovalprotocol-python"
|
|
21
|
+
MAX_RESPONSE = 8 << 20
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AAPError(Exception):
|
|
25
|
+
"""The operation did not produce a usable result."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CredentialRefused(AAPError):
|
|
29
|
+
"""The provider refused the instance credential (HTTP 401 or 403)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConflictError(AAPError):
|
|
33
|
+
"""A 409 answer, identified by the provider's error code."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, code: str, message: str = ""):
|
|
36
|
+
super().__init__(f"AAP conflict: {code}")
|
|
37
|
+
self.code = code
|
|
38
|
+
self.message = message
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotFound(AAPError):
|
|
42
|
+
"""The request is unknown or unavailable to this instance."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_base_url(base_url: str) -> str:
|
|
46
|
+
parts = urlsplit(base_url)
|
|
47
|
+
if not parts.netloc or parts.username or parts.password or parts.query or parts.fragment:
|
|
48
|
+
raise ValueError("base URL must be an absolute AAP URL without credentials, query or fragment")
|
|
49
|
+
local = parts.hostname in ("localhost", "127.0.0.1", "::1")
|
|
50
|
+
if parts.scheme != "https" and not (parts.scheme == "http" and local):
|
|
51
|
+
raise ValueError("base URL requires HTTPS, except on localhost")
|
|
52
|
+
return base_url.rstrip("/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _retry_after(response: httpx.Response) -> float | None:
|
|
56
|
+
value = response.headers.get("retry-after")
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
if value.isdigit():
|
|
60
|
+
return float(value)
|
|
61
|
+
try:
|
|
62
|
+
return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _result(response: httpx.Response, raw: bytes) -> Request | None:
|
|
68
|
+
"""Turn a final response into a request, raise, or return None to retry."""
|
|
69
|
+
code = response.status_code
|
|
70
|
+
if code == 429 or code >= 500:
|
|
71
|
+
return None
|
|
72
|
+
if code in (401, 403):
|
|
73
|
+
raise CredentialRefused(f"the provider refused the instance credential (HTTP {code})")
|
|
74
|
+
if code == 404:
|
|
75
|
+
raise NotFound("the request is unavailable")
|
|
76
|
+
if code == 409:
|
|
77
|
+
try:
|
|
78
|
+
error = loads(raw)["error"]
|
|
79
|
+
raise ConflictError(str(error["code"]), str(error.get("message", "")))
|
|
80
|
+
except (ProtocolError, KeyError, TypeError) as err:
|
|
81
|
+
raise AAPError("AAP request failed (HTTP 409)") from err
|
|
82
|
+
if code not in (200, 201):
|
|
83
|
+
raise AAPError(f"AAP request failed (HTTP {code})")
|
|
84
|
+
if len(raw) > MAX_RESPONSE:
|
|
85
|
+
raise ProtocolError("AAP response too large")
|
|
86
|
+
return parse_request(loads(raw))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _Base:
|
|
90
|
+
def __init__(self, base_url: str, token: str, *, retry_budget: float = 30.0):
|
|
91
|
+
if not token or "\n" in token or "\r" in token:
|
|
92
|
+
raise ValueError("instance token is required and must not contain newlines")
|
|
93
|
+
self.base_url = validate_base_url(base_url)
|
|
94
|
+
self.retry_budget = retry_budget
|
|
95
|
+
self._headers = {"Authorization": f"Bearer {token}", "User-Agent": USER_AGENT}
|
|
96
|
+
|
|
97
|
+
def _request(self, method: str, path: str, body: Mapping[str, Any] | None, key: str | None) -> tuple[dict[str, str], bytes | None]:
|
|
98
|
+
headers = dict(self._headers)
|
|
99
|
+
content = None
|
|
100
|
+
if body is not None:
|
|
101
|
+
headers["Content-Type"] = "application/json"
|
|
102
|
+
content = dumps(body)
|
|
103
|
+
if key is not None:
|
|
104
|
+
headers["Idempotency-Key"] = key
|
|
105
|
+
return headers, content
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Client(_Base):
|
|
109
|
+
"""Synchronous AAP client for one instance credential."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, base_url: str, token: str, *, http: httpx.Client | None = None, retry_budget: float = 30.0):
|
|
112
|
+
super().__init__(base_url, token, retry_budget=retry_budget)
|
|
113
|
+
self._http = http or httpx.Client(timeout=40.0, follow_redirects=False)
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
self._http.close()
|
|
117
|
+
|
|
118
|
+
def _send(self, method: str, path: str, body: Mapping[str, Any] | None = None, key: str | None = None, *, timeout: float | None = None) -> Request:
|
|
119
|
+
headers, content = self._request(method, path, body, key)
|
|
120
|
+
deadline = time.monotonic() + self.retry_budget
|
|
121
|
+
delay = 0.1
|
|
122
|
+
while True:
|
|
123
|
+
wait = delay
|
|
124
|
+
try:
|
|
125
|
+
response = self._http.request(method, self.base_url + path, headers=headers, content=content, timeout=timeout or 40.0)
|
|
126
|
+
raw = response.read()
|
|
127
|
+
result = _result(response, raw)
|
|
128
|
+
if result is not None:
|
|
129
|
+
return result
|
|
130
|
+
wait = max(delay, _retry_after(response) or 0.0)
|
|
131
|
+
except httpx.TransportError:
|
|
132
|
+
pass
|
|
133
|
+
if time.monotonic() + wait > deadline:
|
|
134
|
+
raise AAPError("the provider could not be reached")
|
|
135
|
+
time.sleep(wait)
|
|
136
|
+
delay = min(delay * 2, 2.0)
|
|
137
|
+
|
|
138
|
+
def create(self, submission: Mapping[str, Any], key: str) -> Request:
|
|
139
|
+
return self._send("POST", "/v1/requests", submission, key)
|
|
140
|
+
|
|
141
|
+
def get(self, request_id: str, *, wait: int | None = None) -> Request:
|
|
142
|
+
query = f"?wait={wait}s" if wait else ""
|
|
143
|
+
return self._send("GET", f"/v1/requests/{request_id}{query}", timeout=(wait or 0) + 10.0)
|
|
144
|
+
|
|
145
|
+
def cancel(self, request_id: str) -> Request:
|
|
146
|
+
return self._send("DELETE", f"/v1/requests/{request_id}")
|
|
147
|
+
|
|
148
|
+
def consume(self, request_id: str, key: str) -> Request:
|
|
149
|
+
return self._send("POST", f"/v1/requests/{request_id}/consume", None, key)
|
|
150
|
+
|
|
151
|
+
def report_outcome(self, request_id: str, status: str, note: str | None = None) -> Request:
|
|
152
|
+
body = {"status": status, **({"note": note} if note else {})}
|
|
153
|
+
return self._send("POST", f"/v1/requests/{request_id}/outcome", body)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class AsyncClient(_Base):
|
|
157
|
+
"""Asynchronous AAP client for one instance credential."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, base_url: str, token: str, *, http: httpx.AsyncClient | None = None, retry_budget: float = 30.0):
|
|
160
|
+
super().__init__(base_url, token, retry_budget=retry_budget)
|
|
161
|
+
self._http = http or httpx.AsyncClient(timeout=40.0, follow_redirects=False)
|
|
162
|
+
|
|
163
|
+
async def aclose(self) -> None:
|
|
164
|
+
await self._http.aclose()
|
|
165
|
+
|
|
166
|
+
async def _send(self, method: str, path: str, body: Mapping[str, Any] | None = None, key: str | None = None, *, timeout: float | None = None) -> Request:
|
|
167
|
+
headers, content = self._request(method, path, body, key)
|
|
168
|
+
deadline = time.monotonic() + self.retry_budget
|
|
169
|
+
delay = 0.1
|
|
170
|
+
while True:
|
|
171
|
+
wait = delay
|
|
172
|
+
try:
|
|
173
|
+
response = await self._http.request(method, self.base_url + path, headers=headers, content=content, timeout=timeout or 40.0)
|
|
174
|
+
raw = await response.aread()
|
|
175
|
+
result = _result(response, raw)
|
|
176
|
+
if result is not None:
|
|
177
|
+
return result
|
|
178
|
+
wait = max(delay, _retry_after(response) or 0.0)
|
|
179
|
+
except httpx.TransportError:
|
|
180
|
+
pass
|
|
181
|
+
if time.monotonic() + wait > deadline:
|
|
182
|
+
raise AAPError("the provider could not be reached")
|
|
183
|
+
await asyncio.sleep(wait)
|
|
184
|
+
delay = min(delay * 2, 2.0)
|
|
185
|
+
|
|
186
|
+
async def create(self, submission: Mapping[str, Any], key: str) -> Request:
|
|
187
|
+
return await self._send("POST", "/v1/requests", submission, key)
|
|
188
|
+
|
|
189
|
+
async def get(self, request_id: str, *, wait: int | None = None) -> Request:
|
|
190
|
+
query = f"?wait={wait}s" if wait else ""
|
|
191
|
+
return await self._send("GET", f"/v1/requests/{request_id}{query}", timeout=(wait or 0) + 10.0)
|
|
192
|
+
|
|
193
|
+
async def cancel(self, request_id: str) -> Request:
|
|
194
|
+
return await self._send("DELETE", f"/v1/requests/{request_id}")
|
|
195
|
+
|
|
196
|
+
async def consume(self, request_id: str, key: str) -> Request:
|
|
197
|
+
return await self._send("POST", f"/v1/requests/{request_id}/consume", None, key)
|
|
198
|
+
|
|
199
|
+
async def report_outcome(self, request_id: str, status: str, note: str | None = None) -> Request:
|
|
200
|
+
body = {"status": status, **({"note": note} if note else {})}
|
|
201
|
+
return await self._send("POST", f"/v1/requests/{request_id}/outcome", body)
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""The gate: whether one tool call may run now.
|
|
2
|
+
|
|
3
|
+
Every check runs the same steps, whether the call is new or seen again
|
|
4
|
+
after a resume: create the request with the call's idempotency key (a
|
|
5
|
+
replay returns the existing request), wait or report pending, check the
|
|
6
|
+
decision, consume it, allow. A value a framework passes back on resume is
|
|
7
|
+
never read, so only the provider can approve. Any failure denies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import contextlib
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Iterator, Literal, Mapping
|
|
21
|
+
|
|
22
|
+
from ._client import AAPError, AsyncClient, Client, ConflictError, CredentialRefused
|
|
23
|
+
from ._wire import APPROVED, CANCELLED, DENIED, EXPIRED, PENDING, ProtocolError, Request, dumps, loads, matches
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("agentapprovalprotocol")
|
|
26
|
+
|
|
27
|
+
FAIL_CLOSED = "The approval adapter could not obtain a valid AAP decision. The call was not run."
|
|
28
|
+
REFUSED = "The approval provider refused this agent's credential, so no tool call can run. Ask whoever manages the agent. The call was not run."
|
|
29
|
+
EXPIRED_TEXT = "The approval window or permission to start this call expired. The call was not run."
|
|
30
|
+
CANCELLED_TEXT = "The call was cancelled before execution."
|
|
31
|
+
CONSUMED_TEXT = "This approved call was already attempted, so it was not run again. Request approval again if it still needs to happen."
|
|
32
|
+
TIMED_OUT = "Approval was not obtained in time. The call was not run."
|
|
33
|
+
|
|
34
|
+
Mode = Literal["sync", "async"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Call:
|
|
39
|
+
"""One tool call the gate is asked about.
|
|
40
|
+
|
|
41
|
+
``key`` must identify this tool call within its run and stay the same
|
|
42
|
+
when the call is seen again after a resume or replay. ``resume`` is how
|
|
43
|
+
a receiver wakes the run later, in async mode.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
key: str
|
|
47
|
+
tool: str
|
|
48
|
+
arguments: Mapping[str, Any]
|
|
49
|
+
server: str | None = None
|
|
50
|
+
reasoning: str | None = None
|
|
51
|
+
context: Mapping[str, Any] | None = None
|
|
52
|
+
resume: Mapping[str, Any] | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Allow:
|
|
57
|
+
"""The approval was checked and consumed. Run the tool now."""
|
|
58
|
+
|
|
59
|
+
request_id: str
|
|
60
|
+
_report: Callable[[str, str | None], Any] = field(repr=False)
|
|
61
|
+
|
|
62
|
+
def __bool__(self) -> bool:
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
def report(self, status: Literal["succeeded", "failed"], note: str | None = None) -> Any:
|
|
66
|
+
"""Report the outcome. Best effort: failures are logged, never raised."""
|
|
67
|
+
return self._report(status, note)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Deny:
|
|
72
|
+
"""The call must not run. ``reason`` is written for the model."""
|
|
73
|
+
|
|
74
|
+
reason: str
|
|
75
|
+
status: str
|
|
76
|
+
request_id: str | None = None
|
|
77
|
+
|
|
78
|
+
def __bool__(self) -> bool:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Pending:
|
|
84
|
+
"""Async mode: no decision yet. Pause the run; a receiver wakes it."""
|
|
85
|
+
|
|
86
|
+
request_id: str
|
|
87
|
+
|
|
88
|
+
def __bool__(self) -> bool:
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
Verdict = Allow | Deny | Pending
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def submission(call: Call, timeout: str, defaults: Mapping[str, Any]) -> dict[str, Any]:
|
|
96
|
+
context = {**defaults, **(call.context or {}), "call_id": call.key}
|
|
97
|
+
if call.resume is not None:
|
|
98
|
+
context["resume"] = dict(call.resume)
|
|
99
|
+
body: dict[str, Any] = {"tool": call.tool, "arguments": dict(call.arguments), "timeout": timeout, "context": context}
|
|
100
|
+
if call.server:
|
|
101
|
+
body["server"] = call.server
|
|
102
|
+
if call.reasoning:
|
|
103
|
+
body["agent_reasoning"] = call.reasoning
|
|
104
|
+
return body
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def idempotency_key(call: Call, body: Mapping[str, Any]) -> str:
|
|
108
|
+
"""Stable for the same call and submission; any change asks again."""
|
|
109
|
+
return hashlib.sha256(call.key.encode() + b"\x00" + dumps(body)).hexdigest()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _now() -> datetime:
|
|
113
|
+
return datetime.now(timezone.utc)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _settled(request: Request) -> Deny | None:
|
|
117
|
+
"""The deny for a terminal request, or None when it may be consumed."""
|
|
118
|
+
if request.status == DENIED:
|
|
119
|
+
note = request.decision.note if request.decision else None
|
|
120
|
+
return Deny(f"The provider denied this call: {note}" if note else "The provider denied this call.", DENIED, request.id)
|
|
121
|
+
if request.status == CANCELLED:
|
|
122
|
+
return Deny(CANCELLED_TEXT, CANCELLED, request.id)
|
|
123
|
+
if request.status == EXPIRED:
|
|
124
|
+
return Deny(EXPIRED_TEXT, EXPIRED, request.id)
|
|
125
|
+
assert request.status == APPROVED and request.decision and request.decision.expires_at
|
|
126
|
+
if request.consumption is not None:
|
|
127
|
+
return Deny(CONSUMED_TEXT, "consumed", request.id)
|
|
128
|
+
if _now() >= request.decision.expires_at:
|
|
129
|
+
return Deny(EXPIRED_TEXT, EXPIRED, request.id)
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _consumed(request: Request, consumed: Request, body: Mapping[str, Any]) -> bool:
|
|
134
|
+
return consumed.status == APPROVED and consumed.consumption is not None and matches(consumed, body, request)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _conflict(err: ConflictError, request_id: str) -> Deny:
|
|
138
|
+
if err.code == "already_consumed":
|
|
139
|
+
return Deny(CONSUMED_TEXT, "consumed", request_id)
|
|
140
|
+
if err.code == "approval_expired":
|
|
141
|
+
return Deny(EXPIRED_TEXT, EXPIRED, request_id)
|
|
142
|
+
return Deny(FAIL_CLOSED, "error", request_id)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _failure(err: BaseException, request_id: str | None) -> Deny:
|
|
146
|
+
if isinstance(err, CredentialRefused):
|
|
147
|
+
return Deny(REFUSED, "error", request_id)
|
|
148
|
+
if not isinstance(err, (AAPError, ProtocolError)):
|
|
149
|
+
log.exception("approval gate failed", exc_info=err)
|
|
150
|
+
return Deny(FAIL_CLOSED, "error", request_id)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _GateConfig:
|
|
154
|
+
def __init__(self, mode: Mode, timeout: str | None, wait_limit: float, context: Mapping[str, Any] | None, report_outcomes: bool):
|
|
155
|
+
if mode not in ("sync", "async"):
|
|
156
|
+
raise ValueError("mode must be sync or async")
|
|
157
|
+
self.mode = mode
|
|
158
|
+
self.timeout = timeout or ("24h" if mode == "async" else "10m")
|
|
159
|
+
self.wait_limit = wait_limit
|
|
160
|
+
self.context = dict(context or {})
|
|
161
|
+
self.report_outcomes = report_outcomes
|
|
162
|
+
|
|
163
|
+
def _poll_until(self, request: Request, started: float) -> float:
|
|
164
|
+
remaining = (request.deadline_at - _now()).total_seconds() + 10
|
|
165
|
+
return started + min(self.wait_limit, max(0.0, remaining))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class Gate(_GateConfig):
|
|
169
|
+
"""Synchronous gate.
|
|
170
|
+
|
|
171
|
+
In sync mode ``check`` waits for the decision. In async mode it returns
|
|
172
|
+
``Pending`` when there is none yet, unless the caller says this call
|
|
173
|
+
cannot be paused, in which case it waits.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, client: Client, *, mode: Mode = "sync", timeout: str | None = None, wait_limit: float = 540.0, context: Mapping[str, Any] | None = None, report_outcomes: bool = True):
|
|
177
|
+
super().__init__(mode, timeout, wait_limit, context, report_outcomes)
|
|
178
|
+
self.client = client
|
|
179
|
+
|
|
180
|
+
def check(self, call: Call, *, can_pause: bool = True) -> Verdict:
|
|
181
|
+
body = submission(call, self.timeout, self.context)
|
|
182
|
+
request: Request | None = None
|
|
183
|
+
try:
|
|
184
|
+
request = self.client.create(body, idempotency_key(call, body))
|
|
185
|
+
body = loads(dumps(body)) # compare responses with what was sent, exactly
|
|
186
|
+
if not matches(request, body):
|
|
187
|
+
raise ProtocolError("the provider returned a different request")
|
|
188
|
+
started = time.monotonic()
|
|
189
|
+
while request.status == PENDING:
|
|
190
|
+
if self.mode == "async" and can_pause:
|
|
191
|
+
return Pending(request.id)
|
|
192
|
+
left = self._poll_until(request, started) - time.monotonic()
|
|
193
|
+
if left <= 0:
|
|
194
|
+
self._abandon(request.id)
|
|
195
|
+
return Deny(TIMED_OUT, EXPIRED, request.id)
|
|
196
|
+
previous, polled = request, time.monotonic()
|
|
197
|
+
request = self.client.get(request.id, wait=max(1, min(25, int(left))))
|
|
198
|
+
if not matches(request, body, previous):
|
|
199
|
+
raise ProtocolError("the provider returned a different request")
|
|
200
|
+
if request.status == PENDING and time.monotonic() - polled < 0.1:
|
|
201
|
+
time.sleep(0.1) # the provider answered without waiting
|
|
202
|
+
if (deny := _settled(request)) is not None:
|
|
203
|
+
return deny
|
|
204
|
+
try:
|
|
205
|
+
consumed = self.client.consume(request.id, str(uuid.uuid4()))
|
|
206
|
+
except ConflictError as err:
|
|
207
|
+
return _conflict(err, request.id)
|
|
208
|
+
if not _consumed(request, consumed, body):
|
|
209
|
+
raise ProtocolError("invalid consumption")
|
|
210
|
+
return Allow(request.id, self._reporter(request.id))
|
|
211
|
+
except Exception as err: # noqa: BLE001 - every failure denies
|
|
212
|
+
return _failure(err, request.id if request else None)
|
|
213
|
+
|
|
214
|
+
def _abandon(self, request_id: str) -> None:
|
|
215
|
+
with contextlib.suppress(Exception):
|
|
216
|
+
self.client.cancel(request_id)
|
|
217
|
+
|
|
218
|
+
def _reporter(self, request_id: str) -> Callable[[str, str | None], bool]:
|
|
219
|
+
def report(status: str, note: str | None) -> bool:
|
|
220
|
+
try:
|
|
221
|
+
self.client.report_outcome(request_id, status, note)
|
|
222
|
+
return True
|
|
223
|
+
except Exception: # noqa: BLE001
|
|
224
|
+
log.warning("could not report the outcome of %s", request_id, exc_info=True)
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
return report
|
|
228
|
+
|
|
229
|
+
@contextlib.contextmanager
|
|
230
|
+
def guard(self, call: Call, *, can_pause: bool = True) -> Iterator[Verdict]:
|
|
231
|
+
"""Check the call; on an allow, report the outcome when the block exits."""
|
|
232
|
+
verdict = self.check(call, can_pause=can_pause)
|
|
233
|
+
try:
|
|
234
|
+
yield verdict
|
|
235
|
+
except BaseException:
|
|
236
|
+
if isinstance(verdict, Allow) and self.report_outcomes:
|
|
237
|
+
verdict.report("failed")
|
|
238
|
+
raise
|
|
239
|
+
if isinstance(verdict, Allow) and self.report_outcomes:
|
|
240
|
+
verdict.report("succeeded")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class AsyncGate(_GateConfig):
|
|
244
|
+
"""Asynchronous gate, the same rules as ``Gate`` on an ``AsyncClient``."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, client: AsyncClient, *, mode: Mode = "sync", timeout: str | None = None, wait_limit: float = 540.0, context: Mapping[str, Any] | None = None, report_outcomes: bool = True):
|
|
247
|
+
super().__init__(mode, timeout, wait_limit, context, report_outcomes)
|
|
248
|
+
self.client = client
|
|
249
|
+
|
|
250
|
+
async def check(self, call: Call, *, can_pause: bool = True) -> Verdict:
|
|
251
|
+
body = submission(call, self.timeout, self.context)
|
|
252
|
+
request: Request | None = None
|
|
253
|
+
try:
|
|
254
|
+
request = await self.client.create(body, idempotency_key(call, body))
|
|
255
|
+
body = loads(dumps(body)) # compare responses with what was sent, exactly
|
|
256
|
+
if not matches(request, body):
|
|
257
|
+
raise ProtocolError("the provider returned a different request")
|
|
258
|
+
started = time.monotonic()
|
|
259
|
+
while request.status == PENDING:
|
|
260
|
+
if self.mode == "async" and can_pause:
|
|
261
|
+
return Pending(request.id)
|
|
262
|
+
left = self._poll_until(request, started) - time.monotonic()
|
|
263
|
+
if left <= 0:
|
|
264
|
+
await self._abandon(request.id)
|
|
265
|
+
return Deny(TIMED_OUT, EXPIRED, request.id)
|
|
266
|
+
previous, polled = request, time.monotonic()
|
|
267
|
+
request = await self.client.get(request.id, wait=max(1, min(25, int(left))))
|
|
268
|
+
if not matches(request, body, previous):
|
|
269
|
+
raise ProtocolError("the provider returned a different request")
|
|
270
|
+
if request.status == PENDING and time.monotonic() - polled < 0.1:
|
|
271
|
+
await asyncio.sleep(0.1) # the provider answered without waiting
|
|
272
|
+
if (deny := _settled(request)) is not None:
|
|
273
|
+
return deny
|
|
274
|
+
try:
|
|
275
|
+
consumed = await self.client.consume(request.id, str(uuid.uuid4()))
|
|
276
|
+
except ConflictError as err:
|
|
277
|
+
return _conflict(err, request.id)
|
|
278
|
+
if not _consumed(request, consumed, body):
|
|
279
|
+
raise ProtocolError("invalid consumption")
|
|
280
|
+
return Allow(request.id, self._reporter(request.id))
|
|
281
|
+
except Exception as err: # noqa: BLE001 - every failure denies
|
|
282
|
+
return _failure(err, request.id if request else None)
|
|
283
|
+
|
|
284
|
+
async def _abandon(self, request_id: str) -> None:
|
|
285
|
+
with contextlib.suppress(Exception):
|
|
286
|
+
await self.client.cancel(request_id)
|
|
287
|
+
|
|
288
|
+
def _reporter(self, request_id: str) -> Callable[[str, str | None], Awaitable[bool]]:
|
|
289
|
+
async def report(status: str, note: str | None) -> bool:
|
|
290
|
+
try:
|
|
291
|
+
await self.client.report_outcome(request_id, status, note)
|
|
292
|
+
return True
|
|
293
|
+
except Exception: # noqa: BLE001
|
|
294
|
+
log.warning("could not report the outcome of %s", request_id, exc_info=True)
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
return report
|
|
298
|
+
|
|
299
|
+
@contextlib.asynccontextmanager
|
|
300
|
+
async def guard(self, call: Call, *, can_pause: bool = True) -> AsyncIterator[Verdict]:
|
|
301
|
+
"""Check the call; on an allow, report the outcome when the block exits."""
|
|
302
|
+
verdict = await self.check(call, can_pause=can_pause)
|
|
303
|
+
try:
|
|
304
|
+
yield verdict
|
|
305
|
+
except BaseException:
|
|
306
|
+
if isinstance(verdict, Allow) and self.report_outcomes:
|
|
307
|
+
await verdict.report("failed")
|
|
308
|
+
raise
|
|
309
|
+
if isinstance(verdict, Allow) and self.report_outcomes:
|
|
310
|
+
await verdict.report("succeeded")
|