scalebrowser 0.2.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.
scalebrowser/errors.py ADDED
@@ -0,0 +1,97 @@
1
+ """Typed SDK errors and the daemon's stable numeric error codes.
2
+
3
+ The daemon returns error bodies shaped ``{ "code": <4001-4010>, "message": "…" }``
4
+ with the HTTP status from ``core::Error::http_status()`` (docs/features/api.md, SPEC §5).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Final, Optional
10
+
11
+
12
+ class ErrorCode:
13
+ """Stable numeric API error codes (SPEC §5, ``crates/core/src/error.rs``)."""
14
+
15
+ NOT_FOUND: Final = 4001
16
+ PROXY_GEO: Final = 4002
17
+ CAPACITY: Final = 4003
18
+ ENGINE_MISSING: Final = 4004
19
+ PREFLIGHT: Final = 4005
20
+ ALREADY_RUNNING: Final = 4006
21
+ # Retired: the removed Methode-D (VM) tier produced this. Kept reserved (never
22
+ # reused) to match the server's append-only code contract; never returned now.
23
+ VM_PROVISION_FAILED: Final = 4007
24
+ # The subscription's abo-wide concurrent-browser allowance is used up (DEC-012).
25
+ # Distinct from CAPACITY (4003 = this machine is full, wait): the caller's move
26
+ # is to stop a running browser anywhere or upgrade the plan.
27
+ CONCURRENCY_LIMIT: Final = 4008
28
+ UNAUTHORIZED: Final = 4010
29
+
30
+
31
+ _CODE_LABELS: Final[dict[int, str]] = {
32
+ 4001: "Not found",
33
+ 4002: "Proxy or geo check failed",
34
+ 4003: "Capacity exceeded",
35
+ 4004: "Engine missing",
36
+ 4005: "Launch preflight failed",
37
+ 4006: "Profile already running",
38
+ 4008: "Concurrent-browser limit reached (subscription-wide)",
39
+ 4010: "Unauthorized",
40
+ }
41
+
42
+
43
+ def code_label(code: Optional[int]) -> Optional[str]:
44
+ """Human-readable label for a coded business error, if known."""
45
+ return _CODE_LABELS.get(code) if code is not None else None
46
+
47
+
48
+ class ScalebrowserError(Exception):
49
+ """Base class for every error raised by this SDK."""
50
+
51
+
52
+ class ApiError(ScalebrowserError):
53
+ """A transport/contract error from the daemon REST API.
54
+
55
+ Carries the HTTP ``status`` and the daemon's numeric ``code`` so callers can
56
+ branch on auth failures or specific business errors (4001-4010).
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ status: int,
62
+ code: Optional[int] = None,
63
+ message: Optional[str] = None,
64
+ body: Optional[dict[str, Any]] = None,
65
+ ) -> None:
66
+ self.status = status
67
+ self.code = code
68
+ self.body = body
69
+ label = code_label(code)
70
+ super().__init__(message or label or f"Request failed (HTTP {status})")
71
+
72
+ @property
73
+ def is_auth_error(self) -> bool:
74
+ """True for auth failures that should trigger re-authentication."""
75
+ return self.status == 401 or self.code == ErrorCode.UNAUTHORIZED
76
+
77
+ @property
78
+ def code_label(self) -> Optional[str]:
79
+ """Short label for the error code, if any."""
80
+ return code_label(self.code)
81
+
82
+
83
+ class NetworkError(ScalebrowserError):
84
+ """A network-level failure — the daemon was unreachable (no HTTP response)."""
85
+
86
+ def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
87
+ super().__init__(message)
88
+ self.cause = cause
89
+
90
+
91
+ class CdpError(ScalebrowserError):
92
+ """An error returned by the browser over the direct-CDP channel."""
93
+
94
+ def __init__(self, code: Optional[int], message: str, data: Any = None) -> None:
95
+ self.code = code
96
+ self.data = data
97
+ super().__init__(f"CDP error {code}: {message}" if code is not None else f"CDP error: {message}")
scalebrowser/events.py ADDED
@@ -0,0 +1,52 @@
1
+ """Live lifecycle event stream (``GET /v1/events``, AC-API-003).
2
+
3
+ We read the ``text/event-stream`` over the authenticated transport and frame it
4
+ into the typed :data:`~scalebrowser.models.Event` union, mirroring the behaviour
5
+ of ``web-ui/src/api/events.ts``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import AsyncIterator, Optional
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from ._http import AsyncTransport
15
+ from .models import EVENT_ADAPTER, Event
16
+
17
+ EVENTS_PATH = "/v1/events"
18
+
19
+
20
+ def parse_sse_frame(frame: str) -> Optional[Event]:
21
+ """Extract and validate the ``data:`` payload of a single SSE frame."""
22
+ data_lines: list[str] = []
23
+ for line in frame.splitlines():
24
+ if line.startswith("data:"):
25
+ data_lines.append(line[5:].lstrip(" "))
26
+ if not data_lines:
27
+ return None
28
+ payload = "\n".join(data_lines)
29
+ if not payload or payload == "[DONE]":
30
+ return None
31
+ try:
32
+ return EVENT_ADAPTER.validate_json(payload)
33
+ except (ValidationError, ValueError):
34
+ return None
35
+
36
+
37
+ async def iter_events(transport: AsyncTransport, path: str = EVENTS_PATH) -> AsyncIterator[Event]:
38
+ """Stream typed lifecycle events until the connection ends."""
39
+ frame_lines: list[str] = []
40
+ async for line in transport.stream_sse(path):
41
+ if line == "":
42
+ if frame_lines:
43
+ event = parse_sse_frame("\n".join(frame_lines))
44
+ frame_lines = []
45
+ if event is not None:
46
+ yield event
47
+ else:
48
+ frame_lines.append(line)
49
+ if frame_lines:
50
+ event = parse_sse_frame("\n".join(frame_lines))
51
+ if event is not None:
52
+ yield event