@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.0
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.
- package/CHANGELOG.md +79 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""An outbound HTTP client for a service: one deadline across every attempt, bounded retries with jittered backoff,
|
|
2
|
+
`Retry-After` honoured up to a cap, a response size cap, and no secret in an error.
|
|
3
|
+
|
|
4
|
+
response = fabric_http.request("GET", "https://api.example/v1/items", headers={"Authorization": ...},
|
|
5
|
+
deadline_s=20, retries=2)
|
|
6
|
+
response.status, response.headers["content-type"], response.json()
|
|
7
|
+
|
|
8
|
+
Rules (FAA-65 REQ-06):
|
|
9
|
+
|
|
10
|
+
- **What is retried.** A status in `RETRY_STATUSES` (408, 425, 429, 500, 502, 503, 504) or a connection error,
|
|
11
|
+
at most `retries` times. A request is retried only when repeating it is safe: an idempotent method (`GET`, `HEAD`,
|
|
12
|
+
`OPTIONS`, `PUT`, `DELETE`) or a request that carries an `Idempotency-Key` header. Any other request is retried only
|
|
13
|
+
on 429, which says the server refused it before doing anything. Every other status is answered as it came.
|
|
14
|
+
- **When.** The wait before attempt *n* (from 0) is `min(max_backoff_s, backoff_s * 2**n) * (0.5 + random())`; a
|
|
15
|
+
`Retry-After` the server sent (seconds or an HTTP date) replaces it, up to `max_retry_after_s`. A wait that would
|
|
16
|
+
pass the deadline is not taken: the last answer or error stands.
|
|
17
|
+
- **The deadline** covers every attempt and every wait: each attempt gets what is left of it.
|
|
18
|
+
- **The size cap.** A body longer than `max_bytes` is an error, read no further than one byte past the cap.
|
|
19
|
+
- **Secrets.** A plain `http://` URL is refused unless the host is loopback or `insecure=True`: a bearer token never
|
|
20
|
+
crosses a network in clear. An error names the method and the URL without its userinfo, query and fragment
|
|
21
|
+
(`redact_url`); it never carries a request header or a request body.
|
|
22
|
+
|
|
23
|
+
The transport is injected in tests: `transport(method, url, headers, body, timeout_s, max_bytes)` returns
|
|
24
|
+
`(status, headers, body_bytes)` or raises `OSError` (a connection error, `TimeoutError` included) or `HttpError`.
|
|
25
|
+
Standard library only. Part of the service kit (FAA-65, layer L4).
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import datetime as _dt
|
|
30
|
+
import http.client
|
|
31
|
+
import ipaddress
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import socket
|
|
35
|
+
import random as _random
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
import urllib.error
|
|
39
|
+
import urllib.parse
|
|
40
|
+
import urllib.request
|
|
41
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
42
|
+
|
|
43
|
+
import sys as _sys # noqa: E402
|
|
44
|
+
from pathlib import Path as _Path # noqa: E402
|
|
45
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
46
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
47
|
+
from fabric_jsvalue import json_stringify # noqa: E402 - beside this file
|
|
48
|
+
|
|
49
|
+
RETRY_STATUSES = (408, 425, 429, 500, 502, 503, 504)
|
|
50
|
+
IDEMPOTENT_METHODS = ("GET", "HEAD", "OPTIONS", "PUT", "DELETE")
|
|
51
|
+
DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
|
52
|
+
_TOKEN = frozenset("!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
|
53
|
+
|
|
54
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float, int], Tuple[int, Dict[str, str], bytes]]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HttpError(Exception):
|
|
58
|
+
"""A request that did not get an answer it could use. `status` is None for a connection error, a timeout, a
|
|
59
|
+
refused URL or a body over the cap; `url` is redacted; `body` holds at most 2048 bytes of an answer's body."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, message: str, *, status: Optional[int] = None, url: str = "", attempts: int = 0,
|
|
62
|
+
retry_after_s: Optional[float] = None, body: bytes = b"", kind: str = "http"):
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.status = status
|
|
65
|
+
self.url = url
|
|
66
|
+
self.attempts = attempts
|
|
67
|
+
self.retry_after_s = retry_after_s
|
|
68
|
+
self.body = bytes(body[:2048])
|
|
69
|
+
self.kind = kind # http (an answer), connection, timeout, too-large, refused
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Response:
|
|
73
|
+
"""An answer: `status`, `headers` (names lower-cased), `body` (bytes), `attempts`, `url` (redacted)."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, status: int, headers: Mapping[str, str], body: bytes, *, attempts: int = 1, url: str = ""):
|
|
76
|
+
self.status = status
|
|
77
|
+
self.headers = {str(k).lower(): str(v) for k, v in headers.items()}
|
|
78
|
+
self.body = body
|
|
79
|
+
self.attempts = attempts
|
|
80
|
+
self.url = url
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def ok(self) -> bool:
|
|
84
|
+
return 200 <= self.status < 300
|
|
85
|
+
|
|
86
|
+
def text(self) -> str:
|
|
87
|
+
return self.body.decode("utf-8", "replace")
|
|
88
|
+
|
|
89
|
+
def json(self) -> Any:
|
|
90
|
+
"""The body as JSON, read as `JSON.parse` reads it: a UTF-8 byte order mark skipped, `NaN` and `Infinity`
|
|
91
|
+
refused; anything unreadable (nesting too deep included) is a ValueError."""
|
|
92
|
+
def refuse(token: str) -> Any:
|
|
93
|
+
raise ValueError("%s is not JSON" % token)
|
|
94
|
+
try:
|
|
95
|
+
return json.loads(self.body.decode("utf-8-sig"), parse_constant=refuse)
|
|
96
|
+
except RecursionError:
|
|
97
|
+
raise ValueError("the JSON is nested too deep")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def utf8(text: str) -> bytes:
|
|
104
|
+
"""Text as UTF-8 the way `TextEncoder` writes it: a lone surrogate becomes U+FFFD instead of an error."""
|
|
105
|
+
return _SURROGATE.sub("\ufffd", text).encode("utf-8")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def json_bytes(value: Any) -> bytes:
|
|
109
|
+
"""Compact JSON byte for byte as `JSON.stringify` writes it (numbers, key order, a lone surrogate escaped as
|
|
110
|
+
`\\uXXXX`), in UTF-8; NaN or Infinity refused (`ValueError`) rather than written as null, and a value JSON has no
|
|
111
|
+
form for refused (`TypeError`)."""
|
|
112
|
+
json.dumps(value, allow_nan=False) # raises on NaN, Infinity and types JSON cannot hold
|
|
113
|
+
return json_stringify(value).encode("utf-8")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def redact_url(url: str) -> str:
|
|
117
|
+
"""The URL as it may appear in an error or a log line: scheme, host, port and path; no userinfo, query or
|
|
118
|
+
fragment, which is where keys and tokens ride."""
|
|
119
|
+
try:
|
|
120
|
+
parts = urllib.parse.urlsplit(str(url))
|
|
121
|
+
except ValueError:
|
|
122
|
+
return "<unparseable url>"
|
|
123
|
+
host = parts.hostname or ""
|
|
124
|
+
if ":" in host:
|
|
125
|
+
host = "[%s]" % host
|
|
126
|
+
try:
|
|
127
|
+
port = parts.port
|
|
128
|
+
except ValueError:
|
|
129
|
+
port = None
|
|
130
|
+
if port == {"http": 80, "https": 443}.get(parts.scheme.lower()):
|
|
131
|
+
port = None # as a WHATWG URL writes it, so both kits print one URL the same way
|
|
132
|
+
netloc = host.lower() + (":%d" % port if port is not None else "")
|
|
133
|
+
path = parts.path or ("/" if parts.scheme.lower() in ("http", "https") else "")
|
|
134
|
+
return urllib.parse.urlunsplit((parts.scheme.lower(), netloc, path, "", ""))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_loopback(host: str) -> bool:
|
|
138
|
+
"""Loopback as a WHATWG URL parser leaves the host: one trailing dot dropped from an IPv4 address, no whitespace,
|
|
139
|
+
and an IPv4-mapped IPv6 address not counted (Node writes it in hex and the Node twin refuses it)."""
|
|
140
|
+
if host == "localhost" or host.endswith(".localhost"):
|
|
141
|
+
return True
|
|
142
|
+
if not host or any(c.isspace() for c in host):
|
|
143
|
+
return False
|
|
144
|
+
try:
|
|
145
|
+
address = ipaddress.ip_address(host)
|
|
146
|
+
return address.is_loopback and not getattr(address, "ipv4_mapped", None)
|
|
147
|
+
except ValueError:
|
|
148
|
+
pass
|
|
149
|
+
host = host[:-1] if host.endswith(".") else host
|
|
150
|
+
try: # the short IPv4 forms a WHATWG URL also reads (127.1, 2130706433, 0x7f.1)
|
|
151
|
+
return socket.inet_aton(host)[0] == 127
|
|
152
|
+
except (OSError, ValueError):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def check_url(url: str, *, insecure: bool = False) -> None:
|
|
157
|
+
"""Refuse what this client never sends: a scheme other than http or https, no host, credentials in the URL, or
|
|
158
|
+
plain http to a host that is not loopback (unless `insecure`)."""
|
|
159
|
+
try:
|
|
160
|
+
parts = urllib.parse.urlsplit(url)
|
|
161
|
+
host = parts.hostname
|
|
162
|
+
parts.port # noqa: B018 - raises on a bad port
|
|
163
|
+
except (ValueError, TypeError):
|
|
164
|
+
raise HttpError("the URL does not parse", url=redact_url(url), kind="refused")
|
|
165
|
+
if parts.scheme not in ("http", "https") or not host:
|
|
166
|
+
raise HttpError("only http and https URLs with a host are sent", url=redact_url(url), kind="refused")
|
|
167
|
+
if parts.username is not None or parts.password is not None:
|
|
168
|
+
raise HttpError("credentials in a URL are refused: send them in a header", url=redact_url(url), kind="refused")
|
|
169
|
+
if parts.scheme == "http" and not insecure and not _is_loopback(host):
|
|
170
|
+
raise HttpError("plain http to a host that is not loopback is refused (insecure=True to allow)",
|
|
171
|
+
url=redact_url(url), kind="refused")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_IMF_FIXDATE = re.compile(r"(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d\d) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) "
|
|
175
|
+
r"(\d{4}) (\d\d):(\d\d):(\d\d) GMT", re.ASCII)
|
|
176
|
+
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def retry_after_s(value: Optional[str], *, now: Optional[float] = None) -> Optional[float]:
|
|
180
|
+
"""Seconds a `Retry-After` header asks for (RFC 9110 10.2.3): a non-negative integer, or an IMF-fixdate
|
|
181
|
+
(`Sun, 06 Nov 1994 08:49:37 GMT`) read as the time from `now` (epoch seconds), never below 0. None when absent or
|
|
182
|
+
in any other form; the obsolete date forms are not read."""
|
|
183
|
+
if value is None:
|
|
184
|
+
return None
|
|
185
|
+
text = str(value).strip()
|
|
186
|
+
if re.fullmatch(r"[0-9]{1,10}", text):
|
|
187
|
+
return float(int(text))
|
|
188
|
+
m = _IMF_FIXDATE.fullmatch(text)
|
|
189
|
+
if not m:
|
|
190
|
+
return None
|
|
191
|
+
day, month, year, hh, mm, ss = int(m[2]), _MONTHS.index(m[3]) + 1, int(m[4]), int(m[5]), int(m[6]), int(m[7])
|
|
192
|
+
if hh > 23 or mm > 59 or ss > 60 or year < 1000: # a year before 1000 is no HTTP date (and Date.UTC maps 0-99)
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
when = _dt.datetime(year, month, day, hh, mm, min(ss, 59), tzinfo=_dt.timezone.utc).timestamp()
|
|
196
|
+
except ValueError: # a day the month does not have
|
|
197
|
+
return None
|
|
198
|
+
return float(max(0.0, when - (time.time() if now is None else now)))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def backoff_s(attempt: int, *, base_s: float, max_s: float, rand: float) -> float:
|
|
202
|
+
"""The jittered wait before retry `attempt` (from 0): `min(max_s, base_s * 2**attempt) * (0.5 + rand)`."""
|
|
203
|
+
return min(max_s, base_s * (2 ** attempt)) * (0.5 + rand)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def may_retry(method: str, headers: Mapping[str, str], status: Optional[int]) -> bool:
|
|
207
|
+
"""Whether an answer with `status` (None: a connection error or a timeout) may be retried for this request."""
|
|
208
|
+
if status is not None and status not in RETRY_STATUSES:
|
|
209
|
+
return False
|
|
210
|
+
safe = method.upper() in IDEMPOTENT_METHODS or any(str(k).lower() == "idempotency-key" for k in headers)
|
|
211
|
+
return safe or status == 429
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _check_headers(headers: Mapping[str, str]) -> Dict[str, str]:
|
|
215
|
+
out = {}
|
|
216
|
+
for name, value in headers.items():
|
|
217
|
+
name, value = str(name), str(value)
|
|
218
|
+
if not name or any(c not in _TOKEN for c in name):
|
|
219
|
+
raise HttpError("a header name is not an HTTP token", kind="refused")
|
|
220
|
+
if any(c in value for c in "\r\n\0"):
|
|
221
|
+
raise HttpError("a header value carries a line break or NUL", kind="refused")
|
|
222
|
+
out[name] = value
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
227
|
+
def redirect_request(self, *args, **kwargs): # a redirect is answered as it came, never followed
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class _Watchdog:
|
|
232
|
+
"""Shuts a request's socket down when its deadline passes, so a server that trickles its headers or a chunk-size
|
|
233
|
+
line cannot hold the caller past it (a socket timeout bounds one read, not a whole response)."""
|
|
234
|
+
|
|
235
|
+
def __init__(self, end: float):
|
|
236
|
+
self.end = end
|
|
237
|
+
self.fired = False
|
|
238
|
+
self._timer: Optional[threading.Timer] = None
|
|
239
|
+
|
|
240
|
+
def arm(self, sock: Any) -> None:
|
|
241
|
+
def fire() -> None:
|
|
242
|
+
self.fired = True
|
|
243
|
+
try:
|
|
244
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
245
|
+
except OSError:
|
|
246
|
+
pass
|
|
247
|
+
self._timer = threading.Timer(max(0.0, self.end - time.monotonic()), fire)
|
|
248
|
+
self._timer.daemon = True
|
|
249
|
+
self._timer.start()
|
|
250
|
+
|
|
251
|
+
def cancel(self) -> None:
|
|
252
|
+
if self._timer is not None:
|
|
253
|
+
self._timer.cancel()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _watched(base: type, watchdog: _Watchdog) -> type:
|
|
257
|
+
class Watched(base): # type: ignore[misc, valid-type]
|
|
258
|
+
def connect(self) -> None:
|
|
259
|
+
super().connect()
|
|
260
|
+
watchdog.arm(self.sock)
|
|
261
|
+
return Watched
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _opener(watchdog: _Watchdog) -> urllib.request.OpenerDirector:
|
|
265
|
+
class Http(urllib.request.HTTPHandler):
|
|
266
|
+
def http_open(self, req):
|
|
267
|
+
return self.do_open(_watched(http.client.HTTPConnection, watchdog), req)
|
|
268
|
+
|
|
269
|
+
class Https(urllib.request.HTTPSHandler):
|
|
270
|
+
def https_open(self, req):
|
|
271
|
+
return self.do_open(_watched(http.client.HTTPSConnection, watchdog), req, context=self._context)
|
|
272
|
+
return urllib.request.build_opener(_NoRedirect, Http, Https)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout_s: float,
|
|
276
|
+
max_bytes: int) -> Tuple[int, Dict[str, str], bytes]:
|
|
277
|
+
"""The default transport: urllib, no redirects followed, the body read to one byte past `max_bytes`."""
|
|
278
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
279
|
+
end = time.monotonic() + timeout_s # one deadline from the first byte sent, headers and body included
|
|
280
|
+
watchdog = _Watchdog(end)
|
|
281
|
+
try:
|
|
282
|
+
return _send(req, timeout_s, max_bytes, end, watchdog)
|
|
283
|
+
except (OSError, http.client.HTTPException) as exc:
|
|
284
|
+
if watchdog.fired:
|
|
285
|
+
raise TimeoutError("the answer did not arrive within the deadline")
|
|
286
|
+
raise exc if isinstance(exc, OSError) else ConnectionError(type(exc).__name__)
|
|
287
|
+
finally:
|
|
288
|
+
watchdog.cancel()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _send(req: urllib.request.Request, timeout_s: float, max_bytes: int, end: float,
|
|
292
|
+
watchdog: _Watchdog) -> Tuple[int, Dict[str, str], bytes]:
|
|
293
|
+
try:
|
|
294
|
+
resp = _opener(watchdog).open(req, timeout=timeout_s)
|
|
295
|
+
except urllib.error.HTTPError as answer: # a status outside 2xx is an answer, not a failure of the transport
|
|
296
|
+
resp = answer
|
|
297
|
+
except urllib.error.URLError as exc:
|
|
298
|
+
reason = exc.reason
|
|
299
|
+
raise reason if isinstance(reason, OSError) else ConnectionError(str(reason))
|
|
300
|
+
except (http.client.HTTPException, ValueError) as exc:
|
|
301
|
+
raise ConnectionError(type(exc).__name__)
|
|
302
|
+
try:
|
|
303
|
+
chunks, size = [], 0
|
|
304
|
+
while size <= max_bytes:
|
|
305
|
+
left = end - time.monotonic()
|
|
306
|
+
if left <= 0:
|
|
307
|
+
raise TimeoutError("the body did not arrive within the deadline")
|
|
308
|
+
sock = getattr(getattr(getattr(resp, "fp", None), "raw", None), "_sock", None)
|
|
309
|
+
if sock is not None:
|
|
310
|
+
sock.settimeout(left) # each read waits at most what is left of the deadline
|
|
311
|
+
chunk = resp.read1(min(65536, max_bytes + 1 - size))
|
|
312
|
+
if watchdog.fired: # Linux reads a shut socket as a clean end: a cut answer is never a whole one
|
|
313
|
+
raise TimeoutError("the body did not arrive within the deadline")
|
|
314
|
+
if not chunk:
|
|
315
|
+
break
|
|
316
|
+
chunks.append(chunk)
|
|
317
|
+
size += len(chunk)
|
|
318
|
+
data = b"".join(chunks)[:max_bytes + 1]
|
|
319
|
+
return resp.status if hasattr(resp, "status") else resp.code, dict(resp.headers.items()), data
|
|
320
|
+
except http.client.HTTPException as exc:
|
|
321
|
+
raise ConnectionError(type(exc).__name__)
|
|
322
|
+
finally:
|
|
323
|
+
resp.close()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def request(method: str, url: str, *, headers: Optional[Mapping[str, str]] = None, body: Any = None,
|
|
327
|
+
deadline_s: float = 30.0, retries: int = 2, max_bytes: int = DEFAULT_MAX_BYTES, backoff_base_s: float = 0.5,
|
|
328
|
+
max_backoff_s: float = 8.0, max_retry_after_s: float = 30.0, insecure: bool = False,
|
|
329
|
+
transport: Optional[Transport] = None, sleep: Callable[[float], None] = time.sleep,
|
|
330
|
+
clock: Callable[[], float] = time.monotonic, rand: Callable[[], float] = _random.random) -> Response:
|
|
331
|
+
"""Send one request under the rules above and return its answer, whatever its status; raise `HttpError` when
|
|
332
|
+
no answer came (a connection error, the deadline, a body over the cap, a refused URL). A `dict` or `list` body is
|
|
333
|
+
sent as JSON with `Content-Type: application/json` unless the caller set one; `bytes` and `str` go as they are."""
|
|
334
|
+
method = str(method).upper()
|
|
335
|
+
if not method or any(c not in _TOKEN for c in method):
|
|
336
|
+
raise HttpError("the method is not an HTTP token", kind="refused")
|
|
337
|
+
if not (isinstance(retries, int) and 0 <= retries <= 10) or isinstance(retries, bool):
|
|
338
|
+
raise HttpError("retries is an integer from 0 to 10", kind="refused")
|
|
339
|
+
if not (deadline_s > 0 and max_bytes > 0):
|
|
340
|
+
raise HttpError("deadline_s and max_bytes are positive", kind="refused")
|
|
341
|
+
check_url(url, insecure=insecure)
|
|
342
|
+
sent = _check_headers(headers or {})
|
|
343
|
+
data: Optional[bytes]
|
|
344
|
+
if body is None:
|
|
345
|
+
data = None
|
|
346
|
+
elif isinstance(body, (dict, list)):
|
|
347
|
+
try:
|
|
348
|
+
data = json_bytes(body)
|
|
349
|
+
except (ValueError, TypeError):
|
|
350
|
+
raise HttpError("the body holds a value JSON cannot write (NaN, Infinity or a non-JSON type)", kind="refused")
|
|
351
|
+
if not any(k.lower() == "content-type" for k in sent):
|
|
352
|
+
sent["Content-Type"] = "application/json"
|
|
353
|
+
elif isinstance(body, str):
|
|
354
|
+
data = utf8(body)
|
|
355
|
+
elif isinstance(body, (bytes, bytearray)):
|
|
356
|
+
data = bytes(body)
|
|
357
|
+
else:
|
|
358
|
+
raise HttpError("the body is bytes, str, a dict or a list", kind="refused")
|
|
359
|
+
send = transport or urllib_transport
|
|
360
|
+
where = redact_url(url)
|
|
361
|
+
end = clock() + deadline_s
|
|
362
|
+
attempt = 0
|
|
363
|
+
while True:
|
|
364
|
+
left = end - clock()
|
|
365
|
+
if left <= 0:
|
|
366
|
+
raise HttpError("%s %s: the deadline passed before an answer" % (method, where), url=where,
|
|
367
|
+
attempts=attempt, kind="timeout")
|
|
368
|
+
attempt += 1
|
|
369
|
+
status: Optional[int] = None
|
|
370
|
+
failure: Optional[HttpError] = None
|
|
371
|
+
try:
|
|
372
|
+
status, got_headers, got = send(method, url, dict(sent), data, left, max_bytes)
|
|
373
|
+
except HttpError:
|
|
374
|
+
raise
|
|
375
|
+
except TimeoutError:
|
|
376
|
+
failure = HttpError("%s %s: timed out" % (method, where), url=where, attempts=attempt, kind="timeout")
|
|
377
|
+
except Exception as exc: # noqa: BLE001 - as the Node twin: whatever the transport throws is a connection error
|
|
378
|
+
failure = HttpError("%s %s: connection error (%s)" % (method, where, type(exc).__name__), url=where,
|
|
379
|
+
attempts=attempt, kind="connection")
|
|
380
|
+
if failure is None:
|
|
381
|
+
if len(got) > max_bytes:
|
|
382
|
+
raise HttpError("%s %s: the answer is over %d bytes" % (method, where, max_bytes), status=status,
|
|
383
|
+
url=where, attempts=attempt, kind="too-large")
|
|
384
|
+
answer = Response(status, got_headers, got, attempts=attempt, url=where)
|
|
385
|
+
if not may_retry(method, sent, status) or attempt > retries:
|
|
386
|
+
return answer
|
|
387
|
+
wait = retry_after_s(answer.headers.get("retry-after"))
|
|
388
|
+
if wait is not None:
|
|
389
|
+
if wait > max_retry_after_s:
|
|
390
|
+
return answer # the server asks for longer than this caller will wait
|
|
391
|
+
else:
|
|
392
|
+
wait = backoff_s(attempt - 1, base_s=backoff_base_s, max_s=max_backoff_s, rand=rand())
|
|
393
|
+
else:
|
|
394
|
+
if not may_retry(method, sent, None) or attempt > retries:
|
|
395
|
+
raise failure
|
|
396
|
+
answer = None
|
|
397
|
+
wait = backoff_s(attempt - 1, base_s=backoff_base_s, max_s=max_backoff_s, rand=rand())
|
|
398
|
+
if clock() + wait >= end:
|
|
399
|
+
if answer is not None:
|
|
400
|
+
return answer
|
|
401
|
+
raise failure # type: ignore[misc]
|
|
402
|
+
sleep(wait)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -255,12 +255,7 @@ def _own(value: Any, key: str) -> Any:
|
|
|
255
255
|
return value.get(key, _ABSENT) if isinstance(value, dict) else _ABSENT
|
|
256
256
|
|
|
257
257
|
|
|
258
|
-
|
|
259
|
-
year -= month <= 2
|
|
260
|
-
era = (year if year >= 0 else year - 399) // 400
|
|
261
|
-
yoe = year - era * 400
|
|
262
|
-
doy = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
|
|
263
|
-
return era * 146097 + yoe * 365 + yoe // 4 - yoe // 100 + doy - 719468
|
|
258
|
+
from fabric_time import days_from_civil as _days_from_civil # noqa: E402 - the core's (FAA-65)
|
|
264
259
|
|
|
265
260
|
|
|
266
261
|
def utc_time(value: Any) -> Optional[int]:
|