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. The column is NOT NULL and a check constraint refuses a value
below 1, so every target, whatever its type, is stored with at least
1. 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; a blank field on the add target form gives 1, and on the
edit form keeps the stored value. 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 was merged in pull request #547.
This commit is contained in:
2026-10-15 19:38:14 +02:00
parent 147d58dd96
commit 40e8ecaab4
51 changed files with 1027 additions and 820 deletions
+23 -25
View File
@@ -1591,29 +1591,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
@@ -2186,11 +2184,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 │
└────────────┘
```
@@ -2267,7 +2265,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
@@ -2308,10 +2306,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