feat(backend): webhooks deliver signed alerts from an outbox with retries and a test send
This commit is contained in:
1 parent
2bb8ca45fb
commit
9da0c06ac1
8 files changed
+502
No files matched your search
@@ -41,6 +41,7 @@ class ApiSettings(BaseSettings):
|
||||
host: str = "0.0.0.0" # noqa: S104
|
||||
port: int = 8080
|
||||
static_dir: str = "static"
|
||||
public_url: str | None = None
|
||||
|
||||
|
||||
class AuthSettings(BaseSettings):
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
import asyncpg
|
||||
|
||||
from utils.read.models import DeliveryView, WebhookView
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_SENDING = "sending"
|
||||
STATUS_DELIVERED = "delivered"
|
||||
STATUS_FAILED = "failed"
|
||||
DELIVERY_LOG = 20
|
||||
|
||||
_COLS = "id, account_id, url, secret, enabled, watch_ids, created_at, updated_at"
|
||||
_DELIVERY_COLS = (
|
||||
"id, webhook_id, alert_id, event, status, attempts, next_attempt_at, "
|
||||
"response_code, last_error, created_at, finished_at"
|
||||
)
|
||||
|
||||
_CLAIM = """
|
||||
UPDATE webhook_deliveries d SET status = 'sending', attempts = d.attempts + 1
|
||||
FROM webhooks w
|
||||
WHERE w.id = d.webhook_id AND d.id IN (
|
||||
SELECT id FROM webhook_deliveries
|
||||
WHERE status = 'pending' AND next_attempt_at <= now()
|
||||
ORDER BY next_attempt_at LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING d.id, d.alert_id, d.event, d.attempts, d.body,
|
||||
w.id AS webhook_id, w.account_id, w.url, w.secret
|
||||
"""
|
||||
|
||||
_FINISH = """
|
||||
UPDATE webhook_deliveries SET
|
||||
status = $2, response_code = $3, last_error = $4,
|
||||
next_attempt_at = coalesce($5, next_attempt_at),
|
||||
finished_at = CASE WHEN $2 = 'pending' THEN NULL ELSE now() END
|
||||
WHERE id = $1
|
||||
"""
|
||||
|
||||
|
||||
def new_secret() -> str:
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
async def list_webhooks(pool: asyncpg.Pool, account_id: int) -> list[WebhookView]:
|
||||
rows = await pool.fetch(
|
||||
f"SELECT {_COLS} FROM webhooks WHERE account_id = $1 ORDER BY id", # noqa: S608
|
||||
account_id,
|
||||
)
|
||||
return [WebhookView(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def get_webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
|
||||
row = await pool.fetchrow(
|
||||
f"SELECT {_COLS} FROM webhooks WHERE id = $1", # noqa: S608
|
||||
webhook_id,
|
||||
)
|
||||
return WebhookView(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def create_webhook(
|
||||
pool: asyncpg.Pool,
|
||||
account_id: int,
|
||||
url: str,
|
||||
watch_ids: list[int] | None,
|
||||
*,
|
||||
enabled: bool,
|
||||
) -> WebhookView:
|
||||
row = await pool.fetchrow(
|
||||
"INSERT INTO webhooks (account_id, url, secret, enabled, watch_ids) " # noqa: S608
|
||||
f"VALUES ($1, $2, $3, $4, $5) RETURNING {_COLS}",
|
||||
account_id,
|
||||
url,
|
||||
new_secret(),
|
||||
enabled,
|
||||
watch_ids,
|
||||
)
|
||||
return WebhookView(**dict(row))
|
||||
|
||||
|
||||
async def update_webhook(
|
||||
pool: asyncpg.Pool,
|
||||
webhook_id: int,
|
||||
url: str,
|
||||
watch_ids: list[int] | None,
|
||||
*,
|
||||
enabled: bool,
|
||||
) -> WebhookView | None:
|
||||
async with pool.acquire() as conn, conn.transaction():
|
||||
row = await conn.fetchrow(
|
||||
"UPDATE webhooks SET url = $2, enabled = $3, watch_ids = $4, " # noqa: S608
|
||||
f"updated_at = now() WHERE id = $1 RETURNING {_COLS}",
|
||||
webhook_id,
|
||||
url,
|
||||
enabled,
|
||||
watch_ids,
|
||||
)
|
||||
if row is not None and not enabled:
|
||||
await conn.execute(
|
||||
"UPDATE webhook_deliveries SET status = 'failed', "
|
||||
"last_error = 'endpoint disabled', finished_at = now() "
|
||||
"WHERE webhook_id = $1 AND status = 'pending'",
|
||||
webhook_id,
|
||||
)
|
||||
return WebhookView(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def rotate_secret(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
|
||||
row = await pool.fetchrow(
|
||||
"UPDATE webhooks SET secret = $2, updated_at = now() " # noqa: S608
|
||||
f"WHERE id = $1 RETURNING {_COLS}",
|
||||
webhook_id,
|
||||
new_secret(),
|
||||
)
|
||||
return WebhookView(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def delete_webhook(pool: asyncpg.Pool, webhook_id: int) -> bool:
|
||||
result = await pool.execute("DELETE FROM webhooks WHERE id = $1", webhook_id)
|
||||
return result.endswith("1")
|
||||
|
||||
|
||||
async def list_deliveries(
|
||||
pool: asyncpg.Pool, webhook_id: int, limit: int = DELIVERY_LOG
|
||||
) -> list[DeliveryView]:
|
||||
rows = await pool.fetch(
|
||||
f"SELECT {_DELIVERY_COLS} FROM webhook_deliveries " # noqa: S608
|
||||
"WHERE webhook_id = $1 ORDER BY id DESC LIMIT $2",
|
||||
webhook_id,
|
||||
limit,
|
||||
)
|
||||
return [DeliveryView(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
async def get_delivery(pool: asyncpg.Pool, delivery_id: int) -> DeliveryView | None:
|
||||
row = await pool.fetchrow(
|
||||
f"SELECT {_DELIVERY_COLS} FROM webhook_deliveries WHERE id = $1", # noqa: S608
|
||||
delivery_id,
|
||||
)
|
||||
return DeliveryView(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def start_test(pool: asyncpg.Pool, webhook_id: int, body: str) -> int:
|
||||
return await pool.fetchval(
|
||||
"INSERT INTO webhook_deliveries (webhook_id, event, status, attempts, body) "
|
||||
"VALUES ($1, 'test', 'sending', 1, $2) RETURNING id",
|
||||
webhook_id,
|
||||
body,
|
||||
)
|
||||
|
||||
|
||||
async def release_stuck(pool: asyncpg.Pool) -> None:
|
||||
await pool.execute(
|
||||
"UPDATE webhook_deliveries SET status = 'pending' WHERE status = 'sending'"
|
||||
)
|
||||
|
||||
|
||||
async def claim_due(pool: asyncpg.Pool, limit: int) -> list[asyncpg.Record]:
|
||||
return await pool.fetch(_CLAIM, limit)
|
||||
|
||||
|
||||
async def save_body(pool: asyncpg.Pool, delivery_id: int, body: str) -> None:
|
||||
await pool.execute(
|
||||
"UPDATE webhook_deliveries SET body = $2 WHERE id = $1", delivery_id, body
|
||||
)
|
||||
|
||||
|
||||
async def finish( # noqa: PLR0913
|
||||
pool: asyncpg.Pool,
|
||||
delivery_id: int,
|
||||
status: str,
|
||||
response_code: int | None,
|
||||
error: str | None,
|
||||
next_attempt_at: datetime | None = None,
|
||||
) -> None:
|
||||
await pool.execute(
|
||||
_FINISH, delivery_id, status, response_code, error, next_attempt_at
|
||||
)
|
||||
|
||||
|
||||
async def next_due(pool: asyncpg.Pool) -> datetime | None:
|
||||
return await pool.fetchval(
|
||||
"SELECT min(next_attempt_at) FROM webhook_deliveries WHERE status = 'pending'"
|
||||
)
|
||||
Reference in new issue
Block a user