Metadata-Version: 2.5
Name: didntrun-sdk
Version: 1.1.1
Summary: First-party Python client for the didnt.run ping API: correct, non-blocking instrumentation by default.
Project-URL: Homepage, https://didnt.run
Project-URL: Source, https://gitlab.com/robert.stetka/didnt-run-sdk-python
Author: Robert Štětka
License-Expression: MIT
License-File: LICENSE
Keywords: cron,didnt.run,healthcheck,heartbeat,monitoring,watchdog
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Monitoring
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# didntrun-sdk — Python client for didnt.run

Instrument a scheduled job against didnt.run with one line. The SDK's contract: **your job is never
slowed beyond a hard time budget and never failed by the watchdog** — every transport problem is
swallowed and logged, by default through Python's `logging` module, or through a logger you supply.

## Install

```bash
pip install didntrun-sdk
```

## Use

```python
import os
from didntrun import Watchdog

# One env var wires everything: https://{ping-token}@it.didnt.run[?budget_ms=2000&connect_timeout_ms=1000]
wd = Watchdog.from_dsn(os.environ["DIDNT_RUN_DSN"])
```

> Your DSN is shown once, ready to copy, when you issue a ping token in the didnt.run admin UI.

```python
# Recommended: bracket the work. ONE completion ping — measured duration on success,
# exception context on failure (the exception is re-raised; your job's semantics don't change).
with wd.run("app.daily-report"):
    generate()

# Or the decorator form:
@wd.watch("app.daily-report")
def daily_report():
    ...

# Or fire the bare "I ran" ping yourself:
wd.ping("app.daily-report")
wd.ping("app.daily-report", duration_ms=1234)

# Or send explicit lifecycle pings — manual bracketing when start and finish
# live in different processes or code paths (see "Lifecycle bracketing"):
wd.start("app.daily-report")
wd.success("app.daily-report", duration_ms=1234)
wd.fail("app.daily-report")
wd.fail("app.daily-report", message="upstream returned 503")   # say why — see "What a failure says"
```

`ping()` returns `bool` (delivered vs. swallowed) if you want to observe delivery; you never have to.

### Job identity

```python
@wd.watch()          # id defaults to f"{fn.__module__}.{fn.__qualname__}"
def daily_report():
    ...
```

**This is identity-defining.** Moving `daily_report` to another module registers a NEW job — the old
one goes stale and will alert. Prefer the explicit-id form (`@wd.watch("app.daily-report")`); every
example above uses it for that reason. Ids are normalized the same way the PHP SDK
normalizes them — leading `\` stripped, `\` → `.`, empty rejected — and capped at 255 **bytes**, which
is the length the server measures, dropping a trailing partial UTF-8 character rather than splitting
one. The mapping is kept for parity rather than necessity: Python identifiers are already dotted, so
it is a no-op for a native id.

### Failure semantics

- Misconfiguration (a malformed DSN, an empty token, a token outside the accept set, a base URL with a
  query, a fragment or an unparseable port, a DSN option that is not a plain positive integer) raises
  `ValueError` **at construction**, and `@wd.watch()` raises `ValueError` **at decoration** when the
  function it wraps is an `async def` or a generator (see "No `async`/`await`"). Both are wiring time,
  before any job runs. After that, nothing raises except the wrapped work's own exception inside
  `run()` / `watch()`, re-raised unchanged.
- Each ping gets a wall-clock budget (default 2 s, connect 1 s) with one retry inside it — see
  "Delivery and retries" below for exactly which failures qualify. `401` is logged at ERROR (your token
  is wrong); every other rejection at WARNING.
- Oversized `meta` (> 64 KiB body), or `meta` that cannot be encoded, is dropped; the ping still goes
  out, and so does its `message`.

### What a failure says

A failed run can say why. `fail()` takes an optional `message`, and `run()` / `watch()` send the text
of the exception that ended them. The text is trimmed and capped at 512 characters, and a blank one
is not sent.

**It is shown in the alert email and in the admin UI**, to everyone who receives alerts for the
project. That includes the exception messages of jobs that were already using `run()` before this
version: the service reads them from the ping's metadata. If an exception message of yours can
carry a credential or customer data, catch it and re-raise or report something safe instead.

### Wiring

```python
wd = Watchdog.from_dsn(os.environ["DIDNT_RUN_DSN"])
```

Construct once at startup, wherever your app already wires singletons, and hand `wd` around — there's
no framework glue to install; `Watchdog` is a plain object.

Bring your own HTTP client (optional): implement the `Transport` protocol — it's `typing.Protocol`, not
an ABC, so nothing has to import from this package. The whole surface is one method:

```python
from collections.abc import Mapping

