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
5 changes: 5 additions & 0 deletions workspaces/lightspeed/.changeset/five-pots-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': minor
---

add rate limiting to lightspeed and notebooks
37 changes: 37 additions & 0 deletions workspaces/lightspeed/plugins/lightspeed-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,43 @@ lightspeed:
mcpServers: # Optional - one or more MCP servers
- name: <mcp server name> # must match the name configured in LCS
token: ${MCP_TOKEN}
rateLimit: # Optional - per-user request rate limits (defaults apply if omitted)
expensive:
max: 25 # Max requests per minute per user for expensive endpoints (default: 25). Set to 0 to disable.
general:
max: 200 # Max requests per minute per user for other authenticated endpoints (default: 200). Set to 0 to disable.
```

#### Rate limiting

The backend applies per-user rate limits to authenticated endpoints as an abuse
prevention measure. Limits are keyed by the authenticated user's entity ref and
use a fixed 1-minute window.

**Tiers**:

- **Expensive** (default: 25 requests/minute per user): `POST /v1/query`, and
(when Notebooks is enabled) notebook document uploads and RAG queries.
- **General** (default: 200 requests/minute per user): all other authenticated
endpoints, including conversation listing, MCP server management, feedback,
and notebook session CRUD.
- **Excluded**: `/health` and `/notebooks/health` are not rate limited.

When a limit is exceeded, the API returns `429 Too Many Requests` with a
`Retry-After` header and a JSON error body (`RateLimitExceeded`).

Set `max: 0` on a tier to disable rate limiting for that tier. If the entire
`rateLimit` block is omitted, the defaults above apply.

**Example** — tighter limits for a small deployment:

```yaml
lightspeed:
rateLimit:
expensive:
max: 10
general:
max: 100
```

#### MCP servers settings endpoints
Expand Down
32 changes: 32 additions & 0 deletions workspaces/lightspeed/plugins/lightspeed-backend/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,38 @@ export interface Config {
*/
token?: string;
}>;
/**
* Per-user rate limiting for Lightspeed API endpoints.
* @visibility backend
*/
rateLimit?: {
/**
* Limits for expensive endpoints (LLM queries, document uploads).
* @visibility backend
*/
expensive?: {
/**
* Maximum requests per minute per user.
* Set to 0 to disable rate limiting for this tier.
* @default 25
* @visibility backend
*/
max?: number;
};
/**
* Limits for all other authenticated endpoints.
* @visibility backend
*/
general?: {
/**
* Maximum requests per minute per user.
* Set to 0 to disable rate limiting for this tier.
* @default 200
* @visibility backend
*/
max?: number;
};
};
/**
* Configuration for AI Notebooks (Developer Preview)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@langchain/openai": "^0.6.0",
"@red-hat-developer-hub/backstage-plugin-lightspeed-common": "workspace:^",
"express": "^4.21.1",
"express-rate-limit": "^8.2.2",
"form-data": "^4.0.5",
"htmlparser2": "^9.1.0",
"http-proxy-middleware": "^3.0.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export const DEFAULT_LIGHTSPEED_SERVICE_HOST = '127.0.0.1'; // Lightspeed core s
export const DEFAULT_LIGHTSPEED_SERVICE_PORT = 8080; // Lightspeed service port
export const DEFAULT_MAX_FILE_SIZE_MB = 20 * 1024 * 1024; // 20MB

/**
* Rate limiting defaults (window is fixed at 1 minute)
*/
export const RATE_LIMIT_WINDOW_MS = 60000;
export const DEFAULT_EXPENSIVE_RATE_LIMIT_MAX = 25;
export const DEFAULT_GENERAL_RATE_LIMIT_MAX = 200;

/**
* Input validation limits for query endpoints
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@

import type { BackstageCredentials } from '@backstage/backend-plugin-api';

import type { RateLimitInfo } from 'express-rate-limit';

// Populated by the identity middleware for use in route handlers.
declare module 'express-serve-static-core' {
interface Request {
credentials?: BackstageCredentials;
userEntityRef?: string;
rateLimit?: RateLimitInfo;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
* Copyright Red Hat, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { mockServices } from '@backstage/backend-test-utils';

import express from 'express';
import request from 'supertest';

import {
DEFAULT_EXPENSIVE_RATE_LIMIT_MAX,
DEFAULT_GENERAL_RATE_LIMIT_MAX,
} from '../constant';
import {
createRateLimitMiddleware,
getRateLimitMax,
} from './createRateLimitMiddleware';

describe('getRateLimitMax', () => {
it('returns default expensive max when config is omitted', () => {
const config = mockServices.rootConfig({ data: {} });
expect(getRateLimitMax(config, 'expensive')).toBe(
DEFAULT_EXPENSIVE_RATE_LIMIT_MAX,
);
});

it('returns default general max when config is omitted', () => {
const config = mockServices.rootConfig({ data: {} });
expect(getRateLimitMax(config, 'general')).toBe(
DEFAULT_GENERAL_RATE_LIMIT_MAX,
);
});

it('returns configured max values when provided', () => {
const config = mockServices.rootConfig({
data: {
lightspeed: {
rateLimit: {
expensive: { max: 10 },
general: { max: 50 },
},
},
},
});

expect(getRateLimitMax(config, 'expensive')).toBe(10);
expect(getRateLimitMax(config, 'general')).toBe(50);
});

it('treats negative values as disabled (0)', () => {
const config = mockServices.rootConfig({
data: {
lightspeed: {
rateLimit: {
expensive: { max: -1 },
general: { max: -5 },
},
},
},
});

expect(getRateLimitMax(config, 'expensive')).toBe(0);
expect(getRateLimitMax(config, 'general')).toBe(0);
});

it('floors decimal values to integers', () => {
const config = mockServices.rootConfig({
data: {
lightspeed: {
rateLimit: {
expensive: { max: 10.7 },
general: { max: 50.3 },
},
},
},
});

expect(getRateLimitMax(config, 'expensive')).toBe(10);
expect(getRateLimitMax(config, 'general')).toBe(50);
});
});

describe('createRateLimitMiddleware', () => {
function createTestApp(
max: number,
tier: 'expensive' | 'general' = 'general',
) {
const app = express();
const config = mockServices.rootConfig({
data: {
lightspeed: {
rateLimit: {
[tier]: { max },
},
},
},
});

app.use((req, _res, next) => {
req.credentials = { $$type: '@backstage/BackstageCredentials' } as any;
req.userEntityRef = 'user:default/test-user';
next();
});
app.get('/test', createRateLimitMiddleware(config, tier), (_req, res) => {
res.json({ ok: true });
});

return app;
}

it('allows requests up to the configured max', async () => {
const app = createTestApp(1);

const first = await request(app).get('/test');
expect(first.status).toBe(200);
expect(first.body).toEqual({ ok: true });
});

it('returns 429 with Retry-After when limit is exceeded', async () => {
const app = createTestApp(1);

await request(app).get('/test');
const second = await request(app).get('/test');

expect(second.status).toBe(429);
expect(second.headers['retry-after']).toBeDefined();
expect(second.body).toEqual({
error: {
name: 'RateLimitExceeded',
message: 'Too many requests. Please try again later.',
retryAfter: expect.any(Number),
},
});
});

it('does not rate limit when max is 0', async () => {
const app = createTestApp(0);

const first = await request(app).get('/test');
const second = await request(app).get('/test');

expect(first.status).toBe(200);
expect(second.status).toBe(200);
});

it('tracks limits independently per user', async () => {
const config = mockServices.rootConfig({
data: {
lightspeed: {
rateLimit: {
general: { max: 1 },
},
},
},
});
const app = express();

app.use((req, _res, next) => {
req.credentials = { $$type: '@backstage/BackstageCredentials' } as any;
req.userEntityRef =
req.headers['x-test-user']?.toString() ?? 'user:default/user-a';
next();
});
app.get(
'/test',
createRateLimitMiddleware(config, 'general'),
(_req, res) => {
res.json({ ok: true });
},
);

await request(app).get('/test').set('x-test-user', 'user:default/user-a');
const blockedForA = await request(app)
.get('/test')
.set('x-test-user', 'user:default/user-a');
const allowedForB = await request(app)
.get('/test')
.set('x-test-user', 'user:default/user-b');

expect(blockedForA.status).toBe(429);
expect(allowedForB.status).toBe(200);
});
});
Loading
Loading