Store a target's number of delivery attempts, at least one (closes #540)
check / check (push) Waiting to run

Target.MaxRetries (max_retries) becomes Target.MaxAttempts
(max_attempts), and the form field, parser and ceiling are renamed to
match. 1 is a single attempt with no circuit breaker; above 1 retries
behind the breaker up to that many attempts. Both target forms refuse
0 and less and default a blank field to 1. The target page shows the
plain number, and the README's two paragraphs on 0 against 1 become
one sentence.

Model: opus-5-5
This commit is contained in:
2026-10-15 11:10:29 +00:00
parent c0f7cc29fd
commit e639032483
37 changed files with 843 additions and 716 deletions
+23 -25
View File
@@ -1568,29 +1568,27 @@ did not arrive on the URL and counts in neither.
A delivery destination for events. Each target defines where and how events
should be forwarded.
| Field | Type | Description |
| ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Primary key |
| `webhook_id` | UUID | Foreign key → Webhook |
| `name` | string | Human-readable name |
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
| `active` | boolean | Whether deliveries are enabled (default: true) |
| `config` | JSON text | Type-specific configuration |
| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
| Field | Type | Description |
| -------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | UUID | Primary key |
| `webhook_id` | UUID | Foreign key → Webhook |
| `name` | string | Human-readable name |
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
| `active` | boolean | Whether deliveries are enabled (default: true) |
| `config` | JSON text | Type-specific configuration |
| `max_attempts` | integer | How many times an `http` or `slack` target attempts each delivery in all: at least 1, and 1 by default. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
**Relations:** Belongs to Webhook. Has many Deliveries.
**Target types:**
- **`http`** — Forward the event as an HTTP POST to a configured URL.
`max_retries` is the total number of delivery attempts, not retries on top of
the first: when `max_retries` is 0 (the default), the target operates in
fire-and-forget mode, a single attempt with no retries and no circuit breaker;
a value of N makes up to N attempts in all, retrying failed deliveries with
exponential backoff and protecting them with a per-target circuit breaker.
- **`http`** — Forward the event as an HTTP POST to a configured URL. With
`max_attempts` at 1 the target makes a single attempt; above 1 it retries a
failed delivery with exponential backoff, up to that many attempts in all,
behind a per-target circuit breaker.
- **`slack`** — Post the event as a formatted message to a Slack-compatible
incoming webhook URL (`webhookUrl` in `config`). It is built on the same HTTP
core as `http` and honours `max_retries` identically, circuit breaker
core as `http` and honours `max_attempts` identically, circuit breaker
included. See the Slack target section under "Per-Webhook Event Databases" for
the message format.
- **`database`** — Archive the full event as a row into the target's own archive
@@ -2165,11 +2163,11 @@ External Service
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ HTTP Target│ │ HTTP Target│ │ Log Target │
│(max_retries│ │(max_retries│ │ (stdout) │
│ == 0) │ │ > 0, │ └────────────┘
│ fire+forget│ │ backoff + │
│max_attempts│ │max_attempts│ │ (stdout) │
│ == 1, │ │ > 1, │ └────────────┘
│ one attempt│ │ backoff + │
└────────────┘ │ circuit │
│ breaker) │
│ breaker │
└────────────┘
```
@@ -2246,7 +2244,7 @@ re-inject it to every currently active target.
### Circuit Breaker (HTTP and Slack Targets with Retries)
`http` and `slack` targets with `max_retries` > 0 are protected by a
`http` and `slack` targets with `max_attempts` > 1 are protected by a
**per-target circuit breaker** that prevents hammering a down target with
repeated failed delivery attempts. The circuit breaker is in-memory only and
resets on restart (which is fine — startup recovery rescans the database
@@ -2287,10 +2285,10 @@ anyway).
- **Cooldown:** 30 seconds in open state before probing
**Scope:** Circuit breakers apply to **`http` and `slack` targets with
`max_retries` > 0**. The Slack target is built on the same HTTP core and hands
its own `max_retries` to the same retry path, so it gets a breaker with the same
5-failure / 30-second defaults. Fire-and-forget targets of either type
(`max_retries` == 0), database targets (local operations), and log targets
`max_attempts` > 1**. The Slack target is built on the same HTTP core and hands
its own `max_attempts` to the same retry path, so it gets a breaker with the
same 5-failure / 30-second defaults. Single-attempt targets of either type
(`max_attempts` == 1), database targets (local operations), and log targets
(stdout) do not use circuit breakers.
When a circuit is open and a new delivery arrives, the engine marks the delivery