Testing Tenant-Scoped API Rate Limiting in SaaS Applications

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

Read more