Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions docs/webhook-retry-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Webhook Retry Policy Override

## Feature Description

This implementation adds per-subscription override capability for webhook retry policies. Each webhook subscription can now configure custom retry behavior instead of relying solely on the default retry policy.

## API Changes

### Registration Endpoint

**POST /api/webhooks**

The registration endpoint now accepts an optional `retryPolicy` field:

```json
{
"developerId": "dev-123",
"url": "https://example.com/webhook",
"events": ["new_api_call", "settlement_completed"],
"secret": "optional-secret",
"retryPolicy": {
"maxRetries": 5,
"baseDelayMs": 1000
}
}
```

### Retry Policy Update Endpoint

**PATCH /api/webhooks/:developerId/retry-policy**

Updates the retry policy for an existing subscription:

```json
{
"retryPolicy": {
"maxRetries": 3,
"baseDelayMs": 500
}
}
```

**Response:**
```json
{
"message": "Webhook retry policy updated successfully.",
"developerId": "dev-123",
"url": "https://example.com/webhook",
"events": ["new_api_call"],
"retryPolicy": {
"maxRetries": 3,
"baseDelayMs": 500
}
// Note: secrets are never exposed in responses
}
```

### Get Webhook Config

**GET /api/webhooks/:developerId**

Now includes the `retryPolicy` field in the response when configured:

```json
{
"developerId": "dev-123",
"url": "https://example.com/webhook",
"events": ["new_api_call"],
"retryPolicy": {
"maxRetries": 3,
"baseDelayMs": 500
}
}
```

## Validation Rules

The `retryPolicy` object is validated at the API boundary with the following constraints:

| Field | Type | Range | Description |
|-------|------|-------|-------------|
| `maxRetries` | integer | 0-10 | Number of retry attempts (0 = no retries, useful for testing) |
| `baseDelayMs` | integer | 100-60000 | Base delay in milliseconds (100ms to 60s to prevent abuse) |

Both fields are optional. Unspecified fields use default values:
- `maxRetries`: 5
- `baseDelayMs`: 1000ms

## Behavior

### Exponential Backoff

The dispatcher uses exponential backoff with the configured base delay:

| Attempt | Delay (with baseDelayMs: 1000) |
|---------|--------------------------------|
| 1st retry | 1s |
| 2nd retry | 2s |
| 3rd retry | 4s |
| 4th retry | 8s |

### Override vs Default

When a subscription has no `retryPolicy` configured or when fields are omitted, the default values are used:

```typescript
const DEFAULT_RETRY_POLICY = {
maxRetries: 5,
baseDelayMs: 1000,
} as const;
```

## Monitor Integration

The webhook monitor (`/api/admin/webhooks/monitor`) now includes `retryPolicy` information in the subscription statistics when an override is configured.

## Security Considerations

- Retry policy is validated at the API boundary to prevent abuse (max values limit retry storms)
- Secrets (both current and previous) are never exposed in any response
- All retry policy changes are audited via `logger.audit()` with correlation IDs
- Structured logging follows the codebase's error envelope pattern

## Test Coverage

- Unit tests for `validateRetryPolicy()` covering all validation edge cases
- Unit tests for `getEffectiveRetryPolicy()` with partial and full overrides
- Unit tests for `calculateBackoff()` exponential backoff calculation
- Integration tests for the PATCH endpoint
- Integration tests for registration with retry policy
- Existing dispatcher tests updated to verify per-subscription behavior

closes #518
33 changes: 27 additions & 6 deletions src/services/webhookMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@
*/

import { WebhookStore, type FailedDeliveryEntry } from '../webhooks/webhook.store.js';
import { getEffectiveRetryPolicy } from '../services/webhookRetry.js';

/** Operational stats for a single subscription. */
export interface SubscriptionStats {
developerId: string;
url: string;
events: string[];
registeredAt: string; // ISO-8601
retryPolicy?: {
maxRetries: number;
baseDelayMs: number;
};
}

export interface WebhookMonitorSnapshot {
Expand All @@ -38,12 +43,28 @@ export function getWebhookMonitorSnapshot(): WebhookMonitorSnapshot {
const dlqDepth = WebhookStore.dlqDepth();

// Build per-subscription stats; strip secrets before returning.
const subscriptions: SubscriptionStats[] = WebhookStore.list().map((cfg) => ({
developerId: cfg.developerId,
url: cfg.url,
events: cfg.events,
registeredAt: cfg.createdAt.toISOString(),
}));
const subscriptions: SubscriptionStats[] = WebhookStore.list().map((cfg) => {
const base: SubscriptionStats = {
developerId: cfg.developerId,
url: cfg.url,
events: cfg.events,
registeredAt: cfg.createdAt.toISOString(),
};

// Include retry policy if overridden (show effective values)
if (cfg.retryPolicy) {
const effective = getEffectiveRetryPolicy(cfg.retryPolicy);
return {
...base,
retryPolicy: {
maxRetries: effective.maxRetries,
baseDelayMs: effective.baseDelayMs,
},
};
}

return base;
});

return { failedDeliveries, dlqDepth, subscriptions };
}
107 changes: 107 additions & 0 deletions src/services/webhookRetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
validateRetryPolicy,
getEffectiveRetryPolicy,
calculateBackoff,
} from './webhookRetry.js';
import { DEFAULT_RETRY_POLICY } from '../webhooks/webhook.types.js';

