Skip to content

Commit b97a8f8

Browse files
author
StackMemory Bot (CLI)
committed
feat(webhook): add persistent retry with exponential backoff
Replace in-memory webhook event queue with SQLite-backed delivery queue. Failed webhook events are now retried up to 5 times with exponential backoff (1s, 2s, 4s, 8s, 16s, capped at 300s) and jitter. Deliveries persist across process restarts. - WebhookDeliveryQueue: SQLite persistence, atomic claim, background worker - Reuses existing calculateBackoff() from core/errors/recovery.ts - Health endpoint now returns delivery stats by status - 11 tests covering enqueue, retry, dead letter, concurrency, stats
1 parent f95cc25 commit b97a8f8

4 files changed

Lines changed: 621 additions & 27 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Plan: Webhook Retry with Exponential Backoff
2+
3+
## Summary
4+
5+
Add persistent retry with exponential backoff to webhook event processing. Replace the in-memory `eventQueue` in `webhook-server.ts` with a SQLite-backed delivery queue that tracks attempts, applies exponential backoff with jitter, and respects circuit breaker state.
6+
7+
## Existing Infrastructure to Leverage
8+
9+
- **`src/core/errors/recovery.ts`**: `retry()`, `calculateBackoff()`, `CircuitBreaker` — all production-ready
10+
- **`src/integrations/linear/webhook-server.ts`**: Current in-memory queue (`eventQueue[]`, `processQueue()`)
11+
- **`src/core/database/sqlite-adapter.ts`**: SQLite persistence layer
12+
- **Error codes**: `LINEAR_WEBHOOK_FAILED`, `LINEAR_API_ERROR` already exist
13+
14+
## Files to Change
15+
16+
| File | Action | Purpose |
17+
|---|---|---|
18+
| `src/integrations/linear/webhook-retry.ts` | CREATE | Delivery queue + retry worker |
19+
| `src/integrations/linear/webhook-server.ts` | MODIFY | Replace in-memory queue with persistent queue |
20+
| `src/integrations/linear/__tests__/webhook-retry.test.ts` | CREATE | Tests for retry logic |
21+
22+
## Data Model
23+
24+
New table: `webhook_deliveries` (added inline in webhook-retry.ts, not in global migrations — this is integration-scoped)
25+
26+
```sql
27+
CREATE TABLE IF NOT EXISTS webhook_deliveries (
28+
id TEXT PRIMARY KEY,
29+
event_type TEXT NOT NULL,
30+
payload TEXT NOT NULL,
31+
status TEXT NOT NULL DEFAULT 'pending', -- pending | processing | completed | failed | dead
32+
attempts INTEGER NOT NULL DEFAULT 0,
33+
max_attempts INTEGER NOT NULL DEFAULT 5,
34+
next_retry_at INTEGER, -- unix ms
35+
last_error TEXT,
36+
created_at INTEGER NOT NULL,
37+
updated_at INTEGER NOT NULL
38+
);
39+
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_status_retry
40+
ON webhook_deliveries(status, next_retry_at);
41+
```
42+
43+
## Implementation Steps
44+
45+
### Step 1: Create `webhook-retry.ts`
46+
47+
- `WebhookDeliveryQueue` class
48+
- `constructor(dbPath: string, options?: RetryConfig)` — opens/creates SQLite DB, ensures table
49+
- `enqueue(eventType: string, payload: object): string` — inserts delivery, returns ID
50+
- `processNext(): Promise<boolean>` — picks oldest `pending` or retriable `failed` delivery where `next_retry_at <= now`, marks `processing`, calls handler, updates status
51+
- `startWorker(intervalMs?: number): void` — setInterval loop calling `processNext()`
52+
- `stopWorker(): void` — clearInterval
53+
- `getStats(): { pending, processing, completed, failed, dead }` — counts by status
54+
- Uses `calculateBackoff()` from `recovery.ts` for next_retry_at computation
55+
- Marks delivery `dead` after max_attempts exceeded
56+
- Config: `{ maxAttempts: 5, initialDelay: 1000, maxDelay: 300_000, backoffFactor: 2 }`
57+
58+
### Step 2: Modify `webhook-server.ts`
59+
60+
- Replace `eventQueue: LinearWebhookPayload[]` with `WebhookDeliveryQueue` instance
61+
- In webhook endpoint handler: call `queue.enqueue()` instead of `eventQueue.push()`
62+
- Start worker in `start()`, stop in `stop()`
63+
- Remove `processQueue()` method and `isProcessing` flag
64+
65+
### Step 3: Write tests
66+
67+
- Unit tests for `WebhookDeliveryQueue`:
68+
- enqueue creates a delivery record
69+
- processNext picks the oldest pending delivery
70+
- failed delivery gets exponential backoff schedule
71+
- delivery marked dead after max_attempts
72+
- concurrent processNext doesn't double-process (status = processing guard)
73+
- getStats returns correct counts
74+
75+
## Acceptance Criteria
76+
77+
- [ ] Failed webhook events are retried up to 5 times with exponential backoff
78+
- [ ] Backoff schedule: 1s, 2s, 4s, 8s, 16s (capped at 300s)
79+
- [ ] Delivery state persisted in SQLite — survives process restart
80+
- [ ] Dead deliveries (exceeded max attempts) are logged but not retried
81+
- [ ] Existing webhook signature verification unchanged
82+
- [ ] Tests pass with 80%+ coverage on new code
83+
84+
## Risks
85+
86+
- **LOW**: SQLite write contention if webhook volume is high — mitigated by WAL mode (already used)
87+
- **LOW**: Worker interval drift — acceptable for webhook retry cadence (not real-time)
88+
89+
## Non-Goals
90+
91+
- Redis/BullMQ queue (overkill for single-process webhook handler)
92+
- Webhook delivery UI/dashboard
93+
- Dead letter queue notification
94+
- Outbound webhook sending (this is for processing *received* webhooks)
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { mkdtempSync, rmSync } from 'fs';
3+
import { tmpdir } from 'os';
4+
import { join } from 'path';
5+
import { WebhookDeliveryQueue } from '../webhook-retry.js';
6+
7+
describe('WebhookDeliveryQueue', () => {
8+
let queue: WebhookDeliveryQueue;
9+
let tempDir: string;
10+
11+
beforeEach(() => {
12+
tempDir = mkdtempSync(join(tmpdir(), 'webhook-retry-test-'));
13+
queue = new WebhookDeliveryQueue(join(tempDir, 'webhooks.db'), {
14+
maxAttempts: 3,
15+
initialDelay: 100,
16+
maxDelay: 1000,
17+
backoffFactor: 2,
18+
});
19+
});
20+
21+
afterEach(() => {
22+
queue.close();
23+
rmSync(tempDir, { recursive: true, force: true });
24+
});
25+
26+
it('enqueue creates a delivery record', () => {
27+
const id = queue.enqueue('Issue', { action: 'create', id: '123' });
28+
29+
expect(id).toBeDefined();
30+
expect(typeof id).toBe('string');
31+
32+
const delivery = queue.getDelivery(id);
33+
expect(delivery).toBeDefined();
34+
expect(delivery!.event_type).toBe('Issue');
35+
expect(delivery!.status).toBe('pending');
36+
expect(delivery!.attempts).toBe(0);
37+
expect(JSON.parse(delivery!.payload)).toEqual({
38+
action: 'create',
39+
id: '123',
40+
});
41+
});
42+
43+
it('processNext picks the oldest pending delivery', async () => {
44+
const processed: string[] = [];
45+
queue.setHandler(async (eventType) => {
46+
processed.push(eventType);
47+
});
48+
49+
queue.enqueue('Issue', { action: 'create' });
50+
queue.enqueue('Comment', { action: 'update' });
51+
52+
await queue.processNext();
53+
expect(processed).toEqual(['Issue']);
54+
55+
await queue.processNext();
56+
expect(processed).toEqual(['Issue', 'Comment']);
57+
});
58+
59+
it('returns false when no deliveries are available', async () => {
60+
queue.setHandler(async () => {});
61+
const result = await queue.processNext();
62+
expect(result).toBe(false);
63+
});
64+
65+
it('returns false when no handler is set', async () => {
66+
queue.enqueue('Issue', { action: 'create' });
67+
const result = await queue.processNext();
68+
expect(result).toBe(false);
69+
});
70+
71+
it('marks delivery completed on success', async () => {
72+
queue.setHandler(async () => {});
73+
74+
const id = queue.enqueue('Issue', { action: 'create' });
75+
await queue.processNext();
76+
77+
const delivery = queue.getDelivery(id);
78+
expect(delivery!.status).toBe('completed');
79+
expect(delivery!.attempts).toBe(1);
80+
expect(delivery!.last_error).toBeNull();
81+
});
82+
83+
it('failed delivery gets exponential backoff schedule', async () => {
84+
let callCount = 0;
85+
queue.setHandler(async () => {
86+
callCount++;
87+
throw new Error('Service unavailable');
88+
});
89+
90+
const id = queue.enqueue('Issue', { action: 'create' });
91+
92+
// First attempt fails
93+
await queue.processNext();
94+
95+
const delivery = queue.getDelivery(id);
96+
expect(delivery!.status).toBe('failed');
97+
expect(delivery!.attempts).toBe(1);
98+
expect(delivery!.last_error).toBe('Service unavailable');
99+
expect(delivery!.next_retry_at).toBeGreaterThan(Date.now() - 1000);
100+
101+
// Should not pick up the delivery again immediately (backoff not elapsed)
102+
const result = await queue.processNext();
103+
expect(result).toBe(false);
104+
expect(callCount).toBe(1);
105+
});
106+
107+
it('delivery marked dead after max_attempts', async () => {
108+
queue.setHandler(async () => {
109+
throw new Error('Permanent failure');
110+
});
111+
112+
const id = queue.enqueue('Issue', { action: 'create' });
113+
114+
// Force process through all attempts by manipulating next_retry_at
115+
for (let i = 0; i < 3; i++) {
116+
// Set next_retry_at to past so it's immediately eligible
117+
const db = (queue as any).db;
118+
db.prepare(
119+
`UPDATE webhook_deliveries SET next_retry_at = 0 WHERE id = ?`
120+
).run(id);
121+
await queue.processNext();
122+
}
123+
124+
const delivery = queue.getDelivery(id);
125+
expect(delivery!.status).toBe('dead');
126+
expect(delivery!.attempts).toBe(3);
127+
expect(delivery!.last_error).toBe('Permanent failure');
128+
});
129+
130+
it('concurrent processNext does not double-process', async () => {
131+
let concurrent = 0;
132+
let maxConcurrent = 0;
133+
134+
queue.setHandler(async () => {
135+
concurrent++;
136+
maxConcurrent = Math.max(maxConcurrent, concurrent);
137+
await new Promise((r) => setTimeout(r, 50));
138+
concurrent--;
139+
});
140+
141+
queue.enqueue('Issue', { action: 'a' });
142+
143+
// Start two processNext calls simultaneously
144+
const [r1, r2] = await Promise.all([
145+
queue.processNext(),
146+
queue.processNext(),
147+
]);
148+
149+
// One should process, the other should find nothing
150+
expect([r1, r2].filter(Boolean).length).toBe(1);
151+
expect(maxConcurrent).toBe(1);
152+
});
153+
154+
it('getStats returns correct counts', async () => {
155+
queue.setHandler(async (_type, payload) => {
156+
const p = payload as { shouldFail?: boolean };
157+
if (p.shouldFail) throw new Error('fail');
158+
});
159+
160+
queue.enqueue('Issue', {});
161+
queue.enqueue('Comment', { shouldFail: true });
162+
queue.enqueue('Project', {});
163+
164+
// Process first two
165+
await queue.processNext(); // Issue -> completed
166+
await queue.processNext(); // Comment -> failed
167+
168+
const stats = queue.getStats();
169+
expect(stats.completed).toBe(1);
170+
expect(stats.failed).toBe(1);
171+
expect(stats.pending).toBe(1);
172+
expect(stats.dead).toBe(0);
173+
});
174+
175+
it('retries succeed on later attempt', async () => {
176+
let callCount = 0;
177+
queue.setHandler(async () => {
178+
callCount++;
179+
if (callCount < 3) throw new Error('Transient error');
180+
});
181+
182+
const id = queue.enqueue('Issue', { action: 'create' });
183+
184+
// First attempt fails
185+
await queue.processNext();
186+
expect(queue.getDelivery(id)!.status).toBe('failed');
187+
188+
// Force retry eligible
189+
const db = (queue as any).db;
190+
db.prepare(
191+
`UPDATE webhook_deliveries SET next_retry_at = 0 WHERE id = ?`
192+
).run(id);
193+
194+
// Second attempt fails
195+
await queue.processNext();
196+
expect(queue.getDelivery(id)!.status).toBe('failed');
197+
198+
// Force retry eligible again
199+
db.prepare(
200+
`UPDATE webhook_deliveries SET next_retry_at = 0 WHERE id = ?`
201+
).run(id);
202+
203+
// Third attempt succeeds
204+
await queue.processNext();
205+
expect(queue.getDelivery(id)!.status).toBe('completed');
206+
expect(queue.getDelivery(id)!.attempts).toBe(3);
207+
});
208+
209+
it('worker starts and stops cleanly', () => {
210+
queue.setHandler(async () => {});
211+
queue.startWorker();
212+
213+
// Starting again is a no-op
214+
queue.startWorker();
215+
216+
queue.stopWorker();
217+
// Stopping again is a no-op
218+
queue.stopWorker();
219+
});
220+
});

0 commit comments

Comments
 (0)