import requests
from didntrun.transport import Transport, TransportResult, TimeBudget

class RequestsTransport(Transport):
    def post(self, url: str, headers: Mapping[str, str], body: bytes, budget: TimeBudget) -> TransportResult:
        try:
            resp = requests.post(
                url, headers=dict(headers), data=body, allow_redirects=False,
                timeout=(budget.connect_ms / 1000, max(budget.total_ms, 1) / 1000),
            )
            return TransportResult.response(resp.status_code)
        except requests.RequestException as e:
            return TransportResult.failure(str(e))

wd = Watchdog.from_dsn(os.environ["DIDNT_RUN_DSN"], transport=RequestsTransport())
```

Note the time budget is then only as good as your client's own timeout config — the default
`HttpClientTransport` (stdlib `http.client`) is deliberately the one thing this package doesn't
outsource: it never follows redirects and enforces the budget by tracking a deadline on
`time.monotonic()` rather than relying on a per-socket-operation `timeout=`.

### Lifecycle bracketing

`run()` / `watch()` send one completion ping by default. Opt into **bracketing** and they also emit a
`start` ping *before* the work runs: the `start` anchor is duration-immune, so it tightens the inferred
cadence for jobs whose duration is non-trivial or variable (a terminal-only anchor drifts with run
length).

Enable it globally (constructor arg, or the `lifecycle` DSN param) with a per-call override:

```python
# Global default via DSN: https://{token}@it.didnt.run?lifecycle=true
wd = Watchdog.from_dsn(os.environ["DIDNT_RUN_DSN"])
with wd.run("app.daily-report"):        # start + completion
    generate()

# Per-call override wins (None = inherit the instance default):
with wd.run("app.daily-report", lifecycle=True):
    generate()
```

A bracketed run has **two** budget windows — one ping before the work, one after — so its worst-case
added wall-clock is ~2× the per-ping budget, straddling the work. For work a single `with` block can't
wrap (it spans processes, or start and finish live in different code), call `start()` / `success()` /
`fail()` directly instead.

**Server requirement:** bracketing needs a ping-api that understands lifecycle kinds. Against an older,
kind-blind self-hosted server, leave `lifecycle` **off** — a separate `start` ping would double-count
runs in schedule inference.

**No `async`/`await`.** There's no async HTTP client in the stdlib, and shipping one would break the
zero-dependency promise. `@wd.watch()` **refuses** an `async def` or a generator function outright,
with a `ValueError` at decoration: a synchronous wrapper around a coroutine function gets the
coroutine object back immediately, so the completion ping would go out before the work had executed a
line — reporting success for a job that has not run yet, and feeding a 0 ms duration into the
hung-detection baseline. Await the work inside a sync wrapper, or keep the ping off the loop thread:

```python
await asyncio.to_thread(wd.ping, "app.daily-report")
```

## Logging

Every failure is swallowed and logged. By default the SDK logs through the standard `logging` module —
`logging.getLogger("didntrun")` — with **no handler attached**, so with nothing else configured in your
process, Python's own `logging.lastResort` emits WARNING and above to **stderr**. Under a plain cron
invocation that's exactly what you want: your cron runner captures or mails it.

Attach your own handler to redirect it, the same as any other library logger:

```python
import logging

