Skip to content
Open
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
88 changes: 88 additions & 0 deletions apps/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,94 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
summary: Create a new deployment
description: >
Creates a new deployment for the authenticated user. Supports idempotent
creation via the `Idempotency-Key` header. Sending the same key within
24 hours returns the original response without triggering a duplicate
deployment. Keys are scoped per user — the same key string from
different users creates independent deployments.
tags:
- Deployments
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: false
schema:
type: string
maxLength: 255
description: >
Client-generated unique key for idempotent request deduplication.
If a successful response for this key was already cached within the
24-hour window, that response is returned immediately with the
`Idempotent-Replayed: true` header instead of creating a duplicate
deployment. Recommended format: UUIDv4.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- templateId
properties:
templateId:
type: string
description: ID of the template to deploy.
name:
type: string
description: Optional display name for the deployment.
customizationConfig:
type: object
description: Template-specific customization options.
responses:
'201':
description: Deployment created successfully.
headers:
Idempotent-Replayed:
schema:
type: string
enum: ['true']
description: >
Present and set to `true` when the response was served from the
idempotency cache rather than creating a new deployment.
content:
application/json:
schema:
$ref: '#/components/schemas/Deployment'
'400':
description: Invalid request body.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Unauthorized.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: Deployment limit reached for the current subscription tier.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Template not found.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'422':
description: Invalid customization config or Stellar endpoints.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'

/deployments/{id}/analytics:
get:
Expand Down
10 changes: 7 additions & 3 deletions apps/backend/src/app/api/deployments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
validateCustomizationConfig,
validateStellarEndpoints,
} from '@/lib/customization/validate';
import { withIdempotency } from '@/lib/api/idempotency';

