Metadata-Version: 2.4
Name: relayt
Version: 0.3.0
Summary: Connect a running app to its Relayt control plane: self-registration, heartbeat, per-deployment config, entitlements/licensing, signed named commands, and error reports that become issues. Standard library only.
Author: Relayt Studio
License-Expression: MIT
Project-URL: Homepage, https://relayt.studio
Keywords: relayt,licensing,entitlements,heartbeat,feature-flags,remote-config,control-plane,error-reporting
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# relayt (Python)

Connect a running app to its Relayt control plane: self-registration, heartbeat, per-deployment
config, entitlements and licensing, signed named commands, and error reports that become issues.
Standard library only, Python 3.8+. Same wire contract and behaviour as the TypeScript SDK
([`@relayt/sdk`](https://www.npmjs.com/package/@relayt/sdk) on npm).

```bash
pip install relayt
```

```python
from relayt import Runtime, CapabilitySpec, CapabilityField

rt = Runtime(
    "https://api.relayt.space",
    register={"deployment_id": DEPLOYMENT_ID, "enroll_key": os.environ["RUNTIMECORE_ENROLL_KEY"], "name": "Acme API"},
    credentials_path=".relayt.json",  # or on_credentials=lambda key, secret: save(key, secret)
    version="1.4.0",
    on_error=lambda e: print("[relayt]", e),
)

rt.handle("user.get", lambda c: {"id": c.payload["userId"]},  # a dict comes back to the panel as JSON
          CapabilitySpec(type="data", fields=[CapabilityField(name="userId", required=True)]))

rt.start()
# ... on shutdown:
rt.stop(timeout=10)  # waits for a running command to finish and ack
```

- Handler exceptions (`Exception`) are caught and acked as failures; `KeyboardInterrupt` and
  `SystemExit` are not swallowed.
- A 401 on heartbeat (key rotated) or a changed `signingKeyId` (secret rotated) re-registers when
  `register` is given, and the new pair is written to `credentials_path` / passed to `on_credentials`.
- `credentials_path=".relayt.json"` also reads a `.runtimecore.json` beside it (the file name used
  before the package was renamed) when `.relayt.json` does not exist yet; the next save writes
  `.relayt.json`.
- Failed beats retry with exponential backoff from `retry_base_s` (1s), ±10% jitter, capped at
  `interval_s` (30s).
- Results: a `str`, or any JSON-serialisable value (JSON-encoded, capped at 8000 characters).
- Recently seen command ids and nonces are remembered, so a replayed command runs at most once.

## Errors

`report_error` sends an error to the panel, which keeps ONE issue per distinct error and counts every
repeat. It never raises and never blocks: the event is scrubbed and queued, and a daemon thread sends
it with this deployment's key.

```python
try:
    charge_card(order)
except Exception as exc:
    rt.report_error(exc, {"order_id": order.id, "route": "/checkout"})
    # inside an except block the exception may be omitted: rt.report_error(context={...})
    # a level or your own grouping key when you want them:
    # rt.report_error(exc, ctx, level="fatal", fingerprint="checkout-timeout")
```

Global capture is opt-in, because a process-level hook changes what a crash does:

```python
rt = Runtime(..., errors={"capture_global": True})   # installed on start()
# or at any time, with an uninstaller back:
stop = rt.capture_errors(excepthook=True, threads=True, loop=None)
```

| Hook | Reported as | Then |
|---|---|---|
| `sys.excepthook` | `fatal` (KeyboardInterrupt is skipped) | the previous hook runs (the traceback still prints), the queue is flushed for up to `exit_timeout_s`, and the process exits as it would have. |
| `threading.excepthook` | `error`, with the thread's name | the previous hook runs. |
| asyncio loop exception handler | `error` (exceptions never retrieved from a task or future) | the previous handler, or the loop's default one, runs. |

`loop=None` means the running loop when `capture_errors` is called inside one; pass a loop to hook a
specific one. Uninstalling restores each previous hook, unless something else replaced ours since.

What happens before anything leaves the process (the same rules as the TypeScript SDK):

- **Scrubbed.** Passwords, tokens, API keys, bearer/basic credentials, JWTs, long hex/base64 runs and
  email addresses are taken out of the message, the stack and the context; context keys that look
  secret (`password`, `token`, `auth`, `cookie`, `session`, `api_key`, `card`, ...) are redacted
  whole. Context is capped at depth 4, 40 keys per level, 500-character strings and 8 KB overall
  (bigger becomes a note); the type at 120 characters, the message at 2000, the stack at 8000 (and
  50 frames). `errors["before_send"](event)` can change or drop (`None`) an event after that.
- **Folded.** The same error repeating within `errors["flush_interval_s"]` (default 2s) goes out
  once with a `count`; the latest context wins.
- **Limited.** At most `errors["max_per_minute"]` (default 30) distinct events a minute and 50
  waiting; extra ones are dropped and counted. A request carries at most 20 events and stays under
  480 KB (the server refuses 512 KB). A `429` pauses sending until its `Retry-After` (seconds or an
  HTTP date), including a per-event 429 inside a batch reply; network errors, 401 and 5xx back off
  (doubling from the flush interval, up to 60s) and keep the events; a 400 drops them.
- **Held for a key.** Reports made before registration finishes wait and go out once there is one.
- **Flushed on the way out.** `stop()` flushes (within its timeout), `flush_errors(timeout)` does it
  on demand, and an `atexit` hook sends what is left for up to `exit_timeout_s` (default 2s).

Without the runtime (a worker that does not heartbeat), use the reporter on its own:

```python
from relayt import ErrorReporter

errors = ErrorReporter("https://api.relayt.space", key=os.environ["RELAYT_KEY"], release="1.4.0", environment="production")
errors.capture_errors()                                   # opt-in, as above
errors.report_error(ValueError("boom"), {"job": "nightly-sync"})
errors.flush(timeout=5)                                   # or let atexit do it
```

### Stacks and fingerprints

A Python traceback is sent innermost frame first, one line per frame and no source lines:

```
Traceback (most recent call first):
  File "/srv/app/billing/invoices.py", line 41, in compute_total
  File "/srv/app/billing/views.py", line 12, in checkout
```

The server fingerprints every event (`fingerprint_of` reproduces it exactly, and is what the SDK uses
to fold repeats):

```
basis = "custom|" + fingerprint[:200]                                       if one is given
      = type + "|" + normalize_message(message) + "|" + "|".join(top_frames(stack))   otherwise
fingerprint = sha256(basis).hexdigest()[:40]
```

`normalize_message` replaces emails, URLs, UUIDs, hex, quoted strings and numbers with placeholders;
`top_frames` takes the first three frames as `function@dir/file` (last two path segments, no line
numbers, no build hashes). A V8 frame `at computeTotal (/srv/app/invoices.ts:41:17)` and a Python
frame `File "/srv/app/invoices.py", line 41, in compute_total` reduce the same way, so in both
languages an issue is keyed on the three frames nearest the raise, and a redeploy that moves lines
keeps it the same issue. The type is the class name (`ValueError`), module-qualified outside
builtins (`requests.exceptions.ConnectionError`). The server skips only JavaScript runtime frames
(`node_modules`, `node:internal`), so an error raised inside a library groups on the library's
frames; pass `fingerprint=` where that matters.

## Invoices

An app linked to a customer in the panel can raise an invoice for that customer (never anyone else:
the invoice belongs to the app's own space, client and project). Money is in integer minor units.

```python
r = rt.create_invoice(
    items=[{"description": "Oylik obuna", "unit_price_cents": 9_900_000, "description_i18n": {"en": "Monthly plan"}}],
    due_date="2026-10-05",
    pdf_enabled=False,  # the "PDF document" switch, see below
)
if r["ok"]:
    send_to_customer(r["invoice"]["payUrl"])
```

`pdf_enabled` (default on): once paid, the payer can open the receipt as a document on the pay domain
(`receiptUrl`) and download the PDF; before that the invoice's page is its checkout (`payUrl`).
`False`: payment only, no document anywhere, `receiptUrl` is `None`. It must be a real `bool`.

`create_invoice` never raises. It returns `{"ok": True, "invoice": {...}}` or
`{"ok": False, "status": int, "error": str}` (status 0 = nothing was sent), and is not retried on a
network failure because the invoice may already exist. Same HTTP contract as the TypeScript SDK
(`POST /api/public/invoices`, see its README).

## Tests

`python -m unittest discover -s tests` (pytest runs them too). The error tests stand a real HTTP
server up on 127.0.0.1 in place of the panel, and check the fingerprint port against values produced
by the server's own `fingerprintOf`. No outside network.