logging.getLogger("didntrun").addHandler(logging.FileHandler("/var/log/didntrun.log"))
```

To silence it completely:

```python
logging.getLogger("didntrun").addHandler(logging.NullHandler())
```

A ping that is never delivered is the one failure you cannot see from the server side — it looks
identical to a job that died before pinging. Leaving logging on is what tells the two apart.

Each failure is written as exactly one line, so a log is safe to grep and count — that's also how the
PHP SDK's deferred time-budget question gets settled for this one: count `ping rejected` against
`ping not delivered`. Control characters in a value you passed — a newline or a tab inside an
`external_id`, say — appear as `?` rather than breaking the line in two, and the same context is also
passed as `extra=` for handlers that render structured fields. Your ping token never appears in a log
line either, including inside an error string authored by a transport.

On a `ping not delivered` line, `error=` says where the ping stopped and `elapsed_ms=` how long both
attempts took together:

- `connect failed: …` — DNS, TCP or TLS failed outright, before the budget ran out.
- `time budget exhausted while connecting (…)` — the connection was still being set up at the
  deadline. The parenthesis says whether TCP had connected (then the TLS handshake stalled) or not
  (DNS or the TCP connect; the two cannot be told apart).
- `time budget exhausted while sending the request` — connected, but the request could not be
  written in time.
- `time budget exhausted while waiting for the response` — the request was sent and no answer came
  in time. The server may still have recorded the ping. If these dominate, raise `?budget_ms=`.

**stderr records carry no timestamp** — `logging.lastResort` uses a bare handler with no `Formatter`, so
a message on stderr says only what happened, not when. Counting failures over a week works either way,
but correlating a specific lost ping with a specific alert needs a timestamp — either attach a real
handler (above) or have your cron wrapper stamp the stream.

## Delivery and retries

A ping is retried **once** when the failure could plausibly succeed on a second attempt:

| Outcome | Retried? |
| --- | --- |
| Connection refused / DNS failure / timeout | yes |
| `408 Request Timeout`, any `5xx` | yes |
| `429 Too Many Requests` | no |
| Any other `4xx` (`400`, `401`, `404`, …) | no |

One retry in total, never one per category. The retry has to fit inside the same time budget as the
first attempt (2 s by default), so against a slow server there may be no room for it — the SDK will
never exceed the budget to get a ping through.

## CLI

Installing the package also installs `didnt-run` — a console script, no separate install step:

```
didnt-run ping <id> [--duration-ms N] [--meta k=v]...
didnt-run run  <id> [--lifecycle] [--capture-stderr] -- <cmd> [args...]
```

Reads `DIDNT_RUN_DSN` from the environment, or `--dsn`. `run` wraps a shell command in your crontab:

```cron
*/5 * * * * didnt-run run app.nightly-backup -- pg_dump -Fc mydb > /backups/mydb.dump
```

- **It exits with the wrapped command's exit code.** The crontab's semantics, and any `&&` chained
  after it, are unchanged.
- **stdout and stderr pass straight through**, so cron mail still works. `--capture-stderr` *tees*
  rather than swallows, keeping the last 512 bytes for `meta` — it defaults to **off**, because a
  command's stderr is a population you didn't write and can't predict (`curl`, `pg_dump`, `rsync` all
  print connection strings on failure on their own); a denylist of secret-looking patterns goes stale
  the day some tool prints a new one, so the safe default is off and the opt-in is explicit.
- **A command that cannot be started says why; one that exits non-zero says nothing.** The reason
  (command not found, not executable) goes out as the failure's `message`, shown in the alert email
  and the admin UI. A non-zero exit sends no message, since the command's own output is not ours to
  forward: its stderr reaches the service only with `--capture-stderr`, and only as metadata.
- **A watchdog failure never changes the exit code.** A missing or malformed `DIDNT_RUN_DSN` logs to
  stderr and the command still runs, uninstrumented — an already-registered job that stops pinging is
  exactly what the watchdog alerts on. A command that can't be executed at all follows the
  shell's own convention: **127** when it is not found, **126** when it is found but not executable.
  The same code goes into the `fail` ping's `exit_code` meta and out as the wrapper's exit status.

A malformed `--meta` on `run` is reported on stderr and dropped — the command still runs. It was the
only watchdog-side input on that path that could stop a backup, and a metadata typo is the least
important of them. `didnt-run ping` has nothing to protect, so misconfiguration there exits `2`.
`--meta k=v` values are always strings; no type coercion is attempted.

## Requirements

Python ≥ 3.11. Zero runtime dependencies — the SDK is stdlib only.

## License

MIT — see [LICENSE](LICENSE).

Development happens in the didnt.run monorepo; this repository is a read-only subtree split. Please
report issues against the SDK there rather than opening merge requests here.