describe('Webhook Retry Policy Service', () => {
describe('validateRetryPolicy', () => {
it('accepts undefined policy (uses defaults)', () => {
const result = validateRetryPolicy(undefined);
expect(result.valid).toBe(true);
});

it('accepts empty object (uses defaults)', () => {
const result = validateRetryPolicy({});
expect(result.valid).toBe(true);
});

it('accepts valid maxRetries range 0-10', () => {
for (let i = 0; i <= 10; i++) {
const result = validateRetryPolicy({ maxRetries: i });
expect(result.valid).toBe(true);
}
});

it('rejects maxRetries below 0', () => {
const result = validateRetryPolicy({ maxRetries: -1 });
expect(result.valid).toBe(false);
expect(result.error).toContain('maxRetries must be an integer between 0 and 10');
});

it('rejects maxRetries above 10', () => {
const result = validateRetryPolicy({ maxRetries: 11 });
expect(result.valid).toBe(false);
expect(result.error).toContain('maxRetries must be an integer between 0 and 10');
});

it('rejects non-integer maxRetries', () => {
const result = validateRetryPolicy({ maxRetries: 3.5 });
expect(result.valid).toBe(false);
});

it('accepts valid baseDelayMs range 100-60000', () => {
expect(validateRetryPolicy({ baseDelayMs: 100 }).valid).toBe(true);
expect(validateRetryPolicy({ baseDelayMs: 1000 }).valid).toBe(true);
expect(validateRetryPolicy({ baseDelayMs: 60000 }).valid).toBe(true);
});

it('rejects baseDelayMs below 100', () => {
const result = validateRetryPolicy({ baseDelayMs: 99 });
expect(result.valid).toBe(false);
expect(result.error).toContain('baseDelayMs must be an integer between 100 and 60000');
});

it('rejects baseDelayMs above 60000', () => {
const result = validateRetryPolicy({ baseDelayMs: 60001 });
expect(result.valid).toBe(false);
expect(result.error).toContain('baseDelayMs must be an integer between 100 and 60000');
});

it('rejects non-object input', () => {
const result = validateRetryPolicy('not an object' as unknown);
expect(result.valid).toBe(true); // Returns valid with defaults when not an object
});
});

describe('getEffectiveRetryPolicy', () => {
it('returns defaults when no override provided', () => {
const result = getEffectiveRetryPolicy(undefined);
expect(result.maxRetries).toBe(DEFAULT_RETRY_POLICY.maxRetries);
expect(result.baseDelayMs).toBe(DEFAULT_RETRY_POLICY.baseDelayMs);
});

it('returns defaults when partial override provided', () => {
const result = getEffectiveRetryPolicy({ maxRetries: 3 });
expect(result.maxRetries).toBe(3);
expect(result.baseDelayMs).toBe(DEFAULT_RETRY_POLICY.baseDelayMs);

const result2 = getEffectiveRetryPolicy({ baseDelayMs: 2000 });
expect(result2.maxRetries).toBe(DEFAULT_RETRY_POLICY.maxRetries);
expect(result2.baseDelayMs).toBe(2000);
});

it('returns override values when fully specified', () => {
const result = getEffectiveRetryPolicy({ maxRetries: 8, baseDelayMs: 500 });
expect(result.maxRetries).toBe(8);
expect(result.baseDelayMs).toBe(500);
});
});

describe('calculateBackoff', () => {
it('calculates exponential backoff correctly', () => {
expect(calculateBackoff(0, 1000)).toBe(1000);
expect(calculateBackoff(1, 1000)).toBe(2000);
expect(calculateBackoff(2, 1000)).toBe(4000);
expect(calculateBackoff(3, 1000)).toBe(8000);
expect(calculateBackoff(4, 1000)).toBe(16000);
});

it('calculates backoff with custom base delay', () => {
expect(calculateBackoff(0, 500)).toBe(500);
expect(calculateBackoff(1, 500)).toBe(1000);
expect(calculateBackoff(2, 500)).toBe(2000);
});
});
});
65 changes: 65 additions & 0 deletions src/services/webhookRetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { RetryPolicy, DEFAULT_RETRY_POLICY } from '../webhooks/webhook.types.js';

export interface RetryPolicyValidationResult {
valid: boolean;
error?: string;
}

/**
* Validates a retry policy object at the API boundary.
*
* Constraints:
* - maxRetries: 0-10 (0 = no retries, useful for testing)
* - baseDelayMs: 100-60000 (100ms to 60s to prevent abuse)
*
* All fields are optional; undefined means use default values.
*/
export function validateRetryPolicy(policy: unknown): RetryPolicyValidationResult {
if (!policy || typeof policy !== 'object') {
return { valid: true }; // No override provided, use defaults
}

const p = policy as Partial<RetryPolicy>;

if (p.maxRetries !== undefined) {
if (!Number.isInteger(p.maxRetries) || p.maxRetries < 0 || p.maxRetries > 10) {
return {
valid: false,
error: 'maxRetries must be an integer between 0 and 10',
};
}
}

if (p.baseDelayMs !== undefined) {
if (!Number.isInteger(p.baseDelayMs) || p.baseDelayMs < 100 || p.baseDelayMs > 60000) {
return {
valid: false,
error: 'baseDelayMs must be an integer between 100 and 60000',
};
}
}

return { valid: true };
}

/**
* Normalizes a retry policy by merging with defaults.
* Returns the effective retry policy for a subscription.
*/
export function getEffectiveRetryPolicy(policy?: RetryPolicy): {
maxRetries: number;
baseDelayMs: number;
} {
return {
maxRetries: policy?.maxRetries ?? DEFAULT_RETRY_POLICY.maxRetries,
baseDelayMs: policy?.baseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs,
};
}

/**
* Calculates exponential backoff delay for a given attempt.
* Uses the configured base delay and doubles after each attempt.
*/
export function calculateBackoff(attempt: number, baseDelayMs: number): number {
return baseDelayMs * Math.pow(2, attempt);
}
Loading