fix: rate limiting breaking smoke tests and backup routes 404 when disabled
- Exempt /healthz and /health from rate limiter - Increase rate limit from 500 to 2000 req/min - Register backup routes even when disabled (status shows disabled) - Guard restore endpoints with 503 when backup not configured - Add retry with backoff on 429 in audit smoke tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -465,11 +465,13 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
gitBackup.setCallbacks(importResource, deleteResource);
|
gitBackup.setCallbacks(importResource, deleteResource);
|
||||||
registerGitBackupRoutes(app, gitBackup);
|
|
||||||
// Init async — don't block server startup
|
// Init async — don't block server startup
|
||||||
gitBackup.init().catch((err) => app.log.error({ err }, 'Git backup init failed'));
|
gitBackup.init().catch((err) => app.log.error({ err }, 'Git backup init failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always register backup routes (status shows disabled when no repo configured)
|
||||||
|
registerGitBackupRoutes(app, gitBackup);
|
||||||
|
|
||||||
// ── RBAC list filtering hook ──
|
// ── RBAC list filtering hook ──
|
||||||
// Filters array responses to only include resources the user is allowed to see.
|
// Filters array responses to only include resources the user is allowed to see.
|
||||||
app.addHook('preSerialization', async (request, _reply, payload) => {
|
app.addHook('preSerialization', async (request, _reply, payload) => {
|
||||||
|
|||||||
@@ -20,5 +20,10 @@ export async function registerSecurityPlugins(
|
|||||||
await app.register(rateLimit, {
|
await app.register(rateLimit, {
|
||||||
max: config.rateLimitMax,
|
max: config.rateLimitMax,
|
||||||
timeWindow: config.rateLimitWindowMs,
|
timeWindow: config.rateLimitWindowMs,
|
||||||
|
allowList: (req) => {
|
||||||
|
// Exempt health probes and internal monitoring from rate limiting
|
||||||
|
const url = req.url;
|
||||||
|
return url === '/healthz' || url === '/health';
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export function registerGitBackupRoutes(app: FastifyInstance, gitBackup: GitBack
|
|||||||
|
|
||||||
// POST /api/v1/backup/restore/preview — preview restore
|
// POST /api/v1/backup/restore/preview — preview restore
|
||||||
app.post<{ Body: { commit: string } }>('/api/v1/backup/restore/preview', async (request, reply) => {
|
app.post<{ Body: { commit: string } }>('/api/v1/backup/restore/preview', async (request, reply) => {
|
||||||
|
if (!gitBackup.enabled) {
|
||||||
|
return reply.code(503).send({ error: 'Backup is not configured' });
|
||||||
|
}
|
||||||
const { commit } = request.body ?? {};
|
const { commit } = request.body ?? {};
|
||||||
if (!commit) {
|
if (!commit) {
|
||||||
return reply.code(400).send({ error: 'commit is required' });
|
return reply.code(400).send({ error: 'commit is required' });
|
||||||
@@ -39,6 +42,9 @@ export function registerGitBackupRoutes(app: FastifyInstance, gitBackup: GitBack
|
|||||||
|
|
||||||
// POST /api/v1/backup/restore — restore to a commit
|
// POST /api/v1/backup/restore — restore to a commit
|
||||||
app.post<{ Body: { commit: string } }>('/api/v1/backup/restore', async (request, reply) => {
|
app.post<{ Body: { commit: string } }>('/api/v1/backup/restore', async (request, reply) => {
|
||||||
|
if (!gitBackup.enabled) {
|
||||||
|
return reply.code(503).send({ error: 'Backup is not configured' });
|
||||||
|
}
|
||||||
const { commit } = request.body ?? {};
|
const { commit } = request.body ?? {};
|
||||||
if (!commit) {
|
if (!commit) {
|
||||||
return reply.code(400).send({ error: 'commit is required' });
|
return reply.code(400).send({ error: 'commit is required' });
|
||||||
|
|||||||
@@ -65,24 +65,32 @@ interface AuditSessionResult {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch JSON from mcpd REST API (with auth from credentials). */
|
/** Fetch JSON from mcpd REST API (with auth from credentials). Retries on rate limit. */
|
||||||
function mcpdGet<T>(path: string): Promise<T> {
|
async function mcpdGet<T>(path: string, retries = 3): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
const url = new URL(path, MCPD_EFFECTIVE_URL);
|
const result = await new Promise<{ status: number; body: T }>((resolve, reject) => {
|
||||||
const headers: Record<string, string> = { 'Accept': 'application/json' };
|
const url = new URL(path, MCPD_EFFECTIVE_URL);
|
||||||
if (MCPD_CREDS.token) headers['Authorization'] = `Bearer ${MCPD_CREDS.token}`;
|
const headers: Record<string, string> = { 'Accept': 'application/json' };
|
||||||
http.get(url, { timeout: 10_000, headers }, (res) => {
|
if (MCPD_CREDS.token) headers['Authorization'] = `Bearer ${MCPD_CREDS.token}`;
|
||||||
const chunks: Buffer[] = [];
|
http.get(url, { timeout: 10_000, headers }, (res) => {
|
||||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
const chunks: Buffer[] = [];
|
||||||
res.on('end', () => {
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
try {
|
res.on('end', () => {
|
||||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')) as T);
|
try {
|
||||||
} catch (err) {
|
resolve({ status: res.statusCode ?? 0, body: JSON.parse(Buffer.concat(chunks).toString('utf-8')) as T });
|
||||||
reject(err);
|
} catch (err) {
|
||||||
}
|
reject(err);
|
||||||
});
|
}
|
||||||
}).on('error', reject);
|
});
|
||||||
});
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
if (result.status === 429 && attempt < retries) {
|
||||||
|
await new Promise((r) => setTimeout(r, 2_000 * (attempt + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
throw new Error('mcpdGet: max retries exceeded');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Query audit events from mcpd. */
|
/** Query audit events from mcpd. */
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
MCPD_LOG_LEVEL: ${MCPD_LOG_LEVEL:-info}
|
MCPD_LOG_LEVEL: ${MCPD_LOG_LEVEL:-info}
|
||||||
MCPD_NODE_RUNNER_IMAGE: mysources.co.uk/michal/mcpctl-node-runner:latest
|
MCPD_NODE_RUNNER_IMAGE: mysources.co.uk/michal/mcpctl-node-runner:latest
|
||||||
MCPD_PYTHON_RUNNER_IMAGE: mysources.co.uk/michal/mcpctl-python-runner:latest
|
MCPD_PYTHON_RUNNER_IMAGE: mysources.co.uk/michal/mcpctl-python-runner:latest
|
||||||
MCPD_RATE_LIMIT_MAX: "500"
|
MCPD_RATE_LIMIT_MAX: "2000"
|
||||||
MCPD_MCP_NETWORK: mcp-servers
|
MCPD_MCP_NETWORK: mcp-servers
|
||||||
MCPD_BACKUP_REPO: ${MCPD_BACKUP_REPO:-}
|
MCPD_BACKUP_REPO: ${MCPD_BACKUP_REPO:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
Reference in New Issue
Block a user