type RequestBody = {
templateId: string;
Expand Down Expand Up @@ -213,8 +214,11 @@ export const GET = withAuth(async (req: NextRequest, ctx: any) =>
deploymentRouter.handle(req, 'GET', ctx),
);

export const POST = withAuth(async (req: NextRequest, ctx: any) =>
deploymentRouter.handle(req, 'POST', ctx),
);
export const POST = withAuth(async (req: NextRequest, ctx: any) => {
const handler = withIdempotency(ctx.user.id, (r) =>
deploymentRouter.handle(r, 'POST', ctx),
);
return handler(req);
});

export { deploymentRouter };
16 changes: 15 additions & 1 deletion apps/backend/src/lib/api/circuit-breaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface CircuitBreakerConfig {
resetTimeoutMs?: number;
/** Injected clock — override in tests. Default: Date.now */
now?: () => number;
/** Called whenever the circuit transitions between states. */
onStateChange?: (name: string, from: CircuitState, to: CircuitState, metadata?: Record<string, unknown>) => void;
}

/** Thrown when a call is rejected because the circuit is OPEN. */
Expand All @@ -49,13 +51,15 @@ export class CircuitBreaker {
private readonly failureThreshold: number;
private readonly resetTimeoutMs: number;
private readonly now: () => number;
private readonly onStateChange?: CircuitBreakerConfig['onStateChange'];
readonly name: string;

constructor(config: CircuitBreakerConfig) {
this.name = config.name;
this.failureThreshold = config.failureThreshold ?? 5;
this.resetTimeoutMs = config.resetTimeoutMs ?? 30_000;
this.now = config.now ?? Date.now;
this.onStateChange = config.onStateChange;
}

get currentState(): CircuitState {
Expand Down Expand Up @@ -96,24 +100,34 @@ export class CircuitBreaker {
private transitionIfDue(): void {
if (this.state === 'OPEN' && this.openedAt !== null) {
if (this.now() - this.openedAt >= this.resetTimeoutMs) {
this.state = 'HALF_OPEN';
this.transition('HALF_OPEN', { waitedMs: this.now() - this.openedAt });
}
}
}

private onSuccess(): void {
const prev = this.state;
this.failureCount = 0;
this.openedAt = null;
this.state = 'CLOSED';
if (prev !== 'CLOSED') this.onStateChange?.(this.name, prev, 'CLOSED');
}

private onFailure(): void {
this.failureCount += 1;

if (this.state === 'HALF_OPEN' || this.failureCount >= this.failureThreshold) {
const prev = this.state;
this.state = 'OPEN';
this.openedAt = this.now();
this.failureCount = 0;
this.onStateChange?.(this.name, prev, 'OPEN', { resetTimeoutMs: this.resetTimeoutMs });
}
}

private transition(to: CircuitState, metadata?: Record<string, unknown>): void {
const from = this.state;
this.state = to;
this.onStateChange?.(this.name, from, to, metadata);
}
}
176 changes: 176 additions & 0 deletions apps/backend/src/lib/api/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Unit tests for the request deduplication middleware — Issue #587
*
* Tests:
* - No key → handler always called
* - Same key + same user → cached response returned on second call
* - Same key + different user → separate deployments (no collision)
* - Different keys + same user → separate deployments
* - Idempotent-Replayed header present on cached responses
* - Non-2xx responses are not cached
* - Expired entries are not served (TTL respected)
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest, NextResponse } from 'next/server';
import {
withIdempotency,
clearIdempotencyCache,
IDEMPOTENCY_KEY_HEADER,
} from './idempotency';

function makeRequest(idempotencyKey?: string): NextRequest {
const headers: Record<string, string> = {};
if (idempotencyKey) headers[IDEMPOTENCY_KEY_HEADER] = idempotencyKey;

return new NextRequest('http://localhost/api/deployments', {
method: 'POST',
headers,
body: JSON.stringify({ templateId: 'tpl_1' }),
});
}

function makeHandler(status: number, body: unknown) {
return vi.fn().mockResolvedValue(NextResponse.json(body, { status }));
}

beforeEach(() => {
clearIdempotencyCache();
vi.unstubAllEnvs();
});

// ── No Idempotency-Key ────────────────────────────────────────────────────────

describe('withIdempotency — no key', () => {
it('calls the handler on every request when no key is supplied', async () => {
const handler = makeHandler(201, { id: 'dep_1' });
const wrapped = withIdempotency('user_a', handler);

await wrapped(makeRequest());
await wrapped(makeRequest());

expect(handler).toHaveBeenCalledTimes(2);
});
});

// ── Same key + same user: deduplication ───────────────────────────────────────

describe('withIdempotency — duplicate key same user', () => {
it('returns the original response on the second request without calling the handler again', async () => {
const handler = makeHandler(201, { id: 'dep_1', status: 'pending' });
const wrapped = withIdempotency('user_a', handler);

const r1 = await wrapped(makeRequest('key-abc'));
const r2 = await wrapped(makeRequest('key-abc'));

expect(handler).toHaveBeenCalledTimes(1);
expect(r2.status).toBe(201);
expect(r2.headers.get('Idempotent-Replayed')).toBe('true');

const body1 = await r1.json();
const body2 = await r2.json();
expect(body1).toEqual(body2);
});

it('does not set Idempotent-Replayed on the first (live) response', async () => {
const handler = makeHandler(201, { id: 'dep_1' });
const wrapped = withIdempotency('user_a', handler);

const r1 = await wrapped(makeRequest('key-abc'));
expect(r1.headers.get('Idempotent-Replayed')).toBeNull();
});
});

// ── Cross-user key isolation ──────────────────────────────────────────────────

describe('withIdempotency — cross-user isolation', () => {
it('same key string for different users creates separate deployments', async () => {
const handlerA = makeHandler(201, { id: 'dep_for_a' });
const handlerB = makeHandler(201, { id: 'dep_for_b' });

const wrappedA = withIdempotency('user_a', handlerA);
const wrappedB = withIdempotency('user_b', handlerB);

await wrappedA(makeRequest('shared-key'));
await wrappedB(makeRequest('shared-key'));

// Both handlers called — no cross-tenant collision
expect(handlerA).toHaveBeenCalledTimes(1);
expect(handlerB).toHaveBeenCalledTimes(1);
});

it('cached response for user_a is not returned to user_b', async () => {
const handlerA = makeHandler(201, { id: 'dep_for_a' });
const handlerB = makeHandler(201, { id: 'dep_for_b' });

const wrappedA = withIdempotency('user_a', handlerA);
const wrappedB = withIdempotency('user_b', handlerB);

await wrappedA(makeRequest('shared-key'));
const rb = await wrappedB(makeRequest('shared-key'));

const body = await rb.json();
expect(body.id).toBe('dep_for_b');
expect(rb.headers.get('Idempotent-Replayed')).toBeNull();
});
});

// ── Different keys, same user ─────────────────────────────────────────────────

describe('withIdempotency — different keys same user', () => {
it('different keys create separate cache entries and call the handler each time', async () => {
const handler = makeHandler(201, { id: 'dep_1' });
const wrapped = withIdempotency('user_a', handler);

await wrapped(makeRequest('key-1'));
await wrapped(makeRequest('key-2'));

expect(handler).toHaveBeenCalledTimes(2);
});
});

// ── Non-2xx responses not cached ──────────────────────────────────────────────

describe('withIdempotency — non-2xx not cached', () => {
it('does not cache 4xx error responses', async () => {
const handler = makeHandler(422, { error: 'Invalid config' });
const wrapped = withIdempotency('user_a', handler);

await wrapped(makeRequest('key-err'));
await wrapped(makeRequest('key-err'));

// Handler called twice — error was not cached
expect(handler).toHaveBeenCalledTimes(2);
});

it('does not cache 5xx error responses', async () => {
const handler = makeHandler(500, { error: 'Internal server error' });
const wrapped = withIdempotency('user_a', handler);

await wrapped(makeRequest('key-err'));
await wrapped(makeRequest('key-err'));

expect(handler).toHaveBeenCalledTimes(2);
});
});

// ── TTL expiry ────────────────────────────────────────────────────────────────

describe('withIdempotency — TTL expiry', () => {
it('re-calls the handler after the TTL has elapsed', async () => {
// Set a very short TTL
vi.stubEnv('IDEMPOTENCY_TTL_MS', '1');

const handler = makeHandler(201, { id: 'dep_1' });
const wrapped = withIdempotency('user_a', handler);

await wrapped(makeRequest('key-ttl'));

// Wait for expiry (1 ms TTL)
await new Promise((r) => setTimeout(r, 10));

await wrapped(makeRequest('key-ttl'));

expect(handler).toHaveBeenCalledTimes(2);
});
});
Loading
Loading