-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathorchestrator.py
More file actions
315 lines (267 loc) · 10.1 KB
/
Copy pathorchestrator.py
File metadata and controls
315 lines (267 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
from __future__ import annotations
import json
import logging
import ssl
from functools import lru_cache
from typing import Any, Optional, Sequence
from urllib.parse import ParseResult, parse_qsl, quote, urlencode, urlparse, urlunparse
from urllib.error import URLError, HTTPError
from urllib.request import Request, urlopen
from . import lp_rpc_pb2
from .capabilities import capabilities_to_query
from .errors import (
InsufficientBalance,
LivepeerGatewayError,
SignerRefreshRequired,
SkipPaymentCycle,
)
from .remote_signer import RemoteSignerError
_LOG = logging.getLogger(__name__)
def _truncate(s: str, max_len: int = 2000) -> str:
if len(s) <= max_len:
return s
return s[:max_len] + f"...(+{len(s) - max_len} chars)"
def _http_error_body(e: HTTPError) -> str:
"""
Best-effort read of an HTTPError response body for debugging.
"""
try:
b = e.read()
if not b:
return ""
if isinstance(b, bytes):
return b.decode("utf-8", errors="replace")
return str(b)
except Exception:
return ""
def _extract_error_message(e: HTTPError) -> str:
"""
Best-effort extraction of a useful error message from an HTTPError body.
If the body is JSON and matches {"error": {"message": "..."}}, return that message.
Otherwise return the full body.
Always truncates the returned value for readability.
"""
body = _http_error_body(e)
s = body.strip()
if not s:
return ""
try:
data = json.loads(s)
except Exception:
return _truncate(body)
if isinstance(data, dict):
err = data.get("error")
if isinstance(err, dict):
msg = err.get("message")
if isinstance(msg, str) and msg:
return _truncate(msg)
return _truncate(body)
def request_json(
url: str,
*,
method: Optional[str] = None,
payload: Optional[dict[str, Any]] = None,
headers: Optional[dict[str, str]] = None,
timeout: float = 5.0,
) -> Any:
"""
Make a JSON HTTP request and parse the JSON response.
If method is None, defaults to POST when payload is provided, otherwise GET.
Raises LivepeerGatewayError on HTTP/network/JSON parsing errors.
"""
req_headers: dict[str, str] = {
"Accept": "application/json",
"User-Agent": "livepeer-python-gateway/0.1",
}
body: Optional[bytes] = None
if payload is not None:
req_headers["Content-Type"] = "application/json"
body = json.dumps(payload).encode("utf-8")
if headers:
req_headers.update(headers)
resolved_method = method.upper() if method else ("POST" if payload is not None else "GET")
req = Request(url, data=body, headers=req_headers, method=resolved_method)
# Always ignore HTTPS certificate validation (matches our gRPC behavior).
ssl_ctx = ssl._create_unverified_context()
try:
with urlopen(req, timeout=timeout, context=ssl_ctx) as resp:
raw = resp.read().decode("utf-8")
data: Any = json.loads(raw)
except HTTPError as e:
body = _extract_error_message(e)
body_part = f"; body={body!r}" if body else ""
if e.code == 480:
raise SignerRefreshRequired(
f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}"
) from e
if e.code == 482:
raise SkipPaymentCycle(
f"Signer returned HTTP 482 (skip payment cycle) (url={url}){body_part}"
) from e
if e.code == 483:
raise InsufficientBalance(
f"Signer returned HTTP 483 (insufficient balance) (url={url}){body_part}"
) from e
raise LivepeerGatewayError(
f"HTTP JSON error: HTTP {e.code} from endpoint (url={url}){body_part}"
) from e
except ConnectionRefusedError as e:
raise LivepeerGatewayError(
f"HTTP JSON error: connection refused (is the server running? is the host/port correct?) (url={url})"
) from e
except URLError as e:
raise LivepeerGatewayError(
f"HTTP JSON error: failed to reach endpoint: {getattr(e, 'reason', e)} (url={url})"
) from e
except json.JSONDecodeError as e:
raise LivepeerGatewayError(f"HTTP JSON error: endpoint did not return valid JSON: {e} (url={url})") from e
except Exception as e:
raise LivepeerGatewayError(
f"HTTP JSON error: unexpected error: {e.__class__.__name__}: {e} (url={url})"
) from e
return data
def post_json(
url: str,
payload: dict[str, Any],
*,
headers: Optional[dict[str, str]] = None,
timeout: float = 5.0,
) -> dict[str, Any]:
"""
POST JSON to `url` and parse a JSON object response.
"""
data = request_json(
url,
payload=payload,
headers=headers,
timeout=timeout,
)
if not isinstance(data, dict):
raise LivepeerGatewayError(
f"HTTP JSON error: expected JSON object, got {type(data).__name__} (url={url})"
)
return data
def get_json(
url: str,
*,
headers: Optional[dict[str, str]] = None,
timeout: float = 5.0,
) -> Any:
"""
GET JSON from `url` and parse the response.
"""
return request_json(url, headers=headers, timeout=timeout)
def _parse_http_url(url: str, *, context: str = "URL") -> ParseResult:
"""
Normalize a URL for HTTP(S) endpoints.
Accepts:
- "host:port" (implicitly https://host:port)
- "http://host:port[/...]"
- "https://host:port[/...]"
"""
url = url.strip()
normalized = url if "://" in url else f"https://{url}"
parsed = urlparse(normalized)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Only http:// or https:// {context}s are supported (got {parsed.scheme!r})")
if not parsed.netloc:
raise ValueError(f"Invalid {context}: {url!r}")
return parsed
def _http_origin(url: str) -> str:
"""
Normalize a URL (possibly with a path) into a scheme:// origin (scheme + host:port).
Accepts:
- "host:port" (implicitly https://host:port)
- "http://host:port[/...]" (path/query/fragment are ignored)
- "https://host:port[/...]" (path/query/fragment are ignored)
"""
parsed = _parse_http_url(url)
return f"{parsed.scheme}://{parsed.netloc}"
def _append_caps(url: str, capabilities: Optional[lp_rpc_pb2.Capabilities]) -> str:
"""
Append repeated `caps` query parameters to a URL.
Existing query params are preserved. Capability values keep `/` unescaped.
Example output:
https://example.com/discover-orchestrators?x=1&caps=live-video-to-video/streamdiffusion-sdxl-v2v&caps=text-to-image/sdxl
"""
if capabilities is None:
return url
caps = capabilities_to_query(capabilities)
if not caps:
return url
parsed = urlparse(url)
query_pairs = parse_qsl(parsed.query, keep_blank_values=True)
query_pairs.extend(("caps", cap) for cap in caps)
query = urlencode(query_pairs, doseq=True, quote_via=quote, safe="/")
return urlunparse(parsed._replace(query=query))
def discover_orchestrators(
orchestrators: Optional[Sequence[str] | str] = None,
*,
signer_url: Optional[str] = None,
signer_headers: Optional[dict[str, str]] = None,
discovery_url: Optional[str] = None,
discovery_headers: Optional[dict[str, str]] = None,
capabilities: Optional[lp_rpc_pb2.Capabilities] = None,
) -> list[str]:
"""
Discover orchestrators and return a list of addresses.
This discovery can happen via the following parameters in priority order (highest first):
- orchestrators: list or comma-delimited string
(empty/whitespace-only input falls through)
- discovery_url: use this discovery endpoint
- signer_url: use signer-provided discovery service
"""
if orchestrators is not None:
if isinstance(orchestrators, str):
orch_list = [orch.strip() for orch in orchestrators.split(",")]
else:
try:
orch_list = list(orchestrators)
except TypeError as e:
raise LivepeerGatewayError(
"discover_orchestrators requires a list of orchestrator URLs or a comma-delimited string"
) from e
orch_list = [orch.strip() for orch in orch_list if isinstance(orch, str) and orch.strip()]
if orch_list:
return orch_list
if discovery_url:
discovery_endpoint = _parse_http_url(discovery_url).geturl()
request_headers = discovery_headers
elif signer_url:
discovery_endpoint = f"{_http_origin(signer_url)}/discover-orchestrators"
request_headers = signer_headers
else:
_LOG.debug("discover_orchestrators failed: no discovery inputs")
raise LivepeerGatewayError("discover_orchestrators requires discovery_url or signer_url")
if capabilities is not None:
discovery_endpoint = _append_caps(discovery_endpoint, capabilities)
try:
_LOG.debug("discover_orchestrators running discovery: %s", discovery_endpoint)
data = get_json(discovery_endpoint, headers=request_headers)
except LivepeerGatewayError as e:
_LOG.debug("discover_orchestrators discovery failed: %s", e)
raise RemoteSignerError(
discovery_endpoint,
str(e),
cause=e.__cause__ or e,
) from None
if not isinstance(data, list):
_LOG.debug(
"discover_orchestrators discovery response not list: type=%s",
type(data).__name__,
)
raise RemoteSignerError(
discovery_endpoint,
f"Discovery response must be a JSON list, got {type(data).__name__}",
cause=None,
) from None
_LOG.debug("discover_orchestrators discovery response: %s", data)
orch_list = []
for item in data:
if not isinstance(item, dict):
continue
address = item.get("address")
if isinstance(address, str) and address.strip():
orch_list.append(address.strip())
_LOG.debug("discover_orchestrators discovered %d orchestrators", len(orch_list))
return orch_list