Hooks¶
A hook reacts to what other components do: it declares what to watch, which outcomes matter, and owns a side effect. Like a job's cron, the declaration is inert intent that a platform scheduler evaluates against finished runs. The core defines the classes and the contract.
Declaring a hook¶
import interloper as il
alert = il.WebhookHook(
watches=[ingest_job],
events=["run_failed"],
url="https://ops.example.com/alerts",
)
| Field | Default | Meaning |
|---|---|---|
watches |
[] |
Sources, assets or jobs to observe. |
events |
["run_failed"] |
Outcomes that fire the hook: run_completed, run_failed, backfill_completed, backfill_failed. At least one. |
enabled |
True |
A disabled hook is kept but never fires. |
Built-in hooks¶
TriggerHook runs other components when a watched outcome matches. It is the primitive for
cascading pipelines:
Its targets are sources, assets or jobs. Firing goes through the trigger capability the
operator injects into the context, so the hook has no persistence dependency of its own; firing
without one raises ConfigError.
WebhookHook POSTs a fixed JSON document to url with a timeout (default 10 seconds).
A non-success response raises:
{
"event_type": "run_failed",
"component_id": "…",
"run_id": "…",
"partition_key": "2026-01-15",
"backfill_id": null,
"start_key": null,
"end_key": null,
"url": "https://app.example.com/executions/runs/…",
"hook_id": "…",
"metadata": {}
}
Custom hooks¶
Subclass il.Hook and implement fire(context). Configuration fields and relations work as on
any component, so credentials ride a connection:
class EmailHook(il.Hook):
recipient: str = il.InputField(description="Address to notify")
smtp: SmtpConnection
def fire(self, context: il.HookContext) -> None:
self.smtp.send(self.recipient, f"{context.event_type} on {context.component_id}")
HookContext carries:
| Attribute | Meaning |
|---|---|
event_type |
The matched outcome. |
component_id |
The watched component whose run ended. |
run_id |
The run. |
partition_key |
The run's partition key, if any. |
backfill_id, start_key, end_key |
The backfill and its range, on a backfill event. |
url |
The run's or backfill's page in the app, when server.external_url is set. |
metadata |
Event details supplied by the operator: always status, organisation_name, component_name and component_key, plus what the event kind adds below. |
trigger |
A callable creating a run for a component id, when the operator provides it. |
A hook that acts on other components extends the relation vocabulary the way TriggerHook
does, declaring its own targets relation on the subclass; the base hook only observes. Custom
hook classes join the catalog through the interloper.components entry point like
any component.
HookState (last_fired_at, last_run_id, last_error) is the hook's machine-owned state,
stamped by the operator on every firing; last_error holds the latest firing's error and a
successful firing clears it.
Backfills¶
A backfill has a verdict of its own: backfill_completed once every partition's latest attempt
succeeded, backfill_failed once the batch closed with a failed partition left. Every firing of a
partitioned cron job is a backfill, so a hook subscribed to backfill_failed gets one message per
bad morning rather than one per partition; subscribe to both levels to get both. A canceled
backfill is not an outcome of the work and fires nothing.
The context names the backfill (backfill_id, start_key, end_key) and its metadata carries
partitions, counts (partitions per status, each read as its latest attempt) and, for a failure,
failed_partitions: [partition_key, error] pairs, newest first. A TriggerHook fired by a
backfill event backfills each target over the same range, gated by the target job's concurrency.
Retries¶
A hook observes a verdict, never an attempt. When a run fails and its job's retry policy allows
another attempt, the failure is not an outcome and no hook fires. Only the attempt that ends the
stack does: as run_completed if a retry healed the work, or as run_failed once the budget is
exhausted. A backfill stays open while a retry waits out its backoff, so its event, too, reports
the healed or exhausted stack.
The context carries the stack's position, so a message can say which attempt it is reporting:
def fire(self, context: il.HookContext) -> None:
attempt = context.metadata["attempt"]
attempts = context.metadata["attempts"]
post(f"{context.metadata['component_name']}: {context.metadata['status']} on attempt {attempt}/{attempts}")
Scope¶
Hooks fire on persisted, terminal runs evaluated by a scheduler. For observing execution inside a process (asset lifecycle, destination I/O, log lines), use the event bus.