agentapprovalprotocol 0.1.0__tar.gz
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-0.1.0/.gitignore +18 -0
- agentapprovalprotocol-0.1.0/PKG-INFO +96 -0
- agentapprovalprotocol-0.1.0/README.md +77 -0
- agentapprovalprotocol-0.1.0/pyproject.toml +45 -0
- agentapprovalprotocol-0.1.0/src/agentapprovalprotocol/__init__.py +33 -0
- agentapprovalprotocol-0.1.0/src/agentapprovalprotocol/_client.py +201 -0
- agentapprovalprotocol-0.1.0/src/agentapprovalprotocol/_gate.py +310 -0
- agentapprovalprotocol-0.1.0/src/agentapprovalprotocol/_wire.py +229 -0
- agentapprovalprotocol-0.1.0/src/agentapprovalprotocol/langgraph.py +255 -0
- agentapprovalprotocol-0.1.0/src/agentapprovalprotocol/py.typed +0 -0
- agentapprovalprotocol-0.1.0/src/agentapprovalprotocol/pydantic_ai.py +255 -0
- agentapprovalprotocol-0.1.0/src/agentapprovalprotocol/receiver.py +168 -0
- agentapprovalprotocol-0.1.0/src/agentapprovalprotocol/testing.py +173 -0
- agentapprovalprotocol-0.1.0/tests/test_gate.py +194 -0
- agentapprovalprotocol-0.1.0/tests/test_langgraph.py +158 -0
- agentapprovalprotocol-0.1.0/tests/test_pydantic_ai.py +259 -0
- agentapprovalprotocol-0.1.0/tests/test_wire_and_receiver.py +121 -0
- agentapprovalprotocol-0.1.0/uv.lock +2184 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
.next/
|
|
3
|
+
out/
|
|
4
|
+
.vercel/
|
|
5
|
+
*.tsbuildinfo
|
|
6
|
+
.DS_Store
|
|
7
|
+
website/next-env.d.ts
|
|
8
|
+
test-results/
|
|
9
|
+
playwright-report/
|
|
10
|
+
/aap
|
|
11
|
+
/bin/
|
|
12
|
+
/dist/
|
|
13
|
+
/.wrangler/
|
|
14
|
+
/sdk/python/.venv/
|
|
15
|
+
/sdk/python/dist/
|
|
16
|
+
/sdk/typescript/dist/
|
|
17
|
+
__pycache__/
|
|
18
|
+
/sdk/python/.pytest_cache/
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentapprovalprotocol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Put an AI agent's tool calls behind an Agent Approval Protocol (AAP) approval provider.
|
|
5
|
+
Project-URL: Homepage, https://agentapprovalprotocol.io
|
|
6
|
+
Project-URL: Source, https://github.com/agentapprovalprotocol/agentapprovalprotocol
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Keywords: aap,agents,ai,approval,human-in-the-loop
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Provides-Extra: langgraph
|
|
14
|
+
Requires-Dist: langchain>=1.0; extra == 'langgraph'
|
|
15
|
+
Requires-Dist: langgraph>=1.0; extra == 'langgraph'
|
|
16
|
+
Provides-Extra: pydantic-ai
|
|
17
|
+
Requires-Dist: pydantic-ai-slim>=2.55; (python_full_version >= '3.11') and extra == 'pydantic-ai'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Agent Approval Protocol for Python
|
|
21
|
+
|
|
22
|
+
Put an AI agent's tool calls behind an [AAP](https://agentapprovalprotocol.io) approval provider. Each call waits for the provider's decision, runs only if approved, and runs at most once.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install agentapprovalprotocol
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Check a tool call
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from agentapprovalprotocol import Call, Client, Gate
|
|
32
|
+
|
|
33
|
+
gate = Gate(Client("https://approvals.example.com/api/aap", token))
|
|
34
|
+
|
|
35
|
+
with gate.guard(Call(key=f"{run_id}:{tool_call.id}", tool=tool_call.name, arguments=tool_call.args)) as verdict:
|
|
36
|
+
if verdict:
|
|
37
|
+
result = run_tool(tool_call)
|
|
38
|
+
else:
|
|
39
|
+
result = verdict.reason # return this to the model as the tool result
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`key` must identify the tool call within its run, and stay the same if the call is seen again after a retry or resume. `guard` reports whether the tool succeeded when the block exits.
|
|
43
|
+
|
|
44
|
+
`AsyncGate` and `AsyncClient` provide the same API for `asyncio`.
|
|
45
|
+
|
|
46
|
+
## Pause instead of waiting
|
|
47
|
+
|
|
48
|
+
With `mode="async"`, `check` returns `Pending` when there is no decision yet, so the run can pause and the process can exit. Mount a `Receiver` where the provider can reach it. When a decision arrives it wakes the run, and the gate checks the decision again before anything runs.
|
|
49
|
+
|
|
50
|
+
Framework integrations handle pausing and resuming for you.
|
|
51
|
+
|
|
52
|
+
## LangChain, LangGraph and Deep Agents
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
pip install "agentapprovalprotocol[langgraph]"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from agentapprovalprotocol.langgraph import ApprovalMiddleware
|
|
60
|
+
|
|
61
|
+
approvals = ApprovalMiddleware("https://approvals.example.com/api/aap", token)
|
|
62
|
+
agent = create_agent(model, tools=tools, middleware=[approvals])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Every tool call the agent makes goes to the provider. A denied call returns the reason to the model as the tool result.
|
|
66
|
+
|
|
67
|
+
For Deep Agents, also add the middleware to every subagent:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from agentapprovalprotocol.langgraph import deep_agent_subagents
|
|
71
|
+
|
|
72
|
+
agent = create_deep_agent(model, tools=tools, middleware=[approvals], subagents=deep_agent_subagents(approvals, my_subagents))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
To pause instead of waiting, use `ApprovalMiddleware(..., mode="async")` with a checkpointer, and mount a `Receiver` with `resumers={"langgraph": resumer(agent)}`. On LangSmith Deployment, use `deployment_resumer(get_client())` in a custom route instead. Resuming a thread any other way cannot approve a call: the middleware asks the provider again.
|
|
76
|
+
|
|
77
|
+
## Pydantic AI
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
pip install "agentapprovalprotocol[pydantic-ai]"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Needs Python 3.11 or later.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from agentapprovalprotocol.pydantic_ai import ApprovalCapability
|
|
87
|
+
|
|
88
|
+
approvals = ApprovalCapability("https://approvals.example.com/api/aap", token)
|
|
89
|
+
agent = Agent(model, tools=tools, capabilities=[approvals])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Every tool call the agent makes goes to the provider, including MCP and other toolset tools. A denied call returns the reason to the model as a failed tool result. Add the same capability to every agent you run as a sub-agent.
|
|
93
|
+
|
|
94
|
+
Tools that run at the model provider, such as native web search or code execution, cannot be sent for approval. A run that includes one stops with an error. Use a local tool instead.
|
|
95
|
+
|
|
96
|
+
To pause instead of waiting, use `ApprovalCapability(..., mode="async")` and add `DeferredToolRequests` to the agent's `output_type`. When a run ends with `DeferredToolRequests`, save `result.conversation`. Mount a `Receiver` with `resumers={"pydantic-ai": resumer(agent, load=load, save=save)}`, where `load(conversation_id)` returns the saved conversation and `save(conversation)` stores the resumed one. Calls inside a sub-agent wait instead of pausing. Continuing a conversation any other way cannot approve a call: the capability asks the provider again, and an approval with edited arguments is refused.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Agent Approval Protocol for Python
|
|
2
|
+
|
|
3
|
+
Put an AI agent's tool calls behind an [AAP](https://agentapprovalprotocol.io) approval provider. Each call waits for the provider's decision, runs only if approved, and runs at most once.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install agentapprovalprotocol
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Check a tool call
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from agentapprovalprotocol import Call, Client, Gate
|
|
13
|
+
|
|
14
|
+
gate = Gate(Client("https://approvals.example.com/api/aap", token))
|
|
15
|
+
|
|
16
|
+
with gate.guard(Call(key=f"{run_id}:{tool_call.id}", tool=tool_call.name, arguments=tool_call.args)) as verdict:
|
|
17
|
+
if verdict:
|
|
18
|
+
result = run_tool(tool_call)
|
|
19
|
+
else:
|
|
20
|
+
result = verdict.reason # return this to the model as the tool result
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`key` must identify the tool call within its run, and stay the same if the call is seen again after a retry or resume. `guard` reports whether the tool succeeded when the block exits.
|
|
24
|
+
|
|
25
|
+
`AsyncGate` and `AsyncClient` provide the same API for `asyncio`.
|
|
26
|
+
|
|
27
|
+
## Pause instead of waiting
|
|
28
|
+
|
|
29
|
+
With `mode="async"`, `check` returns `Pending` when there is no decision yet, so the run can pause and the process can exit. Mount a `Receiver` where the provider can reach it. When a decision arrives it wakes the run, and the gate checks the decision again before anything runs.
|
|
30
|
+
|
|
31
|
+
Framework integrations handle pausing and resuming for you.
|
|
32
|
+
|
|
33
|
+
## LangChain, LangGraph and Deep Agents
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install "agentapprovalprotocol[langgraph]"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from agentapprovalprotocol.langgraph import ApprovalMiddleware
|
|
41
|
+
|
|
42
|
+
approvals = ApprovalMiddleware("https://approvals.example.com/api/aap", token)
|
|
43
|
+
agent = create_agent(model, tools=tools, middleware=[approvals])
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Every tool call the agent makes goes to the provider. A denied call returns the reason to the model as the tool result.
|
|
47
|
+
|
|
48
|
+
For Deep Agents, also add the middleware to every subagent:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from agentapprovalprotocol.langgraph import deep_agent_subagents
|
|
52
|
+
|
|
53
|
+
agent = create_deep_agent(model, tools=tools, middleware=[approvals], subagents=deep_agent_subagents(approvals, my_subagents))
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
To pause instead of waiting, use `ApprovalMiddleware(..., mode="async")` with a checkpointer, and mount a `Receiver` with `resumers={"langgraph": resumer(agent)}`. On LangSmith Deployment, use `deployment_resumer(get_client())` in a custom route instead. Resuming a thread any other way cannot approve a call: the middleware asks the provider again.
|
|
57
|
+
|
|
58
|
+
## Pydantic AI
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
pip install "agentapprovalprotocol[pydantic-ai]"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Needs Python 3.11 or later.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from agentapprovalprotocol.pydantic_ai import ApprovalCapability
|
|
68
|
+
|
|
69
|
+
approvals = ApprovalCapability("https://approvals.example.com/api/aap", token)
|
|
70
|
+
agent = Agent(model, tools=tools, capabilities=[approvals])
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Every tool call the agent makes goes to the provider, including MCP and other toolset tools. A denied call returns the reason to the model as a failed tool result. Add the same capability to every agent you run as a sub-agent.
|
|
74
|
+
|
|
75
|
+
Tools that run at the model provider, such as native web search or code execution, cannot be sent for approval. A run that includes one stops with an error. Use a local tool instead.
|
|
76
|
+
|
|
77
|
+
To pause instead of waiting, use `ApprovalCapability(..., mode="async")` and add `DeferredToolRequests` to the agent's `output_type`. When a run ends with `DeferredToolRequests`, save `result.conversation`. Mount a `Receiver` with `resumers={"pydantic-ai": resumer(agent, load=load, save=save)}`, where `load(conversation_id)` returns the saved conversation and `save(conversation)` stores the resumed one. Calls inside a sub-agent wait instead of pausing. Continuing a conversation any other way cannot approve a call: the capability asks the provider again, and an approval with edited arguments is refused.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agentapprovalprotocol"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Put an AI agent's tool calls behind an Agent Approval Protocol (AAP) approval provider."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = ["httpx>=0.27"]
|
|
9
|
+
keywords = ["ai", "agents", "approval", "human-in-the-loop", "aap"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Typing :: Typed",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://agentapprovalprotocol.io"
|
|
17
|
+
Source = "https://github.com/agentapprovalprotocol/agentapprovalprotocol"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
langgraph = [
|
|
21
|
+
"langchain>=1.0",
|
|
22
|
+
"langgraph>=1.0",
|
|
23
|
+
]
|
|
24
|
+
pydantic-ai = [
|
|
25
|
+
"pydantic-ai-slim>=2.55 ; python_full_version >= '3.11'",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"deepagents>=0.7.24 ; python_full_version >= '3.11'",
|
|
31
|
+
"pytest>=8",
|
|
32
|
+
"pytest-asyncio>=0.24",
|
|
33
|
+
"pytest-timeout>=2.4.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["hatchling>=1.25"]
|
|
38
|
+
build-backend = "hatchling.build"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/agentapprovalprotocol"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -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)
|