Testing Tenant-Scoped API Rate Limiting in SaaS Applications
Rate limiting in multi-tenant SaaS serves two goals: protecting infrastructure and enforcing plan limits. Both can break in ways that are hard to catch without explicit tests.
The failure modes are distinct. Infrastructure protection fails when one tenant can starve other tenants — their traffic exhausts rate limit headroom that should have been tenant-scoped. Plan enforcement fails when a starter-tier customer can make as many API calls as an enterprise customer, or when a legitimate enterprise customer gets throttled because their limit was misconfigured.
Testing rate limiting is not just about confirming that requests fail after N calls. It's about verifying that limits are correctly scoped, correctly applied per plan, and correctly isolated between tenants.
How Tenant-Scoped Rate Limiting Works
Most implementations use Redis with keys that include a tenant identifier:
// Rate limit key format: ratelimit:{tenantId}:{endpoint}:{window}
const key = `ratelimit:${tenantId}:api:${Math.floor(Date.now() / windowMs)}`;
const result = await redis.multi()
.incr(key)
.expire(key, windowSeconds)
.exec();
const requestCount = result[0][1];
const limit = getPlanLimit(tenant.plan); // e.g., 1000/hour for starter
if (requestCount > limit) {
throw new RateLimitExceededError(limit, windowSeconds);
}Tests must verify: the key format includes the tenant ID, limits vary by plan, and one tenant's counter doesn't affect another's.
Setting Up Rate Limit Tests
Testing rate limiting requires control over time and Redis state. Use a test Redis instance (never the production one) and a way to mock time:
// test/setup/redis.js
const Redis = require('ioredis');
const testRedis = new Redis({
host: process.env.REDIS_TEST_HOST || 'localhost',
port: process.env.REDIS_TEST_PORT || 6380, // separate test instance
db: 1, // isolated database
});
afterEach(async () => {
// Flush rate limit keys between tests
const keys = await testRedis.keys('ratelimit:*');
if (keys.length > 0) {
await testRedis.del(...keys);
}
});Mocking Time for Window-Based Tests
// Use jest fake timers for time-based window tests
describe('Rate limit window reset', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('resets rate limit after window expires', async () => {
const tenant = createTestTenant({ plan: 'starter', limit: 10 });
// Exhaust the limit
for (let i = 0; i < 10; i++) {
await makeApiRequest(tenant.token);
}
// 11th request should fail
const overLimitResponse = await makeApiRequest(tenant.token);
expect(overLimitResponse.status).toBe(429);
// Advance time past the window
jest.advanceTimersByTime(61 * 1000); // 61 seconds
// Should succeed in new window
const newWindowResponse = await makeApiRequest(tenant.token);
expect(newWindowResponse.status).toBe(200);
});
});Testing Tenant Isolation
The critical test: one tenant exhausting their limit must not affect other tenants.
describe('Tenant isolation', () => {
let tenantA, tenantB;
beforeEach(async () => {
tenantA = await createTestTenant({ plan: 'starter', rateLimit: 5 });
tenantB = await createTestTenant({ plan: 'starter', rateLimit: 5 });
await flushRateLimitKeys();
});
it('tenant A exhausting their limit does not affect tenant B', async () => {
// Exhaust tenant A's limit
for (let i = 0; i < 5; i++) {
await request(app).get('/api/data').set('Authorization', `Bearer ${tenantA.token}`);
}
// Tenant A should be rate limited
const tenantAResponse = await request(app)
.get('/api/data')
.set('Authorization', `Bearer ${tenantA.token}`);
expect(tenantAResponse.status).toBe(429);
// Tenant B should still have their full quota
const tenantBResponse = await request(app)
.get('/api/data')
.set('Authorization', `Bearer ${tenantB.token}`);
expect(tenantBResponse.status).toBe(200);
expect(tenantBResponse.headers['x-ratelimit-remaining']).toBe('4');
});
it('concurrent requests from different tenants are correctly attributed', async () => {
const concurrentRequests = [
...Array(5).fill(null).map(() =>
request(app).get('/api/data').set('Authorization', `Bearer ${tenantA.token}`)
),
...Array(5).fill(null).map(() =>
request(app).get('/api/data').set('Authorization', `Bearer ${tenantB.token}`)
),
];
const responses = await Promise.all(concurrentRequests);
const tenantASuccesses = responses.slice(0, 5).filter(r => r.status === 200);
const tenantBSuccesses = responses.slice(5).filter(r => r.status === 200);
expect(tenantASuccesses).toHaveLength(5);
expect(tenantBSuccesses).toHaveLength(5);
});
});Testing Plan-Based Limits
Different subscription plans should have different rate limits:
describe('Plan-based rate limits', () => {
const planLimits = {
free: 100,
starter: 1000,
professional: 10000,
enterprise: 100000,
};
Object.entries(planLimits).forEach(([plan, limit]) => {
it(`${plan} plan allows ${limit} requests per hour`, async () => {
const tenant = await createTestTenant({ plan });
// Make requests up to the limit
// (For large limits, just check the headers)
const response = await request(app)
.get('/api/data')
.set('Authorization', `Bearer ${tenant.token}`);
expect(response.status).toBe(200);
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(limit);
});
});
it('upgrades rate limit immediately when plan changes', async () => {
const tenant = await createTestTenant({ plan: 'free' });
// Exhaust free tier limit (100 requests)
await exhaustRateLimit(tenant.token, 100);
const beforeUpgrade = await request(app)
.get('/api/data')
.set('Authorization', `Bearer ${tenant.token}`);
expect(beforeUpgrade.status).toBe(429);
// Upgrade to starter
await upgradeTenantPlan(tenant.id, 'starter');
// Should now have starter tier headroom
const afterUpgrade = await request(app)
.get('/api/data')
.set('Authorization', `Bearer ${tenant.token}`);
expect(afterUpgrade.status).toBe(200);
expect(parseInt(afterUpgrade.headers['x-ratelimit-limit'])).toBe(1000);
});
});Testing Rate Limit Headers
Rate limit headers tell clients how to behave. They must be accurate:
describe('Rate limit response headers', () => {
it('includes correct rate limit headers on successful requests', async () => {
const tenant = await createTestTenant({ plan: 'starter', rateLimit: 1000 });
const response = await request(app)
.get('/api/data')
.set('Authorization', `Bearer ${tenant.token}`);
expect(response.headers['x-ratelimit-limit']).toBe('1000');
expect(response.headers['x-ratelimit-remaining']).toBe('999');
expect(response.headers['x-ratelimit-reset']).toMatch(/^\d+$/); // Unix timestamp
});
it('includes Retry-After header on 429 responses', async () => {
const tenant = await createTestTenant({ plan: 'starter', rateLimit: 1 });
await request(app).get('/api/data').set('Authorization', `Bearer ${tenant.token}`);
const response = await request(app)
.get('/api/data')
.set('Authorization', `Bearer ${tenant.token}`);
expect(response.status).toBe(429);
expect(response.headers['retry-after']).toBeDefined();
expect(parseInt(response.headers['retry-after'])).toBeGreaterThan(0);
});
});Testing Burst Allowances
Many rate limiters implement a token bucket algorithm that allows bursts:
describe('Burst rate limiting', () => {
it('allows burst up to burst limit, then throttles', async () => {
const tenant = await createTestTenant({
plan: 'professional',
rateLimit: 100, // per minute
burstLimit: 200, // burst allowance
});
// Make 200 requests rapidly (burst)
const burstResponses = await Promise.all(
Array(200).fill(null).map(() =>
request(app).get('/api/data').set('Authorization', `Bearer ${tenant.token}`)
)
);
const burstSuccesses = burstResponses.filter(r => r.status === 200);
expect(burstSuccesses.length).toBeGreaterThanOrEqual(200); // burst allowed
// 201st request should be throttled
const overBurstResponse = await request(app)
.get('/api/data')
.set('Authorization', `Bearer ${tenant.token}`);
expect(overBurstResponse.status).toBe(429);
});
});Testing Endpoint-Specific Limits
Some endpoints should have tighter limits than others:
describe('Endpoint-specific rate limits', () => {
it('applies stricter limits to expensive endpoints', async () => {
const tenant = await createTestTenant({ plan: 'starter' });
// Export endpoint has tighter limit regardless of plan
const exportResponse = await request(app)
.get('/api/export')
.set('Authorization', `Bearer ${tenant.token}`);
expect(parseInt(exportResponse.headers['x-ratelimit-limit'])).toBeLessThan(
parseInt((await request(app).get('/api/data').set('Authorization', `Bearer ${tenant.token}`))
.headers['x-ratelimit-limit'])
);
});
it('endpoint limits are independent of general API limits', async () => {
const tenant = await createTestTenant({ plan: 'starter' });
// Exhaust the export endpoint limit
await exhaustEndpointRateLimit(tenant.token, '/api/export');
// General API should still be available
const generalResponse = await request(app)
.get('/api/data')
.set('Authorization', `Bearer ${tenant.token}`);
expect(generalResponse.status).toBe(200);
});
});Key Takeaways
- Rate limit keys must include the tenant ID — shared keys cause cross-tenant interference
- Always test that one tenant's exhaustion doesn't affect other tenants' quotas
- Verify rate limit headers (
X-RateLimit-Limit,X-RateLimit-Remaining,Retry-After) are accurate - Test plan upgrades reset or expand the rate limit immediately
- Use a separate Redis instance or database for rate limit tests — never flush production Redis
- Concurrent request tests catch race conditions in counter incrementing