sursolver 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.
- sursolver/__init__.py +86 -0
- sursolver/_async_client.py +191 -0
- sursolver/_client.py +194 -0
- sursolver/_core.py +362 -0
- sursolver/_errors.py +163 -0
- sursolver/_types.py +105 -0
- sursolver/_version.py +2 -0
- sursolver/py.typed +0 -0
- sursolver/task_types.py +75 -0
- sursolver-0.1.0.dist-info/METADATA +235 -0
- sursolver-0.1.0.dist-info/RECORD +13 -0
- sursolver-0.1.0.dist-info/WHEEL +4 -0
- sursolver-0.1.0.dist-info/licenses/LICENSE +21 -0
sursolver/_core.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Validation, request building and response classification shared by both clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._errors import (
|
|
14
|
+
ApiError,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
Operation,
|
|
17
|
+
ProtocolError,
|
|
18
|
+
SurSolverError,
|
|
19
|
+
TransportError,
|
|
20
|
+
api_error_for,
|
|
21
|
+
)
|
|
22
|
+
from ._types import (
|
|
23
|
+
HttpRequest,
|
|
24
|
+
HttpResponse,
|
|
25
|
+
ServiceInfo,
|
|
26
|
+
TaskResult,
|
|
27
|
+
TaskTypeCatalog,
|
|
28
|
+
TaskTypeInfo,
|
|
29
|
+
)
|
|
30
|
+
from ._version import VERSION
|
|
31
|
+
|
|
32
|
+
DEFAULT_BASE_URL = "https://api.sursolver.com"
|
|
33
|
+
DEFAULT_REQUEST_TIMEOUT = 30.0
|
|
34
|
+
DEFAULT_POLL_INTERVAL = 3.0
|
|
35
|
+
DEFAULT_SOLVE_TIMEOUT = 210.0
|
|
36
|
+
DEFAULT_MAX_SUBMIT_RETRIES = 1
|
|
37
|
+
SUBMIT_RETRY_JITTER_MS = 1000
|
|
38
|
+
"""Upper bound of the random delay added to a submit retry, so parallel callers spread out."""
|
|
39
|
+
|
|
40
|
+
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Settings:
|
|
45
|
+
api_key: str
|
|
46
|
+
base_url: str
|
|
47
|
+
request_timeout: float
|
|
48
|
+
poll_interval: float
|
|
49
|
+
solve_timeout: float
|
|
50
|
+
max_submit_retries: int
|
|
51
|
+
app_id: str | None
|
|
52
|
+
user_agent: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def positive_seconds(name: str, value: float | None, fallback: float) -> float:
|
|
56
|
+
if value is None:
|
|
57
|
+
return fallback
|
|
58
|
+
if (
|
|
59
|
+
isinstance(value, bool)
|
|
60
|
+
or not isinstance(value, (int, float))
|
|
61
|
+
or not math.isfinite(value)
|
|
62
|
+
or value <= 0
|
|
63
|
+
):
|
|
64
|
+
raise ConfigurationError(f"{name} must be a positive number of seconds")
|
|
65
|
+
return float(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _normalize_base_url(raw: str) -> str:
|
|
69
|
+
parts = urlsplit(raw)
|
|
70
|
+
loopback_http = parts.scheme == "http" and parts.hostname in _LOOPBACK_HOSTS
|
|
71
|
+
if parts.scheme != "https" and not loopback_http:
|
|
72
|
+
raise ConfigurationError("base_url must use https:// (plain http:// only for localhost)")
|
|
73
|
+
if not parts.netloc:
|
|
74
|
+
raise ConfigurationError("base_url has no host")
|
|
75
|
+
if parts.username is not None or parts.password is not None or parts.query or parts.fragment:
|
|
76
|
+
raise ConfigurationError("base_url must not carry credentials, a query or a fragment")
|
|
77
|
+
return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_settings(
|
|
81
|
+
api_key: str,
|
|
82
|
+
*,
|
|
83
|
+
base_url: str,
|
|
84
|
+
request_timeout: float | None,
|
|
85
|
+
poll_interval: float | None,
|
|
86
|
+
solve_timeout: float | None,
|
|
87
|
+
max_submit_retries: int | None,
|
|
88
|
+
app_id: str | None,
|
|
89
|
+
user_agent_suffix: str | None,
|
|
90
|
+
) -> Settings:
|
|
91
|
+
if not isinstance(api_key, str) or api_key.strip() == "":
|
|
92
|
+
raise ConfigurationError("api_key must be a non-empty string")
|
|
93
|
+
if api_key.strip() != api_key:
|
|
94
|
+
raise ConfigurationError("api_key has leading or trailing whitespace")
|
|
95
|
+
retries = DEFAULT_MAX_SUBMIT_RETRIES if max_submit_retries is None else max_submit_retries
|
|
96
|
+
if isinstance(retries, bool) or not isinstance(retries, int) or retries < 0:
|
|
97
|
+
raise ConfigurationError("max_submit_retries must be a non-negative integer")
|
|
98
|
+
if user_agent_suffix is not None and not (
|
|
99
|
+
user_agent_suffix and all(" " <= char <= "~" for char in user_agent_suffix)
|
|
100
|
+
):
|
|
101
|
+
raise ConfigurationError("user_agent_suffix must be printable ASCII")
|
|
102
|
+
user_agent = f"sursolver-python/{VERSION}"
|
|
103
|
+
if user_agent_suffix is not None:
|
|
104
|
+
user_agent = f"{user_agent} {user_agent_suffix}"
|
|
105
|
+
trimmed_app_id = app_id.strip() if app_id is not None else ""
|
|
106
|
+
return Settings(
|
|
107
|
+
api_key=api_key,
|
|
108
|
+
base_url=_normalize_base_url(base_url),
|
|
109
|
+
request_timeout=positive_seconds("request_timeout", request_timeout, DEFAULT_REQUEST_TIMEOUT),
|
|
110
|
+
poll_interval=positive_seconds("poll_interval", poll_interval, DEFAULT_POLL_INTERVAL),
|
|
111
|
+
solve_timeout=positive_seconds("solve_timeout", solve_timeout, DEFAULT_SOLVE_TIMEOUT),
|
|
112
|
+
max_submit_retries=retries,
|
|
113
|
+
app_id=trimmed_app_id or None,
|
|
114
|
+
user_agent=user_agent,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def solver_request(settings: Settings, path: str, payload: dict[str, Any]) -> HttpRequest:
|
|
119
|
+
return HttpRequest(
|
|
120
|
+
method="POST",
|
|
121
|
+
url=f"{settings.base_url}{path}",
|
|
122
|
+
headers={
|
|
123
|
+
"accept": "application/json",
|
|
124
|
+
"content-type": "application/json",
|
|
125
|
+
"user-agent": settings.user_agent,
|
|
126
|
+
},
|
|
127
|
+
body=json.dumps(payload).encode("utf-8"),
|
|
128
|
+
timeout=settings.request_timeout,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def catalog_request(settings: Settings, path: str) -> HttpRequest:
|
|
133
|
+
return HttpRequest(
|
|
134
|
+
method="GET",
|
|
135
|
+
url=f"{settings.base_url}{path}",
|
|
136
|
+
headers={"accept": "application/json", "user-agent": settings.user_agent},
|
|
137
|
+
body=None,
|
|
138
|
+
timeout=settings.request_timeout,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def create_task_payload(settings: Settings, task: Any) -> dict[str, Any]:
|
|
143
|
+
payload: dict[str, Any] = {"clientKey": settings.api_key, "task": task}
|
|
144
|
+
if settings.app_id is not None:
|
|
145
|
+
payload["appId"] = settings.app_id
|
|
146
|
+
return payload
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def transport_failure(operation: Operation, error: Exception, timeout: float) -> TransportError:
|
|
150
|
+
"""A transport that raised: no response arrived."""
|
|
151
|
+
if isinstance(error, (httpx.TimeoutException, TimeoutError)):
|
|
152
|
+
return TransportError(operation, f"request timed out after {timeout:g} s")
|
|
153
|
+
return TransportError(operation, "connection failed")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _load_json(body: bytes) -> Any:
|
|
157
|
+
if not body:
|
|
158
|
+
return None
|
|
159
|
+
try:
|
|
160
|
+
return json.loads(body)
|
|
161
|
+
except ValueError:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_integral(value: Any) -> bool:
|
|
166
|
+
if isinstance(value, bool):
|
|
167
|
+
return False
|
|
168
|
+
return isinstance(value, int) or (isinstance(value, float) and value.is_integer())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _checked(operation: Operation, response: HttpResponse) -> Any:
|
|
172
|
+
"""Reject non-200 statuses; return the parsed JSON body (None when not JSON)."""
|
|
173
|
+
parsed = _load_json(response.body)
|
|
174
|
+
if response.status != 200:
|
|
175
|
+
envelope = parsed.get("error") if isinstance(parsed, dict) else None
|
|
176
|
+
envelope = envelope if isinstance(envelope, dict) else {}
|
|
177
|
+
code = envelope.get("code")
|
|
178
|
+
server_code = code if isinstance(code, str) else None
|
|
179
|
+
retry_after = envelope.get("retry_after_seconds")
|
|
180
|
+
reason = f"HTTP {response.status}" + ("" if server_code is None else f" ({server_code})")
|
|
181
|
+
raise TransportError(
|
|
182
|
+
operation,
|
|
183
|
+
reason,
|
|
184
|
+
status=response.status,
|
|
185
|
+
server_code=server_code,
|
|
186
|
+
retry_after_seconds=retry_after
|
|
187
|
+
if isinstance(retry_after, (int, float)) and not isinstance(retry_after, bool)
|
|
188
|
+
else None,
|
|
189
|
+
)
|
|
190
|
+
return parsed
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_solver(operation: Operation, response: HttpResponse, task_id: str | None = None) -> dict[str, Any]:
|
|
194
|
+
"""Return the ``errorId: 0`` body of a solver call or raise the classified error."""
|
|
195
|
+
body = _checked(operation, response)
|
|
196
|
+
if not isinstance(body, dict) or not _is_integral(body.get("errorId")):
|
|
197
|
+
what = "not JSON" if body is None else "not the solver envelope"
|
|
198
|
+
raise TransportError(operation, f"HTTP 200 body is {what}", status=200)
|
|
199
|
+
error_id = body["errorId"]
|
|
200
|
+
if error_id == 0:
|
|
201
|
+
return body
|
|
202
|
+
if error_id != 1:
|
|
203
|
+
raise ProtocolError(operation, f"errorId {error_id}")
|
|
204
|
+
code = body.get("errorCode")
|
|
205
|
+
if not isinstance(code, str):
|
|
206
|
+
raise ProtocolError(operation, "errorId 1 without a string errorCode")
|
|
207
|
+
description = body.get("errorDescription")
|
|
208
|
+
supported = body.get("supportedUserAgents")
|
|
209
|
+
raise api_error_for(
|
|
210
|
+
operation,
|
|
211
|
+
code,
|
|
212
|
+
description if isinstance(description, str) else "",
|
|
213
|
+
task_id=task_id,
|
|
214
|
+
supported_user_agents=[ua for ua in supported if isinstance(ua, str)]
|
|
215
|
+
if isinstance(supported, list)
|
|
216
|
+
else (),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def parse_catalog(operation: Operation, response: HttpResponse) -> dict[str, Any]:
|
|
221
|
+
"""Return ``data`` of a ``{"success": true, "data": {...}}`` envelope."""
|
|
222
|
+
body = _checked(operation, response)
|
|
223
|
+
if (
|
|
224
|
+
not isinstance(body, dict)
|
|
225
|
+
or body.get("success") is not True
|
|
226
|
+
or not isinstance(body.get("data"), dict)
|
|
227
|
+
):
|
|
228
|
+
raise TransportError(operation, "HTTP 200 body is not the API envelope", status=200)
|
|
229
|
+
data: dict[str, Any] = body["data"]
|
|
230
|
+
return data
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def task_id_from(body: dict[str, Any]) -> str:
|
|
234
|
+
task_id = body.get("taskId")
|
|
235
|
+
if not isinstance(task_id, str) or task_id == "":
|
|
236
|
+
raise ProtocolError("createTask", "errorId 0 without a taskId")
|
|
237
|
+
return task_id
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def task_result_from(body: dict[str, Any]) -> TaskResult:
|
|
241
|
+
status = body.get("status")
|
|
242
|
+
if status == "processing":
|
|
243
|
+
return TaskResult(status="processing")
|
|
244
|
+
if status == "ready":
|
|
245
|
+
solution = body.get("solution")
|
|
246
|
+
if not isinstance(solution, dict):
|
|
247
|
+
raise ProtocolError("getTaskResult", "status ready without a solution object")
|
|
248
|
+
return TaskResult(status="ready", solution=solution)
|
|
249
|
+
raise ProtocolError("getTaskResult", f"unknown status {json.dumps(status)}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def balance_from(body: dict[str, Any]) -> float:
|
|
253
|
+
balance = body.get("balance")
|
|
254
|
+
if isinstance(balance, bool) or not isinstance(balance, (int, float)) or not math.isfinite(balance):
|
|
255
|
+
raise ProtocolError("getBalance", "errorId 0 without a numeric balance")
|
|
256
|
+
return balance
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class _Entry:
|
|
260
|
+
"""Typed reads of one catalog entry; a missing or mistyped field is a ProtocolError."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, operation: Operation, value: Any) -> None:
|
|
263
|
+
if not isinstance(value, dict):
|
|
264
|
+
raise ProtocolError(operation, "a catalog entry is not an object")
|
|
265
|
+
self._operation = operation
|
|
266
|
+
self._value: dict[str, Any] = value
|
|
267
|
+
|
|
268
|
+
def _fail(self, key: str, expected: str) -> ProtocolError:
|
|
269
|
+
return ProtocolError(self._operation, f"catalog field {key} is not {expected}")
|
|
270
|
+
|
|
271
|
+
def text(self, key: str) -> str:
|
|
272
|
+
value = self._value.get(key)
|
|
273
|
+
if not isinstance(value, str):
|
|
274
|
+
raise self._fail(key, "a string")
|
|
275
|
+
return value
|
|
276
|
+
|
|
277
|
+
def optional_text(self, key: str) -> str | None:
|
|
278
|
+
value = self._value.get(key)
|
|
279
|
+
if value is not None and not isinstance(value, str):
|
|
280
|
+
raise self._fail(key, "a string or null")
|
|
281
|
+
return value
|
|
282
|
+
|
|
283
|
+
def integer(self, key: str) -> int:
|
|
284
|
+
value = self._value.get(key)
|
|
285
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
286
|
+
raise self._fail(key, "an integer")
|
|
287
|
+
return value
|
|
288
|
+
|
|
289
|
+
def optional_integer(self, key: str) -> int | None:
|
|
290
|
+
return None if self._value.get(key) is None else self.integer(key)
|
|
291
|
+
|
|
292
|
+
def texts(self, key: str) -> tuple[str, ...]:
|
|
293
|
+
value = self._value.get(key)
|
|
294
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
295
|
+
raise self._fail(key, "a list of strings")
|
|
296
|
+
return tuple(value)
|
|
297
|
+
|
|
298
|
+
def groups(self, key: str) -> tuple[tuple[str, ...], ...]:
|
|
299
|
+
value = self._value.get(key)
|
|
300
|
+
if not isinstance(value, list) or not all(
|
|
301
|
+
isinstance(group, list) and all(isinstance(item, str) for item in group) for group in value
|
|
302
|
+
):
|
|
303
|
+
raise self._fail(key, "a list of string lists")
|
|
304
|
+
return tuple(tuple(group) for group in value)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def task_types_from(data: dict[str, Any]) -> TaskTypeCatalog:
|
|
308
|
+
entries, codes = data.get("task_types"), data.get("error_codes")
|
|
309
|
+
if not isinstance(entries, list) or not isinstance(codes, list):
|
|
310
|
+
raise ProtocolError("listTaskTypes", "data without task_types and error_codes arrays")
|
|
311
|
+
task_types = []
|
|
312
|
+
for raw in entries:
|
|
313
|
+
entry = _Entry("listTaskTypes", raw)
|
|
314
|
+
task_types.append(
|
|
315
|
+
TaskTypeInfo(
|
|
316
|
+
page_slug=entry.optional_text("page_slug"),
|
|
317
|
+
display_name=entry.text("display_name"),
|
|
318
|
+
task_type=entry.text("task_type"),
|
|
319
|
+
task_type_proxyless=entry.optional_text("task_type_proxyless"),
|
|
320
|
+
aliases=entry.texts("aliases"),
|
|
321
|
+
proxy=entry.text("proxy"),
|
|
322
|
+
required=entry.groups("required"),
|
|
323
|
+
any_of=entry.groups("any_of"),
|
|
324
|
+
optional=entry.texts("optional"),
|
|
325
|
+
solution_keys=entry.texts("solution_keys"),
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
if not all(isinstance(code, str) for code in codes):
|
|
329
|
+
raise ProtocolError("listTaskTypes", "error_codes is not a list of strings")
|
|
330
|
+
return TaskTypeCatalog(task_types=tuple(task_types), error_codes=tuple(codes))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def services_from(data: dict[str, Any]) -> list[ServiceInfo]:
|
|
334
|
+
services = data.get("services")
|
|
335
|
+
if not isinstance(services, list):
|
|
336
|
+
raise ProtocolError("listServices", "data without a services array")
|
|
337
|
+
result = []
|
|
338
|
+
for raw in services:
|
|
339
|
+
entry = _Entry("listServices", raw)
|
|
340
|
+
result.append(
|
|
341
|
+
ServiceInfo(
|
|
342
|
+
id=entry.integer("id"),
|
|
343
|
+
slug=entry.text("slug"),
|
|
344
|
+
display_name=entry.text("display_name"),
|
|
345
|
+
price_per_request=entry.text("price_per_request"),
|
|
346
|
+
status=entry.text("status"),
|
|
347
|
+
avg_latency_ms=entry.optional_integer("avg_latency_ms"),
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def submit_retry_delay(poll_interval: float, random_value: float) -> float:
|
|
354
|
+
"""Wait before resubmitting after ERROR_NO_SLOT_AVAILABLE: one poll interval plus jitter."""
|
|
355
|
+
return poll_interval + math.floor(random_value * SUBMIT_RETRY_JITTER_MS) / 1000
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def is_transient_poll_error(error: SurSolverError) -> bool:
|
|
359
|
+
"""Poll failures that say nothing about the task and may be retried inside the deadline."""
|
|
360
|
+
return isinstance(error, TransportError) or (
|
|
361
|
+
isinstance(error, ApiError) and error.code == "ERROR_INTERNAL_ERROR"
|
|
362
|
+
)
|
sursolver/_errors.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Error types. Cancellation is never wrapped: it stays ``asyncio.CancelledError``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
Operation = Literal["createTask", "getTaskResult", "getBalance", "listTaskTypes", "listServices"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SurSolverError(Exception):
|
|
12
|
+
"""Base class of every error this package raises."""
|
|
13
|
+
|
|
14
|
+
operation: Operation | None
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, operation: Operation | None = None) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.operation = operation
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigurationError(SurSolverError, ValueError):
|
|
22
|
+
"""The client was built with an unusable option. Nothing was sent."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransportError(SurSolverError):
|
|
26
|
+
"""No usable answer: connection failure, timeout, a non-200 status, or a body that is not
|
|
27
|
+
the API's envelope. It says nothing about the task.
|
|
28
|
+
|
|
29
|
+
``may_have_created_task`` is true for ``create_task``: the request may have been accepted
|
|
30
|
+
and charged before the answer was lost. Check ``get_balance()`` before submitting again;
|
|
31
|
+
the API has no idempotency.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
operation: Operation,
|
|
37
|
+
reason: str,
|
|
38
|
+
*,
|
|
39
|
+
status: int | None = None,
|
|
40
|
+
server_code: str | None = None,
|
|
41
|
+
retry_after_seconds: int | float | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.may_have_created_task = operation == "createTask"
|
|
44
|
+
note = (
|
|
45
|
+
" The task may have been created and charged; check get_balance() before submitting again."
|
|
46
|
+
if self.may_have_created_task
|
|
47
|
+
else ""
|
|
48
|
+
)
|
|
49
|
+
super().__init__(f"{operation} failed: {reason}.{note}", operation)
|
|
50
|
+
self.status = status
|
|
51
|
+
self.server_code = server_code
|
|
52
|
+
self.retry_after_seconds = retry_after_seconds
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ProtocolError(SurSolverError):
|
|
56
|
+
"""HTTP 200 JSON whose shape the API contract does not allow."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, operation: Operation, detail: str) -> None:
|
|
59
|
+
super().__init__(f"{operation} returned an unexpected response: {detail}", operation)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ApiError(SurSolverError):
|
|
63
|
+
"""The API answered ``errorId: 1``. Subclasses group the codes callers act on.
|
|
64
|
+
|
|
65
|
+
``code`` is the ``errorCode`` exactly as sent (including codes newer than this package);
|
|
66
|
+
``retryable`` is true only for ``ERROR_NO_SLOT_AVAILABLE``; ``supported_user_agents`` is
|
|
67
|
+
the list sent with ``ERROR_WRONG_USERAGENT`` and empty otherwise.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
operation: Operation,
|
|
73
|
+
code: str,
|
|
74
|
+
description: str,
|
|
75
|
+
*,
|
|
76
|
+
task_id: str | None = None,
|
|
77
|
+
supported_user_agents: Iterable[str] = (),
|
|
78
|
+
) -> None:
|
|
79
|
+
task = "" if task_id is None else f" (task {task_id})"
|
|
80
|
+
super().__init__(f"{code}: {description}{task}", operation)
|
|
81
|
+
self.code = code
|
|
82
|
+
self.description = description
|
|
83
|
+
self.retryable = code == "ERROR_NO_SLOT_AVAILABLE"
|
|
84
|
+
self.task_id = task_id
|
|
85
|
+
self.supported_user_agents: tuple[str, ...] = tuple(supported_user_agents)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AuthenticationError(ApiError):
|
|
89
|
+
"""``ERROR_WRONG_CLIENT_KEY`` or ``ERROR_KEY_DOES_NOT_EXIST``: fix the API key."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class InvalidTaskError(ApiError):
|
|
93
|
+
"""``create_task`` refused the task itself: ``ERROR_INVALID_REQUEST``,
|
|
94
|
+
``ERROR_TYPE_NOT_SUPPORTED`` or ``ERROR_WRONG_USERAGENT`` (see ``supported_user_agents``).
|
|
95
|
+
Nothing was created or charged."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class InsufficientBalanceError(ApiError):
|
|
99
|
+
"""``ERROR_ZERO_BALANCE``: the balance or plan cannot cover the task. Nothing was charged."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class NoSlotAvailableError(ApiError):
|
|
103
|
+
"""``ERROR_NO_SLOT_AVAILABLE``: the account's open tasks fill its thread limit, or the
|
|
104
|
+
service is briefly not admitting work. Nothing was created; submitting later is safe."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TaskNotFoundError(ApiError):
|
|
108
|
+
"""``ERROR_TASK_NOT_FOUND``: unknown task id, or one owned by another account."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TaskFailedError(ApiError):
|
|
112
|
+
"""The task ended without a result: ``ERROR_FAILED``, ``ERROR_TASK_TIMEOUT``,
|
|
113
|
+
``ERROR_TASK_EXPIRED``, or ``ERROR_WRONG_USERAGENT`` after acceptance. Final for this
|
|
114
|
+
task; the API returns a failed task's charge once."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class SolveTimeoutError(SurSolverError):
|
|
118
|
+
"""``solve()`` reached its deadline. The task may still finish and stays charged: poll it
|
|
119
|
+
with ``get_task_result(error.task_id)`` if you still need the result. ``last_error`` is the
|
|
120
|
+
final poll's transient failure, when there was one."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, task_id: str, timeout: float, last_error: SurSolverError | None = None) -> None:
|
|
123
|
+
super().__init__(
|
|
124
|
+
f"task {task_id} was not ready within {timeout:g} s; it may still finish and stays charged",
|
|
125
|
+
"getTaskResult",
|
|
126
|
+
)
|
|
127
|
+
self.task_id = task_id
|
|
128
|
+
self.last_error = last_error
|
|
129
|
+
self.__cause__ = last_error
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_TASK_FAILURE_CODES = frozenset(
|
|
133
|
+
{"ERROR_FAILED", "ERROR_TASK_TIMEOUT", "ERROR_TASK_EXPIRED", "ERROR_WRONG_USERAGENT"}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
_CLASS_BY_CODE: dict[str, type[ApiError]] = {
|
|
137
|
+
"ERROR_WRONG_CLIENT_KEY": AuthenticationError,
|
|
138
|
+
"ERROR_KEY_DOES_NOT_EXIST": AuthenticationError,
|
|
139
|
+
"ERROR_INVALID_REQUEST": InvalidTaskError,
|
|
140
|
+
"ERROR_TYPE_NOT_SUPPORTED": InvalidTaskError,
|
|
141
|
+
"ERROR_WRONG_USERAGENT": InvalidTaskError,
|
|
142
|
+
"ERROR_ZERO_BALANCE": InsufficientBalanceError,
|
|
143
|
+
"ERROR_NO_SLOT_AVAILABLE": NoSlotAvailableError,
|
|
144
|
+
"ERROR_TASK_NOT_FOUND": TaskNotFoundError,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def api_error_for(
|
|
149
|
+
operation: Operation,
|
|
150
|
+
code: str,
|
|
151
|
+
description: str,
|
|
152
|
+
*,
|
|
153
|
+
task_id: str | None = None,
|
|
154
|
+
supported_user_agents: Iterable[str] = (),
|
|
155
|
+
) -> ApiError:
|
|
156
|
+
"""The error class for an ``errorId: 1`` answer: it depends on the operation and the code."""
|
|
157
|
+
if operation == "getTaskResult" and code in _TASK_FAILURE_CODES:
|
|
158
|
+
error_class: type[ApiError] = TaskFailedError
|
|
159
|
+
else:
|
|
160
|
+
error_class = _CLASS_BY_CODE.get(code, ApiError)
|
|
161
|
+
return error_class(
|
|
162
|
+
operation, code, description, task_id=task_id, supported_user_agents=supported_user_agents
|
|
163
|
+
)
|
sursolver/_types.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Public data types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Literal, Protocol
|
|
8
|
+
|
|
9
|
+
Task = Mapping[str, Any]
|
|
10
|
+
"""A task object: ``type`` plus that type's fields, sent verbatim. Browser task types require
|
|
11
|
+
``userAgent`` (the Windows Chrome User-Agent of the client that will use the result)."""
|
|
12
|
+
|
|
13
|
+
Solution = dict[str, Any]
|
|
14
|
+
"""A task's result. Keys differ per type (``token``, ``gRecaptchaResponse``, ``cf_clearance``,
|
|
15
|
+
``cookies``, ...) and are passed through untouched. Tokens and cookies are single-use."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class TaskResult:
|
|
20
|
+
status: Literal["processing", "ready"]
|
|
21
|
+
solution: Solution | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class SolveResult:
|
|
26
|
+
task_id: str
|
|
27
|
+
solution: Solution
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class TaskTypeInfo:
|
|
32
|
+
"""One entry of ``GET /v1/task-types``."""
|
|
33
|
+
|
|
34
|
+
page_slug: str | None
|
|
35
|
+
display_name: str
|
|
36
|
+
task_type: str
|
|
37
|
+
"""Canonical name (the proxy-capable one when a ProxyLess twin exists)."""
|
|
38
|
+
task_type_proxyless: str | None
|
|
39
|
+
aliases: tuple[str, ...]
|
|
40
|
+
proxy: str
|
|
41
|
+
"""``none``, ``optional`` or ``required``."""
|
|
42
|
+
required: tuple[tuple[str, ...], ...]
|
|
43
|
+
"""Groups of accepted spellings; supply one name per group."""
|
|
44
|
+
any_of: tuple[tuple[str, ...], ...]
|
|
45
|
+
"""Alternatives; supply every field of at least one."""
|
|
46
|
+
optional: tuple[str, ...]
|
|
47
|
+
solution_keys: tuple[str, ...]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class TaskTypeCatalog:
|
|
52
|
+
task_types: tuple[TaskTypeInfo, ...]
|
|
53
|
+
error_codes: tuple[str, ...]
|
|
54
|
+
"""The complete ``errorCode`` vocabulary of the solver endpoints."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class ServiceInfo:
|
|
59
|
+
"""One entry of ``GET /v1/services``."""
|
|
60
|
+
|
|
61
|
+
id: int
|
|
62
|
+
slug: str
|
|
63
|
+
display_name: str
|
|
64
|
+
price_per_request: str
|
|
65
|
+
"""USD price per task as a decimal string, e.g. ``"0.0006"``."""
|
|
66
|
+
status: str
|
|
67
|
+
avg_latency_ms: int | None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class HttpRequest:
|
|
72
|
+
method: Literal["GET", "POST"]
|
|
73
|
+
url: str
|
|
74
|
+
headers: Mapping[str, str]
|
|
75
|
+
body: bytes | None
|
|
76
|
+
timeout: float
|
|
77
|
+
"""Seconds."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class HttpResponse:
|
|
82
|
+
status: int
|
|
83
|
+
body: bytes
|
|
84
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
Transport = Callable[[HttpRequest], HttpResponse]
|
|
88
|
+
"""Sends one request. Return any status; raise only when no response arrived. Must not follow
|
|
89
|
+
redirects."""
|
|
90
|
+
|
|
91
|
+
AsyncTransport = Callable[[HttpRequest], Awaitable[HttpResponse]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Clock(Protocol):
|
|
95
|
+
def now(self) -> float:
|
|
96
|
+
"""Seconds; only differences are used."""
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
def sleep(self, seconds: float) -> None: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AsyncClock(Protocol):
|
|
103
|
+
def now(self) -> float: ...
|
|
104
|
+
|
|
105
|
+
async def sleep(self, seconds: float) -> None: ...
|
sursolver/_version.py
ADDED
sursolver/py.typed
ADDED
|
File without changes
|