sealkeeper 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.
sealkeeper/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Look up and verify SealKeeper SEALs, and gate calls on them."""
2
+
3
+ from ._version import __version__
4
+ from .client import AsyncSealKeeper, SealKeeper
5
+ from .errors import BadTargetError, SealBroken, SealKeeperError, UnavailableError
6
+ from .gate import BLOCK_CODES, PASS_CODES, Block, Gate, Pass
7
+ from .lookup import Lookup
8
+ from .seal import (
9
+ SEAL_BROKEN_REASONS,
10
+ SEAL_VERSIONS,
11
+ VerifiedSeal,
12
+ agent_id_from_did_key,
13
+ read_keys,
14
+ verify_with_keys,
15
+ )
16
+
17
+ __all__ = [
18
+ "BLOCK_CODES",
19
+ "PASS_CODES",
20
+ "SEAL_BROKEN_REASONS",
21
+ "SEAL_VERSIONS",
22
+ "AsyncSealKeeper",
23
+ "BadTargetError",
24
+ "Block",
25
+ "Gate",
26
+ "Lookup",
27
+ "Pass",
28
+ "SealBroken",
29
+ "SealKeeper",
30
+ "SealKeeperError",
31
+ "UnavailableError",
32
+ "VerifiedSeal",
33
+ "__version__",
34
+ "agent_id_from_did_key",
35
+ "read_keys",
36
+ "verify_with_keys",
37
+ ]
@@ -0,0 +1,277 @@
1
+ """The requests the clients send and how they read the answers.
2
+
3
+ Pure functions and one small key cache. The sync and async clients only
4
+ move bytes, so both read every answer the same way.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from collections.abc import Mapping
12
+ from datetime import datetime, timezone
13
+ from typing import Any
14
+ from urllib.parse import unquote, urlsplit
15
+
16
+ from ._version import __version__
17
+ from .errors import BadTargetError, SealKeeperError, UnavailableError
18
+ from .seal import DID_KEY_PREFIX, agent_id_from_did_key, is_agent_id
19
+
20
+ MCP_URL = "https://api.sealkeeper.run/mcp"
21
+ KEYS_URL = "https://sealkeeper.run/.well-known/seal.json"
22
+ PAGE_ORIGIN = ("sealkeeper.run", "https")
23
+ API_ORIGIN = ("api.sealkeeper.run", "https")
24
+ MCP_PROTOCOL_VERSION = "2025-11-25"
25
+
26
+ TIMEOUT_SECONDS = 5.0
27
+ ANSWER_MAX_BYTES = 64 * 1024
28
+ # How long a copy of the keys is used before it is fetched again.
29
+ KEYS_MAX_AGE_SECONDS = 300
30
+
31
+ # The longest line of server text an exception carries.
32
+ _TEXT_MAX_CHARS = 500
33
+ _SUFFIX = re.compile(r"[!-~]+(?: [!-~]+)*")
34
+ _SUFFIX_MAX_CHARS = 128
35
+ _PAGE_PATH = re.compile(r"/agents/([^/]+)(?:/([^/]+))?/?")
36
+ _API_CARD_PATH = re.compile(r"/v1/agents/([A-Za-z0-9_-]{43})/card/?")
37
+ _URL = re.compile(r"([A-Za-z][A-Za-z0-9+.-]*://)([^/?#]*)(.*)", re.DOTALL)
38
+ _HTTP_URL = re.compile(r"https?://", re.IGNORECASE)
39
+
40
+
41
+ def user_agent(suffix: str | None) -> str:
42
+ """sealkeeper-py/<version>, then suffix, such as a wrapper's own name."""
43
+ base = f"sealkeeper-py/{__version__}"
44
+ if suffix is None:
45
+ return base
46
+ if (
47
+ not isinstance(suffix, str)
48
+ or len(suffix) > _SUFFIX_MAX_CHARS
49
+ or not _SUFFIX.fullmatch(suffix)
50
+ ):
51
+ raise ValueError("user_agent_suffix must be printable ASCII words")
52
+ return f"{base} {suffix}"
53
+
54
+
55
+ def mcp_headers(agent: str) -> dict[str, str]:
56
+ return {
57
+ "content-type": "application/json",
58
+ "accept": "application/json, text/event-stream",
59
+ "mcp-protocol-version": MCP_PROTOCOL_VERSION,
60
+ "user-agent": agent,
61
+ }
62
+
63
+
64
+ def keys_headers(agent: str) -> dict[str, str]:
65
+ return {"accept": "application/json", "user-agent": agent}
66
+
67
+
68
+ def tool_call(name: str, arguments: Mapping[str, Any]) -> bytes:
69
+ """One stateless JSON-RPC tools/call, the whole request body."""
70
+ message = {
71
+ "jsonrpc": "2.0",
72
+ "id": 1,
73
+ "method": "tools/call",
74
+ "params": {"name": name, "arguments": dict(arguments)},
75
+ }
76
+ return json.dumps(message, separators=(",", ":")).encode("utf-8")
77
+
78
+
79
+ def retry_wait(status: int, headers: Mapping[str, str], attempt: int) -> float | None:
80
+ """The seconds to wait before the one retry of a 429, else None.
81
+
82
+ Only the first answer is retried, and only when Retry-After names
83
+ whole seconds no longer than the timeout.
84
+ """
85
+ if status != 429 or attempt > 0:
86
+ return None
87
+ value = headers.get("retry-after", "").strip()
88
+ if not value.isascii() or not value.isdigit():
89
+ return None
90
+ seconds = int(value)
91
+ return float(seconds) if seconds <= TIMEOUT_SECONDS else None
92
+
93
+
94
+ def _short(text: str) -> str:
95
+ return text if len(text) <= _TEXT_MAX_CHARS else text[:_TEXT_MAX_CHARS] + "..."
96
+
97
+
98
+ def _json(body: bytes) -> object:
99
+ try:
100
+ return json.loads(body.decode("utf-8"))
101
+ except (ValueError, RecursionError):
102
+ return None
103
+
104
+
105
+ def _error_message(parsed: object) -> str:
106
+ error = parsed.get("error") if isinstance(parsed, dict) else None
107
+ message = error.get("message") if isinstance(error, dict) else None
108
+ return _short(message) if isinstance(message, str) else "no message"
109
+
110
+
111
+ def read_answer(status: int, body: bytes) -> dict[str, Any]:
112
+ """The structuredContent of a tools/call answer, or the error it is.
113
+
114
+ A 429 or a 5xx is UnavailableError. A tool error is BadTargetError with
115
+ the server's line. Any other status, a JSON-RPC error or an answer
116
+ that is not a tool result is SealKeeperError.
117
+ """
118
+ if status == 429:
119
+ raise UnavailableError("SealKeeper is rate limiting this caller")
120
+ if status >= 500:
121
+ raise UnavailableError(f"SealKeeper answered HTTP {status}")
122
+ parsed = _json(body)
123
+ if status != 200:
124
+ raise SealKeeperError(
125
+ f"SealKeeper refused the request, HTTP {status}: {_error_message(parsed)}"
126
+ )
127
+ if not isinstance(parsed, dict):
128
+ raise SealKeeperError("SealKeeper's answer is not a JSON object")
129
+ if "error" in parsed:
130
+ raise SealKeeperError(f"SealKeeper answered an error: {_error_message(parsed)}")
131
+ result = parsed.get("result")
132
+ if not isinstance(result, dict):
133
+ raise SealKeeperError("SealKeeper's answer has no result")
134
+ if result.get("isError") is True:
135
+ raise BadTargetError(_first_text(result))
136
+ data = result.get("structuredContent")
137
+ if not isinstance(data, dict):
138
+ raise SealKeeperError("SealKeeper's answer has no structuredContent")
139
+ return data
140
+
141
+
142
+ def _first_text(result: dict[str, Any]) -> str:
143
+ content = result.get("content")
144
+ if isinstance(content, list):
145
+ for item in content:
146
+ if isinstance(item, dict) and isinstance(item.get("text"), str):
147
+ return _short(item["text"])
148
+ return "SealKeeper refused the input"
149
+
150
+
151
+ def read_keys_answer(status: int, body: bytes) -> object:
152
+ """The key document of a 200 answer. Anything else is UnavailableError."""
153
+ if status != 200:
154
+ raise UnavailableError(f"could not load the SealKeeper keys, HTTP {status}")
155
+ parsed = _json(body)
156
+ if parsed is None:
157
+ raise UnavailableError("the SealKeeper key document is not JSON")
158
+ return parsed
159
+
160
+
161
+ def page_url(agent_id: str) -> str:
162
+ return f"https://sealkeeper.run/agents/{agent_id}"
163
+
164
+
165
+ def own_url_id(target: str) -> tuple[bool, str | None]:
166
+ """Whether target is a SealKeeper page or API card URL, and its agent id.
167
+
168
+ A page is /agents/<first>/<name> or /agents/<id> on sealkeeper.run, a
169
+ card /v1/agents/<id>/card on api.sealkeeper.run. Only the id forms name
170
+ an id.
171
+ """
172
+ if not _HTTP_URL.match(target):
173
+ return False, None
174
+ try:
175
+ parts = urlsplit(target)
176
+ port = parts.port
177
+ except ValueError:
178
+ return False, None
179
+ origin = (parts.hostname, parts.scheme) if port in (None, 443) else None
180
+ path = parts.path
181
+ if origin == PAGE_ORIGIN:
182
+ page = _PAGE_PATH.fullmatch(path)
183
+ if page is None:
184
+ return False, None
185
+ first = unquote(page.group(1))
186
+ return True, first if page.group(2) is None and is_agent_id(first) else None
187
+ if origin == API_ORIGIN:
188
+ card = _API_CARD_PATH.fullmatch(path)
189
+ if card is None:
190
+ return False, None
191
+ return True, card.group(1) if is_agent_id(card.group(1)) else None
192
+ return False, None
193
+
194
+
195
+ def trust_target(target: object) -> tuple[str, str | None]:
196
+ """What get_trust is sent for target, and the agent id target names.
197
+
198
+ A bare agent id goes as its page URL, and a did:key as the page URL of
199
+ the id it names. Every other target goes as given.
200
+ """
201
+ if not isinstance(target, str) or not target.strip():
202
+ raise BadTargetError("the target must be a non-empty string")
203
+ text = target.strip()
204
+ if text.startswith(DID_KEY_PREFIX):
205
+ agent_id = agent_id_from_did_key(text)
206
+ if agent_id is None:
207
+ raise BadTargetError("the target is not an Ed25519 did:key")
208
+ return page_url(agent_id), agent_id
209
+ if is_agent_id(text):
210
+ return page_url(text), text
211
+ return target, own_url_id(text)[1]
212
+
213
+
214
+ def is_http_url(target: str) -> bool:
215
+ return _HTTP_URL.match(target.strip()) is not None
216
+
217
+
218
+ def is_listed_url(target: str) -> bool:
219
+ """Whether target is an http or https URL other than SealKeeper's own."""
220
+ return is_http_url(target) and not own_url_id(target.strip())[0]
221
+
222
+
223
+ def host_lowered(url: str) -> str:
224
+ """url with only its host in lower case, and an empty path read as /."""
225
+ match = _URL.fullmatch(url.strip())
226
+ if match is None:
227
+ return url
228
+ scheme, authority, rest = match.groups()
229
+ userinfo, at, host = authority.rpartition("@")
230
+ return f"{scheme}{userinfo}{at}{host.lower()}{rest or '/'}"
231
+
232
+
233
+ def host_of(url: str) -> str | None:
234
+ try:
235
+ return urlsplit(url.strip()).hostname
236
+ except ValueError:
237
+ return None
238
+
239
+
240
+ def verify_arguments(
241
+ seal: str, agent: str | None, at: datetime | str | None
242
+ ) -> dict[str, str]:
243
+ """verify_seal's arguments. A datetime at must carry its time zone."""
244
+ arguments = {"seal": seal}
245
+ if agent is not None:
246
+ arguments["agent"] = agent
247
+ if isinstance(at, datetime):
248
+ if at.tzinfo is None:
249
+ raise ValueError("at must carry its time zone")
250
+ utc = at.astimezone(timezone.utc)
251
+ arguments["at"] = (
252
+ utc.strftime("%Y-%m-%dT%H:%M:%S.") + f"{utc.microsecond // 1000:03d}Z"
253
+ )
254
+ elif at is not None:
255
+ arguments["at"] = at
256
+ return arguments
257
+
258
+
259
+ class KeyCache:
260
+ """The last key document fetched, kid to x, and when it was fetched."""
261
+
262
+ def __init__(self) -> None:
263
+ self._keys: dict[str, str] = {}
264
+ self._fetched_at: float | None = None
265
+
266
+ def usable(self, kid: str, now: float) -> dict[str, str] | None:
267
+ """The keys while the copy is younger than five minutes and knows kid.
268
+
269
+ A copy dated after now counts as too old.
270
+ """
271
+ if self._fetched_at is None or kid not in self._keys:
272
+ return None
273
+ age = now - self._fetched_at
274
+ return self._keys if 0 <= age < KEYS_MAX_AGE_SECONDS else None
275
+
276
+ def store(self, keys: dict[str, str], now: float) -> None:
277
+ self._keys, self._fetched_at = keys, now
sealkeeper/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
sealkeeper/client.py ADDED
@@ -0,0 +1,303 @@
1
+ """SealKeeper and AsyncSealKeeper, the two clients. They only do I/O.
2
+
3
+ Every request and every answer goes through _protocol, and every SEAL
4
+ through seal.verify_with_keys, so both clients behave the same.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
12
+ from datetime import datetime
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from . import _protocol as p
18
+ from .errors import SealBroken, SealKeeperError, UnavailableError
19
+ from .lookup import Lookup, lookup_of, seal_to_check
20
+ from .seal import (
21
+ DID_KEY_PREFIX,
22
+ VerifiedSeal,
23
+ agent_id_from_did_key,
24
+ is_agent_id,
25
+ read_keys,
26
+ seal_kid,
27
+ verify_with_keys,
28
+ )
29
+
30
+ Clock = Callable[[], float]
31
+ _Answer = tuple[int, Mapping[str, str], bytes]
32
+
33
+
34
+ def _expected_id(expected_agent_id: str) -> str:
35
+ """The raw agent id, from an agent id or its did:key."""
36
+ agent_id: object = expected_agent_id
37
+ if isinstance(agent_id, str) and agent_id.startswith(DID_KEY_PREFIX):
38
+ agent_id = agent_id_from_did_key(agent_id)
39
+ if not is_agent_id(agent_id):
40
+ raise ValueError("expected_agent_id must be an agent id or its did:key")
41
+ return agent_id
42
+
43
+
44
+ def _keys_of(status: int, body: bytes) -> dict[str, str]:
45
+ try:
46
+ return read_keys(p.read_keys_answer(status, body))
47
+ except ValueError as error:
48
+ raise UnavailableError(f"could not use the SealKeeper keys, {error}") from None
49
+
50
+
51
+ def _too_large() -> SealKeeperError:
52
+ return SealKeeperError(
53
+ f"SealKeeper's answer is larger than {p.ANSWER_MAX_BYTES} bytes"
54
+ )
55
+
56
+
57
+ def _timed_out() -> UnavailableError:
58
+ return UnavailableError(
59
+ f"SealKeeper did not answer within {p.TIMEOUT_SECONDS} seconds"
60
+ )
61
+
62
+
63
+ def _unreachable(error: Exception) -> UnavailableError:
64
+ return UnavailableError(f"could not reach SealKeeper, {type(error).__name__}")
65
+
66
+
67
+ def _read_capped(chunks: Iterator[bytes], deadline: float) -> bytes:
68
+ body = bytearray()
69
+ for chunk in chunks:
70
+ body += chunk
71
+ if len(body) > p.ANSWER_MAX_BYTES:
72
+ raise _too_large()
73
+ if time.monotonic() > deadline:
74
+ raise _timed_out()
75
+ return bytes(body)
76
+
77
+
78
+ async def _aread_capped(chunks: AsyncIterator[bytes], deadline: float) -> bytes:
79
+ body = bytearray()
80
+ async for chunk in chunks:
81
+ body += chunk
82
+ if len(body) > p.ANSWER_MAX_BYTES:
83
+ raise _too_large()
84
+ if time.monotonic() > deadline:
85
+ raise _timed_out()
86
+ return bytes(body)
87
+
88
+
89
+ class SealKeeper:
90
+ """Reads trust from SealKeeper and verifies SEALs, one blocking call each.
91
+
92
+ http_client is an httpx.Client to send through, for a caller's own
93
+ transport or a test. One is made and closed with the client when none
94
+ is given. clock answers Unix seconds and decides whether a SEAL is
95
+ current. user_agent_suffix is added to the User-Agent, such as
96
+ "langchain-sealkeeper/0.1.0". sleep waits before the one retry of a 429.
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ *,
102
+ http_client: httpx.Client | None = None,
103
+ clock: Clock | None = None,
104
+ user_agent_suffix: str | None = None,
105
+ sleep: Callable[[float], None] | None = None,
106
+ ) -> None:
107
+ agent = p.user_agent(user_agent_suffix)
108
+ self._mcp_headers = p.mcp_headers(agent)
109
+ self._keys_headers = p.keys_headers(agent)
110
+ self._owns_http = http_client is None
111
+ self._http = http_client if http_client is not None else httpx.Client()
112
+ self.clock: Clock = clock if clock is not None else time.time
113
+ self._sleep = sleep if sleep is not None else time.sleep
114
+ self._keys = p.KeyCache()
115
+
116
+ def close(self) -> None:
117
+ if self._owns_http:
118
+ self._http.close()
119
+
120
+ def __enter__(self) -> SealKeeper:
121
+ return self
122
+
123
+ def __exit__(self, *_: object) -> None:
124
+ self.close()
125
+
126
+ def get_trust(self, target: str) -> dict[str, Any]:
127
+ """get_trust's answer for a handle, a URL, an agent id or a did:key.
128
+
129
+ The answer as SealKeeper sends it. Its agent fields are unsigned,
130
+ so a decision reads lookup instead.
131
+ """
132
+ return self._call("get_trust", {"agent": p.trust_target(target)[0]})
133
+
134
+ def verify_seal(
135
+ self, seal: str, agent: str | None = None, at: datetime | str | None = None
136
+ ) -> dict[str, Any]:
137
+ """SealKeeper's own check of a SEAL, with its record of holds.
138
+
139
+ agent is the agent id expected, at a time to check at in place of
140
+ now. The answer as SealKeeper sends it.
141
+ """
142
+ return self._call("verify_seal", p.verify_arguments(seal, agent, at))
143
+
144
+ def verify(self, seal: str, expected_agent_id: str) -> VerifiedSeal:
145
+ """Verifies a SEAL here, against SealKeeper's published keys.
146
+
147
+ Raises SealBroken with the reason, or UnavailableError when the keys
148
+ cannot be had and no copy younger than five minutes knows the kid.
149
+ """
150
+ expected = _expected_id(expected_agent_id)
151
+ now = self.clock()
152
+ kid = seal_kid(seal)
153
+ keys = self._keys.usable(kid, now)
154
+ if keys is None:
155
+ status, _, body = self._send("GET", p.KEYS_URL, None, self._keys_headers)
156
+ keys = _keys_of(status, body)
157
+ self._keys.store(keys, now)
158
+ return verify_with_keys(seal, keys, now, expected)
159
+
160
+ def lookup(self, target: str) -> Lookup:
161
+ """get_trust for target, with the SEAL in its answer verified here."""
162
+ argument, named_id = p.trust_target(target)
163
+ answer = self._call("get_trust", {"agent": argument})
164
+ check = seal_to_check(answer, named_id)
165
+ if check is None:
166
+ return lookup_of(answer, None, None)
167
+ try:
168
+ return lookup_of(answer, self.verify(*check), None)
169
+ except SealBroken as error:
170
+ return lookup_of(answer, None, error.reason)
171
+
172
+ def _call(self, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
173
+ body = p.tool_call(name, arguments)
174
+ attempt = 0
175
+ while True:
176
+ status, headers, answer = self._send(
177
+ "POST", p.MCP_URL, body, self._mcp_headers
178
+ )
179
+ wait = p.retry_wait(status, headers, attempt)
180
+ if wait is None:
181
+ return p.read_answer(status, answer)
182
+ self._sleep(wait)
183
+ attempt += 1
184
+
185
+ def _send(
186
+ self, method: str, url: str, body: bytes | None, headers: dict[str, str]
187
+ ) -> _Answer:
188
+ deadline = time.monotonic() + p.TIMEOUT_SECONDS
189
+ try:
190
+ with self._http.stream(
191
+ method,
192
+ url,
193
+ content=body,
194
+ headers=headers,
195
+ timeout=p.TIMEOUT_SECONDS,
196
+ follow_redirects=False,
197
+ ) as response:
198
+ data = _read_capped(response.iter_bytes(), deadline)
199
+ return response.status_code, response.headers, data
200
+ except httpx.TimeoutException:
201
+ raise _timed_out() from None
202
+ except httpx.TransportError as error:
203
+ raise _unreachable(error) from None
204
+
205
+
206
+ class AsyncSealKeeper:
207
+ """SealKeeper with every call awaited, the same methods and answers.
208
+
209
+ http_client is an httpx.AsyncClient. sleep is awaited before the one
210
+ retry of a 429, asyncio.sleep unless another is given.
211
+ """
212
+
213
+ def __init__(
214
+ self,
215
+ *,
216
+ http_client: httpx.AsyncClient | None = None,
217
+ clock: Clock | None = None,
218
+ user_agent_suffix: str | None = None,
219
+ sleep: Callable[[float], Awaitable[None]] | None = None,
220
+ ) -> None:
221
+ agent = p.user_agent(user_agent_suffix)
222
+ self._mcp_headers = p.mcp_headers(agent)
223
+ self._keys_headers = p.keys_headers(agent)
224
+ self._owns_http = http_client is None
225
+ self._http = http_client if http_client is not None else httpx.AsyncClient()
226
+ self.clock: Clock = clock if clock is not None else time.time
227
+ self._sleep = sleep if sleep is not None else asyncio.sleep
228
+ self._keys = p.KeyCache()
229
+
230
+ async def aclose(self) -> None:
231
+ if self._owns_http:
232
+ await self._http.aclose()
233
+
234
+ async def __aenter__(self) -> AsyncSealKeeper:
235
+ return self
236
+
237
+ async def __aexit__(self, *_: object) -> None:
238
+ await self.aclose()
239
+
240
+ async def get_trust(self, target: str) -> dict[str, Any]:
241
+ return await self._call("get_trust", {"agent": p.trust_target(target)[0]})
242
+
243
+ async def verify_seal(
244
+ self, seal: str, agent: str | None = None, at: datetime | str | None = None
245
+ ) -> dict[str, Any]:
246
+ return await self._call("verify_seal", p.verify_arguments(seal, agent, at))
247
+
248
+ async def verify(self, seal: str, expected_agent_id: str) -> VerifiedSeal:
249
+ expected = _expected_id(expected_agent_id)
250
+ now = self.clock()
251
+ kid = seal_kid(seal)
252
+ keys = self._keys.usable(kid, now)
253
+ if keys is None:
254
+ status, _, body = await self._send(
255
+ "GET", p.KEYS_URL, None, self._keys_headers
256
+ )
257
+ keys = _keys_of(status, body)
258
+ self._keys.store(keys, now)
259
+ return verify_with_keys(seal, keys, now, expected)
260
+
261
+ async def lookup(self, target: str) -> Lookup:
262
+ argument, named_id = p.trust_target(target)
263
+ answer = await self._call("get_trust", {"agent": argument})
264
+ check = seal_to_check(answer, named_id)
265
+ if check is None:
266
+ return lookup_of(answer, None, None)
267
+ try:
268
+ return lookup_of(answer, await self.verify(*check), None)
269
+ except SealBroken as error:
270
+ return lookup_of(answer, None, error.reason)
271
+
272
+ async def _call(self, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
273
+ body = p.tool_call(name, arguments)
274
+ attempt = 0
275
+ while True:
276
+ status, headers, answer = await self._send(
277
+ "POST", p.MCP_URL, body, self._mcp_headers
278
+ )
279
+ wait = p.retry_wait(status, headers, attempt)
280
+ if wait is None:
281
+ return p.read_answer(status, answer)
282
+ await self._sleep(wait)
283
+ attempt += 1
284
+
285
+ async def _send(
286
+ self, method: str, url: str, body: bytes | None, headers: dict[str, str]
287
+ ) -> _Answer:
288
+ deadline = time.monotonic() + p.TIMEOUT_SECONDS
289
+ try:
290
+ async with self._http.stream(
291
+ method,
292
+ url,
293
+ content=body,
294
+ headers=headers,
295
+ timeout=p.TIMEOUT_SECONDS,
296
+ follow_redirects=False,
297
+ ) as response:
298
+ data = await _aread_capped(response.aiter_bytes(), deadline)
299
+ return response.status_code, response.headers, data
300
+ except httpx.TimeoutException:
301
+ raise _timed_out() from None
302
+ except httpx.TransportError as error:
303
+ raise _unreachable(error) from None
sealkeeper/errors.py ADDED
@@ -0,0 +1,23 @@
1
+ """The exceptions the library raises. Every one is a SealKeeperError."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SealKeeperError(Exception):
7
+ """SealKeeper answered something this library cannot use."""
8
+
9
+
10
+ class BadTargetError(SealKeeperError):
11
+ """SealKeeper refused the target or the input, with its line saying why."""
12
+
13
+
14
+ class UnavailableError(SealKeeperError):
15
+ """SealKeeper could not be reached, timed out or asked to slow down."""
16
+
17
+
18
+ class SealBroken(SealKeeperError):
19
+ """A SEAL that did not verify. reason is one of SEAL_BROKEN_REASONS."""
20
+
21
+ def __init__(self, reason: str) -> None:
22
+ super().__init__(f"broken SEAL: {reason}")
23
+ self.reason = reason