robovue-sdk 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.
@@ -0,0 +1,13 @@
1
+ from .client import RobovueClient
2
+ from .exceptions import RobovueApiException
3
+ from .models import FailureCategory, RunReport, RunReportOutcome, RunReportResult, RunStatus
4
+
5
+ __all__ = [
6
+ "RobovueClient",
7
+ "RunReport",
8
+ "RunReportResult",
9
+ "RunReportOutcome",
10
+ "RunStatus",
11
+ "FailureCategory",
12
+ "RobovueApiException",
13
+ ]
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+
6
+
7
+ RUN_PURPOSE = "run"
8
+ ATTACHMENT_PURPOSE = "attachment"
9
+
10
+
11
+ def compute_v1(secret: str, timestamp: str, purpose: str, subject: str, raw_body: bytes) -> str:
12
+ """The v1 request signature (ADR 0161).
13
+
14
+ HMAC-SHA256, keyed with the UTF-8-encoded secret, of ``{timestamp}\\n{purpose}\\n{subject}\\n``
15
+ (ASCII) followed by the raw body bytes, hex-encoded in lowercase and prefixed with "v1=". The
16
+ purpose is "run" or "attachment"; the subject is the delivery id for a run report and the run id
17
+ (lowercase, hyphenated) for an attachment.
18
+
19
+ An independent reimplementation of the server's WebhookSignature.ComputeV1 and Robovue.Sdk's
20
+ SignatureCalculator.ComputeV1 — all three are tested against sdks/contract/signature-vectors.json.
21
+ """
22
+ message = f"{timestamp}\n{purpose}\n{subject}\n".encode("ascii") + raw_body
23
+ digest = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
24
+ return f"v1={digest}"
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import urllib.error
5
+ import urllib.request
6
+ import email.utils
7
+ from typing import Any, Callable, Dict, Mapping, Optional, Tuple, Union
8
+
9
+ # (url, headers, body) -> (status_code, response_body_bytes[, response_headers]).
10
+ # Raises OSError (or a subclass, e.g. urllib.error.URLError/socket.timeout)
11
+ # for a network-level failure — never for a real HTTP response, even a 4xx/5xx one.
12
+ # The third element is optional so existing custom transports keep working; without it a 429's
13
+ # Retry-After can't be read and the normal backoff applies instead.
14
+ Transport = Callable[[str, Dict[str, str], bytes], Tuple[Any, ...]]
15
+
16
+ #: The longest Retry-After worth waiting for inside a bot's run. A server asking for longer is
17
+ #: answered with the 429 itself, so the caller decides - a bot blocked for minutes on its
18
+ #: monitoring is worse than a report that arrives late or not at all.
19
+ MAX_RETRY_AFTER_SECONDS = 60.0
20
+
21
+
22
+ def retry_after_seconds(headers: Optional[Mapping[str, str]], now: Optional[float] = None) -> Optional[float]:
23
+ """How long a 429 asks to wait: Retry-After as seconds or as an HTTP date, or None."""
24
+ if not headers:
25
+ return None
26
+ value = next((v for k, v in headers.items() if k.lower() == "retry-after"), None)
27
+ if value is None:
28
+ return None
29
+ value = value.strip()
30
+ if value.isdigit():
31
+ return float(value)
32
+ try:
33
+ when = email.utils.parsedate_to_datetime(value)
34
+ except (TypeError, ValueError):
35
+ return None
36
+ if when is None:
37
+ return None
38
+ current = time.time() if now is None else now
39
+ return max(0.0, when.timestamp() - current)
40
+
41
+
42
+ def urllib_transport(url: str, headers: Dict[str, str], body: bytes) -> Tuple[int, bytes]:
43
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
44
+ try:
45
+ with urllib.request.urlopen(request, timeout=30) as response:
46
+ return response.status, response.read(), dict(response.headers.items())
47
+ except urllib.error.HTTPError as error:
48
+ # urlopen raises for any non-2xx status; treat it as a normal response
49
+ # so the retry loop can apply the same status-code policy either way.
50
+ return error.code, error.read(), dict(error.headers.items()) if error.headers else {}
51
+
52
+
53
+ def send_with_retry(
54
+ transport: Transport,
55
+ url: str,
56
+ headers: Union[Dict[str, str], Callable[[], Dict[str, str]]],
57
+ body: bytes,
58
+ max_attempts: int,
59
+ base_delay: float,
60
+ sleep: Callable[[float], None] = time.sleep,
61
+ ) -> Tuple[int, bytes]:
62
+ """Mirrors Robovue.Sdk's RetryingRequestSender exactly.
63
+
64
+ Most 4xx responses never retry. A 429 retries after its Retry-After (seconds or an HTTP date),
65
+ unless that's longer than MAX_RETRY_AFTER_SECONDS. 5xx responses retry until attempts are
66
+ exhausted. A network-level failure retries the same way, and — unlike a
67
+ 5xx — is re-raised as-is (not wrapped) once attempts are exhausted.
68
+
69
+ ``headers`` may be a callable, called once per attempt: signed requests (ADR 0161) carry a
70
+ timestamp the server rejects after five minutes, so each attempt is signed afresh.
71
+ """
72
+ for attempt in range(1, max_attempts + 1):
73
+ attempt_headers = headers() if callable(headers) else headers
74
+ try:
75
+ result = transport(url, attempt_headers, body)
76
+ except OSError:
77
+ if attempt < max_attempts:
78
+ sleep(base_delay * (2 ** (attempt - 1)))
79
+ continue
80
+ raise
81
+ status, response_body = result[0], result[1]
82
+ response_headers = result[2] if len(result) > 2 else None
83
+ if attempt == max_attempts:
84
+ return status, response_body, response_headers
85
+ if status == 429:
86
+ wait = retry_after_seconds(response_headers)
87
+ if wait is None:
88
+ wait = base_delay * (2 ** (attempt - 1))
89
+ if wait > MAX_RETRY_AFTER_SECONDS:
90
+ return status, response_body, response_headers
91
+ sleep(wait)
92
+ continue
93
+ if status < 500:
94
+ return status, response_body, response_headers
95
+ sleep(base_delay * (2 ** (attempt - 1)))
96
+
97
+ raise AssertionError("unreachable") # pragma: no cover
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("robovue-sdk")
7
+ except PackageNotFoundError: # running from a source checkout, not an installed package
8
+ __version__ = "0.0.0+local"
9
+
10
+ # Sent on every request. Robovue's edge (Cloudflare) blocks urllib's default
11
+ # "Python-urllib/x.y" User-Agent outright with a 403 before the request ever reaches the
12
+ # API, so this is not cosmetic - without it the SDK cannot talk to production at all, and
13
+ # because a 403 is a 4xx it would never be retried.
14
+ USER_AGENT = f"robovue-sdk-python/{__version__}"
robovue_sdk/client.py ADDED
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Callable, Dict, Optional
8
+
9
+ from . import _signature
10
+ from ._transport import Transport, send_with_retry, urllib_transport
11
+ from ._version import USER_AGENT
12
+ from .exceptions import RobovueApiException
13
+ from .models import FailureCategory, RunReport, RunReportOutcome, RunReportResult, RunStatus
14
+
15
+ # The server's limits (the Connect page's contract reference, ADR 0160), checked here so a mistake
16
+ # fails locally with a clear message instead of costing a round trip and a 400.
17
+ _MAX_BOT_ID_LENGTH = 200
18
+ _MAX_PLATFORM_LENGTH = 50
19
+ _MAX_DELIVERY_ID_LENGTH = 200
20
+ _MAX_REPORT_BYTES = 64 * 1024
21
+ _EARLIEST = datetime(2000, 1, 1, tzinfo=timezone.utc)
22
+
23
+
24
+ def _format_datetime(value: datetime) -> str:
25
+ # This app has no per-user timezone preference anywhere (server-side
26
+ # timestamps are UTC throughout) — a naive datetime is treated as
27
+ # already being UTC rather than rejected, matching that same convention.
28
+ if value.tzinfo is None:
29
+ value = value.replace(tzinfo=timezone.utc)
30
+ return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
31
+
32
+
33
+ def _serialize(report: RunReport) -> bytes:
34
+ status_value = report.status.value if isinstance(report.status, RunStatus) else report.status
35
+ obj: dict[str, Any] = {
36
+ "botId": report.bot_id,
37
+ "platform": report.platform,
38
+ "status": status_value,
39
+ "startedAt": _format_datetime(report.started_at),
40
+ }
41
+ # Omitted entirely rather than sent as null when the run is in flight (ADR 0072), which makes a
42
+ # start ping a strict subset of the terminal shape and satisfies the server's "completedAt must
43
+ # be omitted when status is Running" rule by construction. Same treatment payload and
44
+ # failure_category already get below.
45
+ if report.completed_at is not None:
46
+ obj["completedAt"] = _format_datetime(report.completed_at)
47
+ if report.payload is not None:
48
+ obj["payload"] = report.payload
49
+ if report.failure_category is not None:
50
+ category_value = (
51
+ report.failure_category.value
52
+ if isinstance(report.failure_category, FailureCategory)
53
+ else report.failure_category
54
+ )
55
+ obj["failureCategory"] = category_value
56
+ # Compact, no whitespace - the exact bytes produced here are what gets
57
+ # signed, so any serializer choice is self-consistent as long as it's
58
+ # applied before signing and never re-applied afterward.
59
+ # allow_nan=False: NaN and Infinity aren't JSON, and the server rejects the whole body for them.
60
+ try:
61
+ encoded = json.dumps(obj, separators=(",", ":"), allow_nan=False).encode("utf-8")
62
+ except ValueError as exc:
63
+ raise ValueError("payload can't contain NaN or Infinity - they aren't valid JSON") from exc
64
+ if len(encoded) > _MAX_REPORT_BYTES:
65
+ raise ValueError(f"the report is {len(encoded)} bytes; the limit is {_MAX_REPORT_BYTES} - make payload smaller")
66
+ return encoded
67
+
68
+
69
+ def _as_utc(value: datetime) -> datetime:
70
+ # The same rule _format_datetime applies: a naive datetime is UTC. Comparing through this avoids
71
+ # Python's TypeError on a naive/aware mix.
72
+ return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
73
+
74
+
75
+ def _validate_name(value: Any, name: str, max_length: int) -> None:
76
+ if not isinstance(value, str) or not value.strip():
77
+ raise ValueError(f"{name} is required")
78
+ if len(value) > max_length:
79
+ raise ValueError(f"{name} must be {max_length} characters or fewer")
80
+ if value != value.strip():
81
+ raise ValueError(f"{name} must not start or end with whitespace")
82
+ if any(ord(c) < 32 or 127 <= ord(c) < 160 for c in value):
83
+ raise ValueError(f"{name} must not contain control characters")
84
+
85
+
86
+ def _validate(report: RunReport) -> None:
87
+ if report is None:
88
+ raise ValueError("report is required")
89
+ _validate_name(report.bot_id, "bot_id", _MAX_BOT_ID_LENGTH)
90
+ # A bot id is part of the Management API's URLs, so it can't hold a path separator (server ADR 0166).
91
+ if "/" in report.bot_id or "\\" in report.bot_id:
92
+ raise ValueError("bot_id must not contain / or \\")
93
+ if report.bot_id in (".", ".."):
94
+ raise ValueError('bot_id must not be "." or ".."')
95
+ _validate_name(report.platform, "platform", _MAX_PLATFORM_LENGTH)
96
+
97
+ status = report.status.value if isinstance(report.status, RunStatus) else report.status
98
+ if status not in {s.value for s in RunStatus}:
99
+ raise ValueError(f"status must be one of: {', '.join(s.value for s in RunStatus)}")
100
+
101
+ if not isinstance(report.started_at, datetime):
102
+ raise ValueError("started_at is required")
103
+ started = _as_utc(report.started_at)
104
+ if started < _EARLIEST:
105
+ raise ValueError("started_at can't be before 2000-01-01")
106
+
107
+ if report.failure_category is not None:
108
+ category = report.failure_category.value if isinstance(report.failure_category, FailureCategory) else report.failure_category
109
+ if category not in {c.value for c in FailureCategory}:
110
+ raise ValueError(f"failure_category must be one of: {', '.join(c.value for c in FailureCategory)}")
111
+ if status != RunStatus.FAILURE.value:
112
+ raise ValueError("failure_category can only be set when status is FAILURE")
113
+
114
+ if report.payload is not None and not isinstance(report.payload, dict):
115
+ raise ValueError("payload must be a dict (a JSON object)")
116
+
117
+ if status == RunStatus.RUNNING.value:
118
+ if report.completed_at is not None:
119
+ raise ValueError("completed_at must be None when status is RUNNING")
120
+ return
121
+
122
+ if report.completed_at is None:
123
+ raise ValueError("completed_at is required unless status is RUNNING")
124
+ if _as_utc(report.completed_at) < started:
125
+ raise ValueError("completed_at must be >= started_at")
126
+
127
+
128
+ def _error_from(status: int, response_body: bytes, response_headers: Optional[Dict[str, str]]) -> RobovueApiException:
129
+ parsed: Dict[str, Any] = {}
130
+ try:
131
+ decoded = json.loads(response_body)
132
+ if isinstance(decoded, dict):
133
+ parsed = decoded
134
+ except (json.JSONDecodeError, UnicodeDecodeError):
135
+ pass # Not the error envelope - e.g. a proxy's HTML page.
136
+ request_id = parsed.get("requestId")
137
+ if request_id is None and response_headers:
138
+ request_id = next((v for k, v in response_headers.items() if k.lower() == "x-request-id"), None)
139
+ return RobovueApiException(status, parsed.get("error"), parsed.get("code"), parsed.get("field"), request_id)
140
+
141
+
142
+ class RobovueClient:
143
+ """Client for Robovue's generic ingestion webhook (POST /ingest/{tenantId}/runs)."""
144
+
145
+ def __init__(
146
+ self,
147
+ base_url: str,
148
+ tenant_id: str,
149
+ webhook_secret: str,
150
+ *,
151
+ max_retry_attempts: int = 3,
152
+ retry_base_delay: float = 0.2,
153
+ transport: Transport = urllib_transport,
154
+ clock: Callable[[], float] = time.time,
155
+ dry_run: bool = False,
156
+ ) -> None:
157
+ """``dry_run=True`` sends every request as a dry run (``X-Robovue-Dry-Run: true``): Robovue
158
+ authenticates and validates it exactly as normal but stores nothing, and a report's result
159
+ says what ``would_be``. A request that would fail still raises the real error. For checking
160
+ a new integration - leave it off in production, or no runs are recorded."""
161
+ if not base_url:
162
+ raise ValueError("base_url is required")
163
+ if not tenant_id:
164
+ raise ValueError("tenant_id is required")
165
+ if not webhook_secret:
166
+ raise ValueError("webhook_secret is required")
167
+ if max_retry_attempts < 1:
168
+ raise ValueError("max_retry_attempts must be >= 1")
169
+
170
+ self._ingest_base_url = f"{str(base_url).rstrip('/')}/ingest/{tenant_id}"
171
+ self._url = f"{self._ingest_base_url}/runs"
172
+ self._webhook_secret = webhook_secret
173
+ self._max_retry_attempts = max_retry_attempts
174
+ self._retry_base_delay = retry_base_delay
175
+ self._transport = transport
176
+ # Seconds since the epoch; replaceable so tests can pin a signature and prove retries re-sign.
177
+ self._clock = clock
178
+ self._dry_run = dry_run
179
+
180
+ def _signed_headers(self, base: Dict[str, str], purpose: str, subject: str, body: bytes) -> Callable[[], Dict[str, str]]:
181
+ """Headers for one attempt, signed with the current time (ADR 0161)."""
182
+
183
+ def build() -> Dict[str, str]:
184
+ timestamp = str(int(self._clock()))
185
+ headers = dict(base)
186
+ headers["X-Robovue-Timestamp"] = timestamp
187
+ headers["X-Robovue-Signature"] = _signature.compute_v1(self._webhook_secret, timestamp, purpose, subject, body)
188
+ if self._dry_run:
189
+ headers["X-Robovue-Dry-Run"] = "true"
190
+ return headers
191
+
192
+ return build
193
+
194
+ def report_run(self, report: RunReport, delivery_id: Optional[str] = None) -> RunReportResult:
195
+ _validate(report)
196
+
197
+ body = _serialize(report)
198
+ effective_delivery_id = (
199
+ delivery_id if delivery_id and delivery_id.strip() else str(uuid.uuid4())
200
+ )
201
+ # Checked here rather than left to the server: a proxy or the server trims header whitespace,
202
+ # so an id with a space at either end would be signed one way and verified another.
203
+ if any(not ("!" <= c <= "~") for c in effective_delivery_id):
204
+ raise ValueError("delivery_id may only contain printable ASCII characters, with no spaces")
205
+ if len(effective_delivery_id) > _MAX_DELIVERY_ID_LENGTH:
206
+ raise ValueError(f"delivery_id must be {_MAX_DELIVERY_ID_LENGTH} characters or fewer")
207
+
208
+ headers = self._signed_headers(
209
+ {
210
+ "Content-Type": "application/json",
211
+ "User-Agent": USER_AGENT,
212
+ "X-Robovue-Delivery-Id": effective_delivery_id,
213
+ },
214
+ _signature.RUN_PURPOSE,
215
+ effective_delivery_id,
216
+ body,
217
+ )
218
+
219
+ status, response_body, response_headers = send_with_retry(
220
+ self._transport,
221
+ self._url,
222
+ headers,
223
+ body,
224
+ self._max_retry_attempts,
225
+ self._retry_base_delay,
226
+ )
227
+
228
+ if status in (200, 201):
229
+ try:
230
+ parsed = json.loads(response_body)
231
+ if parsed.get("dryRun") is True:
232
+ would_be = parsed.get("wouldBe")
233
+ return RunReportResult(
234
+ run_id="",
235
+ duplicate=would_be == "duplicate",
236
+ delivery_id=effective_delivery_id,
237
+ dry_run=True,
238
+ would_be=would_be,
239
+ )
240
+ return RunReportResult(
241
+ run_id=str(parsed["runId"]),
242
+ duplicate=bool(parsed["duplicate"]),
243
+ delivery_id=effective_delivery_id,
244
+ )
245
+ except (json.JSONDecodeError, KeyError, TypeError, UnicodeDecodeError) as exc:
246
+ raise RobovueApiException(status, "response body could not be parsed") from exc
247
+
248
+ raise _error_from(status, response_body, response_headers)
249
+
250
+ def try_report_run(self, report: RunReport, delivery_id: Optional[str] = None) -> RunReportOutcome:
251
+ """Reports a run without ever raising - for bots where monitoring must not become a new way
252
+ to fail. Any problem (an invalid report, a rejection, a network failure) comes back in the
253
+ outcome's ``error``; log it."""
254
+ try:
255
+ return RunReportOutcome(result=self.report_run(report, delivery_id))
256
+ except Exception as exc: # noqa: BLE001 - deliberately broad: the promise is that reporting can't break the bot
257
+ return RunReportOutcome(error=exc)
258
+
259
+ # 5MB (ADR 0045) — matches the server's own cap exactly; checked here before any HTTP call so
260
+ # an oversized upload fails fast with a clear local exception instead of a round trip.
261
+ _MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024
262
+
263
+ def upload_attachment(self, run_id: str, content: bytes, content_type: str) -> None:
264
+ """Attaches a screenshot or log excerpt to a failed run.
265
+
266
+ Only accepted for a run whose status is Failure, on a bot with attachments turned on
267
+ (Manage bots, then Screenshots, in the dashboard) — off by default, since a
268
+ screenshot can capture on-screen credentials or other sensitive data. content_type
269
+ must be one of "image/png", "image/jpeg", "text/plain"; content must be 5MB or
270
+ smaller (checked here before any HTTP call, and again server-side). Retries network
271
+ errors and 5xx responses automatically; raises RobovueApiException for any other
272
+ non-success response.
273
+ """
274
+ if not content:
275
+ raise ValueError("content is required")
276
+ if len(content) > self._MAX_ATTACHMENT_BYTES:
277
+ raise ValueError(f"content must be {self._MAX_ATTACHMENT_BYTES} bytes or fewer")
278
+ if not content_type or not content_type.strip():
279
+ raise ValueError("content_type is required")
280
+
281
+ # Canonicalised once and used for both the URL and the signature: the server parses the id
282
+ # from the URL and signs its lowercase hyphenated form, so a braced or unhyphenated id sent
283
+ # as-is would be signed differently from how it's verified.
284
+ try:
285
+ canonical_run_id = str(uuid.UUID(str(run_id)))
286
+ except ValueError as exc:
287
+ raise ValueError("run_id must be a run id (a UUID) returned by report_run") from exc
288
+ url = f"{self._ingest_base_url}/runs/{canonical_run_id}/attachment"
289
+ headers = self._signed_headers(
290
+ {"Content-Type": content_type, "User-Agent": USER_AGENT},
291
+ _signature.ATTACHMENT_PURPOSE,
292
+ canonical_run_id,
293
+ content,
294
+ )
295
+
296
+ status, response_body, response_headers = send_with_retry(
297
+ self._transport,
298
+ url,
299
+ headers,
300
+ content,
301
+ self._max_retry_attempts,
302
+ self._retry_base_delay,
303
+ )
304
+
305
+ if 200 <= status < 300:
306
+ return
307
+
308
+ raise _error_from(status, response_body, response_headers)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class RobovueApiException(Exception):
7
+ """Raised for any non-2xx (or unparseable-2xx) response from the ingestion endpoint.
8
+
9
+ Branch on ``code``, which is stable, rather than on the message, which is for people. The full
10
+ list of codes, with what to do about each, is on the Connect page and in the README.
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ status_code: int,
16
+ server_error_message: Optional[str] = None,
17
+ code: Optional[str] = None,
18
+ field: Optional[str] = None,
19
+ request_id: Optional[str] = None,
20
+ ) -> None:
21
+ self.status_code = status_code
22
+ self.server_error_message = server_error_message
23
+ #: The stable error code, e.g. "invalid_field" or "timestamp_out_of_range".
24
+ self.code = code
25
+ #: The request field the error is about, when there is exactly one.
26
+ self.field = field
27
+ #: The request's id - quote it if you contact support.
28
+ self.request_id = request_id
29
+
30
+ message = f"Robovue ingestion endpoint returned {status_code}"
31
+ if code:
32
+ message += f" ({code})"
33
+ if server_error_message:
34
+ message += f": {server_error_message}"
35
+ if request_id:
36
+ message += f" [request {request_id}]"
37
+ super().__init__(message + ".")
robovue_sdk/models.py ADDED
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Any, Optional
7
+
8
+
9
+ class RunStatus(str, Enum):
10
+ """Serializes to the literal wire value Robovue expects — not camelCased."""
11
+
12
+ SUCCESS = "Success"
13
+ FAILURE = "Failure"
14
+
15
+ #: The run has started and has not finished. Report it with ``completed_at`` left as None,
16
+ #: then report the same run again with its real outcome and completion time, reusing the SAME
17
+ #: delivery id - that is what tells Robovue the two calls are one run rather than two.
18
+ RUNNING = "Running"
19
+
20
+
21
+ class FailureCategory(str, Enum):
22
+ """Why a run failed, if known — optional, only meaningful when status is FAILURE. Robovue
23
+ defaults to UNCATEGORIZED when omitted; a run can always be reclassified later from its
24
+ detail page."""
25
+
26
+ UNCATEGORIZED = "Uncategorized"
27
+ BUSINESS_EXCEPTION = "BusinessException"
28
+ APPLICATION_EXCEPTION = "ApplicationException"
29
+ TIMEOUT = "Timeout"
30
+ AUTHENTICATION_ERROR = "AuthenticationError"
31
+ CANCELLED = "Cancelled"
32
+
33
+
34
+ @dataclass
35
+ class RunReport:
36
+ bot_id: str
37
+ platform: str
38
+ status: RunStatus
39
+ started_at: datetime
40
+
41
+ #: None while the run is in flight, i.e. status RUNNING (ADR 0072). Gaining a default does not
42
+ #: move it: the two fields below it already had defaults, so positional construction of every
43
+ #: existing call site is unchanged.
44
+ completed_at: Optional[datetime] = None
45
+ payload: Optional[Any] = None
46
+ failure_category: Optional[FailureCategory] = None
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class RunReportResult:
51
+ run_id: str
52
+ duplicate: bool
53
+
54
+ #: The delivery id the report was sent with - the one you passed, or the one generated for you.
55
+ #: Pass it to the call that completes a RUNNING run: reusing it is what makes the two calls one run.
56
+ delivery_id: str = ""
57
+
58
+ #: True when the report was sent as a dry run (``dry_run=True`` on the client) and nothing was
59
+ #: stored. ``run_id`` is then empty.
60
+ dry_run: bool = False
61
+
62
+ #: For a dry run, what the report would have done: "created", "completed" (it completes a
63
+ #: RUNNING run) or "duplicate". None otherwise.
64
+ would_be: Optional[str] = None
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class RunReportOutcome:
69
+ """What ``try_report_run`` returns: the result, or why the report failed - never an exception,
70
+ so a monitoring problem can't break the bot it's monitoring."""
71
+
72
+ result: Optional[RunReportResult] = None
73
+
74
+ #: A ValueError for an invalid report, a RobovueApiException for a rejection, or a network
75
+ #: error (OSError). Log it.
76
+ error: Optional[BaseException] = None
77
+
78
+ @property
79
+ def succeeded(self) -> bool:
80
+ return self.result is not None
robovue_sdk/py.typed ADDED
File without changes
@@ -0,0 +1,429 @@
1
+ Metadata-Version: 2.5
2
+ Name: robovue-sdk
3
+ Version: 0.2.0
4
+ Summary: Report RPA and scripted bot runs to Robovue: signed requests, retries, checks before sending and dry runs.
5
+ Project-URL: Homepage, https://robovue.co.uk
6
+ Project-URL: Documentation, https://robovue.co.uk/api
7
+ Author: Robovue
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: automation,bots,monitoring,robovue,rpa,webhook
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: System :: Monitoring
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # robovue-sdk
29
+
30
+ Report your bots' runs to [Robovue](https://robovue.co.uk), which watches every bot in one place and
31
+ tells you when one fails or doesn't run. This package signs each report, retries when the network
32
+ or Robovue has a problem, checks a report against Robovue's rules before sending it, and can send
33
+ dry runs while you set it up. It uses only Python's standard library.
34
+
35
+ ## Install
36
+
37
+ ```shell
38
+ pip install robovue-sdk
39
+ ```
40
+
41
+ ## Requirements
42
+
43
+ - Python 3.9 or later, on any operating system. No other packages.
44
+ - A Robovue workspace. Signing up is free.
45
+
46
+ Can't install a package where your bot runs, such as Power Automate Desktop's built-in Python?
47
+ Connections → HTTP API in Robovue gives you the same request as plain Python, C# and shell code to
48
+ paste in, and the [wire contract](#the-wire-contract-any-language) below describes it for any
49
+ language.
50
+
51
+ ## Quickstart
52
+
53
+ 1. **Sign up** at [robovue.co.uk](https://robovue.co.uk).
54
+ 2. **Copy your credentials** from **Settings → Connections → HTTP API**: the webhook secret, and
55
+ your tenant ID, which is the long id in the address shown there
56
+ (`https://app.robovue.co.uk/ingest/<tenant ID>/runs`).
57
+ 3. **Keep them out of your code.** Set them as environment variables on the machine that runs the
58
+ bot, `ROBOVUE_TENANT_ID` and `ROBOVUE_WEBHOOK_SECRET`, or in your platform's credential store,
59
+ and create one client:
60
+
61
+ <!-- sample:begin imports create-client -->
62
+ ```python
63
+ import os
64
+ import uuid
65
+ from datetime import datetime, timezone
66
+
67
+ from robovue_sdk import FailureCategory, RobovueApiException, RobovueClient, RunReport, RunStatus
68
+
69
+ # One client for the whole process: it's safe to share between threads.
70
+ robovue = RobovueClient(
71
+ base_url="https://app.robovue.co.uk",
72
+ tenant_id=os.environ["ROBOVUE_TENANT_ID"],
73
+ webhook_secret=os.environ["ROBOVUE_WEBHOOK_SECRET"],
74
+ )
75
+ ```
76
+ <!-- sample:end -->
77
+
78
+ 4. **Wrap your bot's job.** Report the run as `RUNNING` when it starts, then as `SUCCESS` or
79
+ `FAILURE` when it ends, with the same delivery id so Robovue knows it's one run:
80
+
81
+ <!-- sample:begin quickstart -->
82
+ ```python
83
+ delivery_id = str(uuid.uuid4()) # one per run: it ties the run's reports together
84
+ started_at = datetime.now(timezone.utc)
85
+
86
+ robovue.try_report_run(
87
+ RunReport(
88
+ bot_id="invoice-bot-01",
89
+ platform="python",
90
+ status=RunStatus.RUNNING,
91
+ started_at=started_at,
92
+ ),
93
+ delivery_id=delivery_id,
94
+ )
95
+
96
+ try:
97
+ process_invoices() # your bot's work
98
+ except Exception as exc:
99
+ robovue.try_report_run(
100
+ RunReport(
101
+ bot_id="invoice-bot-01",
102
+ platform="python",
103
+ status=RunStatus.FAILURE,
104
+ started_at=started_at,
105
+ completed_at=datetime.now(timezone.utc),
106
+ failure_category=FailureCategory.APPLICATION_EXCEPTION,
107
+ payload={"error": str(exc)},
108
+ ),
109
+ delivery_id=delivery_id,
110
+ )
111
+ raise
112
+
113
+ robovue.try_report_run(
114
+ RunReport(
115
+ bot_id="invoice-bot-01",
116
+ platform="python",
117
+ status=RunStatus.SUCCESS,
118
+ started_at=started_at,
119
+ completed_at=datetime.now(timezone.utc),
120
+ ),
121
+ delivery_id=delivery_id,
122
+ )
123
+ ```
124
+ <!-- sample:end -->
125
+
126
+ `try_report_run` never raises, so a problem reaching Robovue can't break your bot. The job's own
127
+ exception is reported, then raised again, so the bot still fails the way it always did.
128
+
129
+ 5. **Check it arrives.** Run the bot once with [`dry_run`](#checking-a-new-integration-with-a-dry-run)
130
+ on, then for real, and watch the report land in the Verify step on Connections → HTTP API, in the
131
+ request log, and on Fleet.
132
+
133
+ ## Make missed runs alert
134
+
135
+ A failure alerts as soon as it's reported. A run that never happens can only be noticed if Robovue
136
+ knows when to expect it, so in **Manage bots**, register the bot (its id is `bot_id`) and give it a
137
+ schedule. Until a bot is registered, its runs appear under **Unregistered ids**, and each new id
138
+ counts toward your plan's bot limit.
139
+
140
+ ## Reporting in more detail
141
+
142
+ ### Failure categories
143
+
144
+ On a `FAILURE`, set `failure_category` to say why it failed. It's shown on the run, filterable on
145
+ Runs, and named in the alert. Leave it out if you don't know: Robovue records `Uncategorized`, and a
146
+ run can be reclassified later from its page.
147
+
148
+ <!-- sample:begin failure-category -->
149
+ ```python
150
+ result = robovue.report_run(
151
+ RunReport(
152
+ bot_id="invoice-bot-01",
153
+ platform="python",
154
+ status=RunStatus.FAILURE,
155
+ started_at=started_at,
156
+ completed_at=datetime.now(timezone.utc),
157
+ failure_category=FailureCategory.BUSINESS_EXCEPTION,
158
+ )
159
+ )
160
+ ```
161
+ <!-- sample:end -->
162
+
163
+ | Category | Use it for |
164
+ |---|---|
165
+ | `BUSINESS_EXCEPTION` | A business rule stopped the run: bad input data, a missing record, a rejected transaction. |
166
+ | `APPLICATION_EXCEPTION` | Something broke in the bot or the application it drives. |
167
+ | `TIMEOUT` | The run, or a step in it, took too long. |
168
+ | `AUTHENTICATION_ERROR` | The bot couldn't sign in to something it needs. |
169
+ | `CANCELLED` | A person or a scheduler stopped the run. |
170
+ | `UNCATEGORIZED` | You don't know. The default. |
171
+
172
+ ### Attaching context with `payload`
173
+
174
+ `payload` takes any JSON-serialisable value and is stored as sent, shown in full on the run's page.
175
+ Use it for whatever would help you debug the run later: an invoice number, the step that failed, a
176
+ stack trace. If it has an `error`, `errorMessage` or `message` key, the alert uses that text to say
177
+ what went wrong.
178
+
179
+ <!-- sample:begin payload -->
180
+ ```python
181
+ result = robovue.report_run(
182
+ RunReport(
183
+ bot_id="invoice-bot-01",
184
+ platform="python",
185
+ status=RunStatus.FAILURE,
186
+ started_at=started_at,
187
+ completed_at=datetime.now(timezone.utc),
188
+ failure_category=FailureCategory.TIMEOUT,
189
+ payload={
190
+ "invoiceId": "INV-2291",
191
+ "error": "Timed out waiting for the vendor portal",
192
+ },
193
+ )
194
+ )
195
+ ```
196
+ <!-- sample:end -->
197
+
198
+ The whole report, payload included, must be under 64 KB.
199
+
200
+ ### Attaching a screenshot or log to a failed run
201
+
202
+ On the Team plan and above, you can attach one screenshot or log excerpt to a `FAILURE` run once
203
+ it's reported. Screenshots are off for each bot until someone turns them on (**Manage bots →
204
+ Screenshots**), because a screenshot can capture credentials or other sensitive data on screen.
205
+
206
+ <!-- sample:begin attachment -->
207
+ ```python
208
+ robovue.upload_attachment(result.run_id, screenshot_bytes, "image/png")
209
+ ```
210
+ <!-- sample:end -->
211
+
212
+ `content_type` is `"image/png"`, `"image/jpeg"` or `"text/plain"`, up to 5 MB, one per run. Robovue
213
+ deletes attachments after 30 days, or 7 or 14 if an Admin chose that. An upload for a bot with
214
+ screenshots off, or on a plan without them, gets the same 404 as an unknown run, so catch it if a
215
+ missing screenshot shouldn't fail your bot.
216
+
217
+ ### Reporting without ever raising
218
+
219
+ Monitoring shouldn't become a new way for a bot to fail. `try_report_run` never raises for a failed
220
+ report: an invalid report, a rejection or a network problem comes back in the outcome for you to log.
221
+
222
+ <!-- sample:begin try-report -->
223
+ ```python
224
+ outcome = robovue.try_report_run(report)
225
+ if not outcome.succeeded:
226
+ print(f"Couldn't report the run to Robovue: {outcome.error}", file=sys.stderr)
227
+ ```
228
+ <!-- sample:end -->
229
+
230
+ ### When a report is rejected
231
+
232
+ `report_run` raises `RobovueApiException`. Branch on `code`, which is stable; the message is for
233
+ people. `field` names the field at fault when there is one, and `request_id` is worth quoting if you
234
+ contact support.
235
+
236
+ <!-- sample:begin rejected -->
237
+ ```python
238
+ try:
239
+ robovue.report_run(report)
240
+ except RobovueApiException as exc:
241
+ if exc.code != "timestamp_out_of_range":
242
+ raise
243
+ # This machine's clock is more than 5 minutes out. The message gives Robovue's time.
244
+ print(exc, file=sys.stderr)
245
+ ```
246
+ <!-- sample:end -->
247
+
248
+ Network errors and 5xx responses are retried automatically (3 attempts by default), and so is a
249
+ `429` after its `Retry-After`, unless that's more than a minute, when the 429 is raised instead of
250
+ blocking your bot. A report is checked against the server's own rules before anything is sent, so
251
+ most mistakes fail at once with a `ValueError`. A `datetime` with no time zone is treated as UTC.
252
+ Every code is in the [responses table](#responses) below, with whether a retry can help.
253
+
254
+ ### Checking a new integration with a dry run
255
+
256
+ With `dry_run=True`, every request is checked exactly as a real one would be (signature, fields,
257
+ plan limits, delivery id) but nothing is stored. The result says what would have happened, and a
258
+ request that would fail raises the real error.
259
+
260
+ <!-- sample:begin dry-run -->
261
+ ```python
262
+ robovue = RobovueClient(
263
+ base_url="https://app.robovue.co.uk",
264
+ tenant_id=tenant_id,
265
+ webhook_secret=webhook_secret,
266
+ dry_run=True, # checked in full, stored nowhere
267
+ )
268
+
269
+ result = robovue.report_run(report)
270
+ print(result.dry_run, result.would_be) # True created
271
+ ```
272
+ <!-- sample:end -->
273
+
274
+ Dry runs show in the request log, which warns you if any arrived in the last day, so a bot left in
275
+ dry-run mode doesn't go unnoticed. Turn it off before going live.
276
+
277
+ ### Runs that take a while
278
+
279
+ The quickstart reports `RUNNING` first. That's optional, but it means a bot that starts a job and
280
+ then hangs shows as Running straight away, rather than only when its next run fails to turn up.
281
+ Reusing the delivery id is the whole mechanism: a fresh id on the second report makes two runs,
282
+ one stuck Running and one that looks like nobody started it. A run left Running silences nothing:
283
+ if it never completes, the bot still goes Late and then Overdue on its schedule.
284
+
285
+ ## Using it in your bot
286
+
287
+ **One client per process.** `RobovueClient` holds no state between calls, so share one instance
288
+ across threads.
289
+
290
+ **Timeouts, proxies and TLS.** Each attempt times out after 30 seconds. Requests go through
291
+ Python's `urllib`, so they use the proxy in `HTTPS_PROXY` (or, on Windows, the system's proxy
292
+ settings) and the operating system's trusted certificates; set `SSL_CERT_FILE` to a certificate
293
+ bundle if your network inspects TLS with its own certificate. For anything else, pass
294
+ `transport=`: a function that takes the URL, a dict of headers and the body bytes, sends the POST,
295
+ and returns `(status, response_body, response_headers)`.
296
+
297
+ ## Options
298
+
299
+ | `RobovueClient(...)` | Default | What it does |
300
+ |---|---|---|
301
+ | `base_url` | required | `https://app.robovue.co.uk` |
302
+ | `tenant_id` | required | Your workspace's tenant ID. |
303
+ | `webhook_secret` | required | Signs every request. Never put it in source code. |
304
+ | `max_retry_attempts` | `3` | Attempts per call, the first included. |
305
+ | `retry_base_delay` | `0.2` | Seconds before the first retry; each later wait doubles. |
306
+ | `dry_run` | `False` | Check every request in full and store nothing. |
307
+ | `transport` | `urllib` | How requests are sent (see above). |
308
+
309
+ ## Troubleshooting
310
+
311
+ | What you see | What to do |
312
+ |---|---|
313
+ | `invalid_signature` | The secret is wrong or was rotated. Copy the current one from Connections → HTTP API. |
314
+ | `timestamp_out_of_range` | The machine's clock is more than 5 minutes out. Sync it (on Windows, `w32tm /resync`). |
315
+ | `unknown_tenant` | The tenant ID is wrong. Copy it from the address on Connections → HTTP API. |
316
+ | `bot_cap_reached` | A new bot id would go over your plan's bot limit. Delete a bot you no longer use, or change plan. |
317
+ | `payload_too_large` | The report is over 64 KB. Send less in `payload`, and attach a log file instead. |
318
+ | A 403 with no code | Something between your bot and Robovue blocked the request, such as a proxy rewriting it. Check with whoever runs your network, or send us the time it happened. |
319
+ | An `OSError` or `URLError` | The machine can't reach `app.robovue.co.uk`. Check its proxy and firewall. |
320
+ | `CERTIFICATE_VERIFY_FAILED` | Your network inspects TLS. Point `SSL_CERT_FILE` at a bundle that includes its certificate. |
321
+ | The report succeeds but nothing shows on Fleet | `dry_run` is still on, the bot is archived (see **Manage bots → Archived bots**), or the credentials belong to another workspace. |
322
+ | A bot stopped running and nobody was told | It has no schedule. See [Make missed runs alert](#make-missed-runs-alert). |
323
+
324
+ The request log, in Connections, shows every request from the last 7 days and the exact reason any
325
+ was turned away.
326
+
327
+ ## The wire contract (any language)
328
+
329
+ This is exactly what `RobovueClient` sends. From a language with no Robovue SDK, send the same
330
+ request yourself. The tables below are generated from the server's own contract, so they list every
331
+ field, header, response and limit, including the optional ones.
332
+
333
+ <!-- contract:begin — generated from IngestionContract by ContractDocsTests; don't edit by hand -->
334
+ ### `POST /ingest/{tenantId}/runs`
335
+
336
+ The endpoint both SDKs call, and the one to use from any other language. A JSON object with the fields below.
337
+
338
+ #### Headers
339
+
340
+ | Header | Value | Description |
341
+ |---|---|---|
342
+ | `Content-Type` | application/json | The body is JSON. A charset parameter is fine. |
343
+ | `X-Robovue-Delivery-Id` | 1–200 printable ASCII characters, no spaces | Unique to this run. Sending it again — a retry, or the Success/Failure that ends a Running run — updates or confirms the same run instead of creating a second one. A GUID, or your bot's own run id, both work. |
344
+ | `X-Robovue-Timestamp` | Unix time in whole seconds | When you signed the request. It must be within 5 minutes of Robovue's clock either way, so a captured request stops working within minutes. Sign each attempt, retries included, just before sending it. |
345
+ | `X-Robovue-Signature` | v1=&lt;lowercase hex&gt; | HMAC-SHA256, keyed with your webhook secret, of the timestamp, the word run, the delivery id — each followed by a line break — then the exact request body; hex-encoded in lowercase and prefixed with v1=. Sign the exact bytes you send: re-serialising the JSON after signing changes them. Several comma-separated v1= values are accepted, and any one matching is enough. |
346
+ | `X-Robovue-Dry-Run` | true | Checks the request exactly as a real one — authentication, every field, the delivery id, your plan's bot limit — and stores nothing. The response says what would have happened. Use it to test an integration without creating runs. Any other value, or no header, is an ordinary request. |
347
+
348
+ #### Body fields
349
+
350
+ | Field | Type | Required | Description |
351
+ |---|---|---|---|
352
+ | `botId` | string, ≤ 200 chars | Yes | Your name for the bot. Every run with the same botId is the same bot on your dashboard. Spaces and punctuation are fine; not / or \, no spaces at either end, and no tabs or line breaks. The same id registers and manages the bot through the Management API. |
353
+ | `platform` | string, ≤ 50 chars | Yes | What runs the bot, shown beside it on the dashboard — any short label, such as dotnet, python, go or blue-prism. Same whitespace rules as botId. |
354
+ | `status` | one of `Success`, `Failure`, `Running` | Yes | How the run went. Running reports a run that has started and not finished yet; send the same delivery id again with Success or Failure when it ends. Values are case-sensitive. |
355
+ | `startedAt` | timestamp | Yes | When the run started, as an RFC 3339 timestamp with a time zone offset — Z for UTC or ±hh:mm, e.g. 2026-07-17T09:00:00Z. Not before 2000, and not more than an hour ahead of Robovue's clock. Unix times aren't accepted. |
356
+ | `completedAt` | timestamp | Required when status is Success or Failure; leave it out when status is Running | When the run finished. Same format and limits as startedAt, and not before it. |
357
+ | `failureCategory` | one of `Uncategorized`, `BusinessException`, `ApplicationException`, `Timeout`, `AuthenticationError`, `Cancelled` | No — only with status Failure | Why the run failed, if your bot can tell. Leave it out if not — it becomes Uncategorized and can be changed later on the run's page. Values are case-sensitive. |
358
+ | `payload` | object | No | Anything that will help you debug the run later — an invoice number, the step that failed, a stack trace. Must be a JSON object; it's stored as sent and shown on the run's page. A top-level error, errorMessage or message string becomes the summary in the failure alert. Counts toward the 64 KB request limit. |
359
+
360
+ #### Responses
361
+
362
+ Every error body is `{"error": "…", "code": "…", "field": "…", "requestId": "…"}` — `field` only when the error is about one field — and every response carries an `X-Request-Id` header.
363
+
364
+ | Status | Code | Meaning | Retry? |
365
+ |---|---|---|---|
366
+ | 201 | — | A new run was recorded. Body: {"runId": "…", "duplicate": false} | No need — nothing more to send |
367
+ | 200 | — | The run was already known. duplicate is false when this completed a run reported as Running, and true when it repeated a report already recorded — a retry, which is safe. | No need — nothing more to send |
368
+ | 200 | — | A dry run passed every check. Body: {"dryRun": true, "wouldBe": "created"} — or "completed" or "duplicate". Nothing was stored. | No need — nothing more to send |
369
+ | 400 | `invalid_body` | The body isn't a single valid JSON object. The message gives the line and position. | No — fix the request first |
370
+ | 400 | `duplicate_field` | A field appears twice. | No — fix the request first |
371
+ | 400 | `unknown_field` | A field isn't one of the documented names. Names are case-sensitive; put your own data inside payload. | No — fix the request first |
372
+ | 400 | `missing_field` | A required field is missing, blank or null. field names it. | No — fix the request first |
373
+ | 400 | `invalid_field` | A field's value breaks its rule. field names it and the message says how. | No — fix the request first |
374
+ | 400 | `missing_delivery_id` | X-Robovue-Delivery-Id is missing. | No — fix the request first |
375
+ | 400 | `invalid_delivery_id` | X-Robovue-Delivery-Id is longer than 200 characters, or has spaces or non-ASCII characters. | No — fix the request first |
376
+ | 400 | `bot_cap_reached` | This would be a new bot and your plan's bot limit is reached. Archive and delete a bot you no longer use, or raise the limit on the Billing page. | No — fix the request first |
377
+ | 401 | `missing_signature` | X-Robovue-Signature is missing. | No — fix the request first |
378
+ | 401 | `missing_timestamp` | X-Robovue-Timestamp is missing. | No — fix the request first |
379
+ | 401 | `invalid_timestamp` | X-Robovue-Timestamp isn't whole Unix seconds — milliseconds are the usual cause. | No — fix the request first |
380
+ | 401 | `timestamp_out_of_range` | X-Robovue-Timestamp is more than 5 minutes from Robovue's clock. The message gives the server's time; fix the sending machine's clock. | Yes, once re-signed with the current time |
381
+ | 401 | `invalid_signature` | The signature doesn't match. Check the secret, and that you sign the timestamp, the delivery or run id and the exact bytes you send. | No — fix the request first |
382
+ | 404 | `unknown_tenant` | No workspace has this tenant id. Copy the URL from your Connect page. | No — fix the request first |
383
+ | 409 | `delivery_id_conflict` | The delivery id already belongs to a different bot's run. Reuse a delivery id only for the same bot's run. | No — fix the request first |
384
+ | 413 | `payload_too_large` | The body is over 64 KB. | No — fix the request first |
385
+ | 415 | `unsupported_media_type` | Content-Type isn't application/json. | No — fix the request first |
386
+ | 429 | `rate_limited` | Too many requests. Retry-After says how many seconds to wait. | Yes, after the Retry-After seconds |
387
+ | 500 | `internal_error` | Something went wrong on Robovue's side. Quote the requestId if it keeps happening. | Yes, with backoff — the same delivery id makes it safe |
388
+
389
+ #### Limits
390
+
391
+ | Limit | Value | Notes |
392
+ |---|---|---|
393
+ | Run report size | 64 KB | Per request, payload included. Anything larger is rejected with 413 — it isn't truncated. |
394
+ | Run reports | 600 per minute per workspace | Shared by the run report, Power Automate and UiPath endpoints. A start and its completion count as two. |
395
+ | Attachment size | 5 MB | One file per failed run: PNG, JPEG or UTF-8 text. |
396
+ | Attachment uploads | 50 per hour per workspace | Across all bots. |
397
+ | Attachment storage | 500 MB per workspace | Attachments are deleted after the workspace's retention, 30 days at most, which frees their space. |
398
+ | Management API | 300 requests per minute per workspace | Every /tenants/… request. |
399
+ | Per address | 3,000 requests per minute per client IP | Across ingestion and the Management API together, for every workspace sending from that address. |
400
+ <!-- contract:end -->
401
+
402
+ **Signing, in one line:** `"v1=" + hex(HMAC-SHA256(key: webhook secret, message: timestamp + "\nrun\n" + delivery id + "\n" + the exact body bytes))`,
403
+ lowercase, sent with the same timestamp in `X-Robovue-Timestamp` (Unix seconds). Sign each attempt, retries
404
+ included, just before sending it. Sign the bytes you actually send: re-serialising the JSON after signing (a
405
+ different key order, different whitespace) changes them and the request is rejected with `invalid_signature`.
406
+ Connections → HTTP API has a signature checker to compare yours against.
407
+
408
+ The attachment upload (`POST /ingest/{tenantId}/runs/{runId}/attachment`) uses the same scheme with the word
409
+ `attachment` in place of `run` and the run id (lowercase, hyphenated) in place of the delivery id, computed over
410
+ the raw file bytes instead of a JSON body. It has no delivery-id header: one attachment per run is what makes it
411
+ safe to retry.
412
+
413
+ This scheme is only for the `/ingest/{tenantId}/runs` endpoint. UiPath Orchestrator, Power Automate and
414
+ Automation Anywhere connect to Robovue without code; Connections in Robovue sets each one up.
415
+
416
+ ## Versions and support
417
+
418
+ - The package follows [semantic versioning](https://semver.org). `CHANGELOG.md`, in the source
419
+ distribution, lists what changed in each version.
420
+ - For bots written in C# or another .NET language, [`Robovue.Sdk`](https://www.nuget.org/packages/Robovue.Sdk)
421
+ is the same client.
422
+ - Questions and problems: [contact@robovue.co.uk](mailto:contact@robovue.co.uk). Quote the `RequestId` from
423
+ the error if there is one.
424
+ - Found a security problem? See [robovue.co.uk/security](https://robovue.co.uk/security).
425
+
426
+ ## Licence
427
+
428
+ MIT, so you can use, change and ship this package freely (see `LICENSE` in the package). Using the Robovue
429
+ service is covered by the [terms](https://robovue.co.uk/terms).
@@ -0,0 +1,12 @@
1
+ robovue_sdk/__init__.py,sha256=YcZ9_dE2e0b_0f1ia-jB11gkYCIgdgsU57uXvPPoY0o,351
2
+ robovue_sdk/_signature.py,sha256=C0LxJQm4CGE3Emv1uVa7Hz2pBk1uE-hqS24aUQO7HVM,1033
3
+ robovue_sdk/_transport.py,sha256=ntk9thTKOKt2EsmLW0EiG-3Oo1DEC3EkapVBmrVYRpI,4333
4
+ robovue_sdk/_version.py,sha256=v0S4g9l3egEtnYhqDUzI344nMOz-DFazAXJGvJLPcYI,637
5
+ robovue_sdk/client.py,sha256=0_pMRdZeX5DOoYssvuYJmC0p1n9rFtCPk-eoP4AoX60,14647
6
+ robovue_sdk/exceptions.py,sha256=uep_oWZfh4xjy26tNB3xMrW35XV-Zqrs8k1ex01ep14,1398
7
+ robovue_sdk/models.py,sha256=Eb6B60FPIqfGaclcZYaCzSGUbMhZYA23dZkeAH1YgC8,2852
8
+ robovue_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ robovue_sdk-0.2.0.dist-info/METADATA,sha256=udJubr0_WpggFR0IarH9BXB58U9BerZHRNDqh_x6PLg,23474
10
+ robovue_sdk-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ robovue_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=rezHn0C4gc7fYv0B-t7irMY9kzlFYVjZ097o-NFJUq4,1085
12
+ robovue_sdk-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robovue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.