Testing Token Limit System: Simulate concurrent sessions và verify race condition
Sau khi thiết kế và vẽ flow, bước tiếp theo là test. Bài này đi vào cách test hệ thống token limit — đặc biệt là concurrent sessions, race condition trong quota enforcement, và Stripe Webhook idempotency.
Nghe bài viết này dưới dạng podcastTesting Token Limit System: Simulate concurrent sessions và verify race condition
Hai bài trước mình đã thiết kế hệ thống token limit và vẽ toàn bộ flow từ góc UX lẫn kỹ thuật. Bài này là phần cuối của series: làm thế nào để test hệ thống này đúng cách?
Không phải unit test thông thường — mà là những test nhắm thẳng vào những điểm dễ fail nhất: concurrent sessions, race condition trong Redis, Stripe Webhook idempotency, và quota reset.
Tại sao testing hệ thống này khó?
Token limit system có 3 đặc điểm khiến nó khó test hơn business logic thông thường:
1. Tính thời gian (time-dependent): Quota reset theo tuần/tháng. Test không thể đợi thật sự — cần mock time.
2. Tính đồng thời (concurrency): Bug quan trọng nhất — race condition — chỉ xuất hiện khi nhiều requests đến cùng lúc. Unit test single-threaded sẽ không bao giờ bắt được.
3. External dependencies: Redis, Postgres, Stripe Webhook — mỗi thứ có failure mode riêng. Integration test cần cả stack thật hoặc gần thật.
Layer 1: Unit Test — Business Logic thuần
Bắt đầu từ thứ dễ nhất và không có side effect: hàm tính credits.
// credit-calculator.test.ts
import { calculateCredits } from '../lib/credit-calculator';
describe('calculateCredits', () => {
const baseRate = {
inputCreditsPerMillion: 3.0,
cachedInputCreditsPerMillion: 0.75,
outputCreditsPerMillion: 15.0,
fastModeMultiplier: 1.5,
};
it('tính đúng cho request bình thường', () => {
const usage = {
inputTokens: 1_000_000,
cachedInputTokens: 0,
outputTokens: 1_000_000,
modelId: 'gpt-5.3-codex',
fastMode: false,
};
const credits = calculateCredits(usage, baseRate);
expect(credits).toBe(18.0); // 3.0 + 0 + 15.0
});
it('tính cached tokens với giá thấp hơn', () => {
const usage = {
inputTokens: 500_000,
cachedInputTokens: 500_000, // nửa là cached
outputTokens: 0,
modelId: 'gpt-5.3-codex',
fastMode: false,
};
const credits = calculateCredits(usage, baseRate);
expect(credits).toBe(1.875); // (0.5 * 3.0) + (0.5 * 0.75)
});
it('nhân fast mode multiplier', () => {
const usage = {
inputTokens: 1_000_000,
cachedInputTokens: 0,
outputTokens: 0,
modelId: 'gpt-5.3-codex',
fastMode: true,
};
const credits = calculateCredits(usage, baseRate);
expect(credits).toBe(4.5); // 3.0 * 1.5
});
it('không bao giờ trả về số âm', () => {
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
modelId: 'gpt-5.3-codex',
fastMode: false,
};
expect(calculateCredits(usage, baseRate)).toBe(0);
});
});
Đơn giản, nhanh, không cần infra. Chạy được trong CI mà không cần setup gì.
Layer 2: Integration Test — Redis Quota Enforcement
Đây là layer quan trọng nhất. Cần Redis thật (hoặc testcontainers).
// quota-service.integration.test.ts
import { GenericContainer, StartedTestContainer } from 'testcontainers';
import Redis from 'ioredis';
import { QuotaService } from '../services/quota-service';
describe('QuotaService Integration', () => {
let container: StartedTestContainer;
let redis: Redis;
let quotaService: QuotaService;
beforeAll(async () => {
container = await new GenericContainer('redis:7-alpine')
.withExposedPorts(6379)
.start();
redis = new Redis({
host: container.getHost(),
port: container.getMappedPort(6379),
});
quotaService = new QuotaService(redis);
});
afterAll(async () => {
await redis.quit();
await container.stop();
});
afterEach(async () => {
await redis.flushall(); // clean state giữa các test
});
it('cho phép request khi còn quota', async () => {
const result = await quotaService.consumeCredits({
userId: 'user-1',
credits: 100,
quotaLimit: 1000,
});
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(900);
});
it('block request khi vượt quota', async () => {
// Dùng 900 trước
await quotaService.consumeCredits({
userId: 'user-1',
credits: 900,
quotaLimit: 1000,
});
// Request tiếp theo vượt limit
const result = await quotaService.consumeCredits({
userId: 'user-1',
credits: 200,
quotaLimit: 1000,
});
expect(result.allowed).toBe(false);
expect(result.remaining).toBe(100);
});
it('không deduct khi bị block', async () => {
await quotaService.consumeCredits({
userId: 'user-1',
credits: 950,
quotaLimit: 1000,
});
// Request bị block
await quotaService.consumeCredits({
userId: 'user-1',
credits: 200,
quotaLimit: 1000,
});
// Verify Redis không bị deduct khi block
const usage = await quotaService.getCurrentUsage('user-1');
expect(usage).toBe(950); // vẫn 950, không phải 1150
});
});
Layer 3: Concurrency Test — Bắt Race Condition
Đây là test quan trọng nhất và ít ai viết. Mục tiêu: chứng minh hệ thống không bị vượt quota khi nhiều requests đến đồng thời.
// quota-concurrency.test.ts
describe('QuotaService Concurrency', () => {
it('không cho phép vượt quota dù 50 requests đồng thời', async () => {
const userId = 'user-concurrent';
const quotaLimit = 1000;
const creditsPerRequest = 30; // mỗi request dùng 30 credits
const concurrentRequests = 50; // 50 * 30 = 1500 > 1000
// Fire 50 requests đồng thời
const results = await Promise.all(
Array.from({ length: concurrentRequests }, () =>
quotaService.consumeCredits({
userId,
credits: creditsPerRequest,
quotaLimit,
})
)
);
const allowed = results.filter(r => r.allowed);
const blocked = results.filter(r => !r.allowed);
// Chỉ tối đa 33 requests được phép (33 * 30 = 990 ≤ 1000)
expect(allowed.length).toBeLessThanOrEqual(33);
// Tổng credits được dùng không vượt limit
const totalConsumed = allowed.length * creditsPerRequest;
expect(totalConsumed).toBeLessThanOrEqual(quotaLimit);
// Có request bị block
expect(blocked.length).toBeGreaterThan(0);
});
it('tổng credits trong Redis chính xác sau concurrent requests', async () => {
const userId = 'user-accurate';
const quotaLimit = 10_000;
// 100 requests đồng thời, mỗi cái 50 credits
await Promise.all(
Array.from({ length: 100 }, () =>
quotaService.consumeCredits({
userId,
credits: 50,
quotaLimit,
})
)
);
// Tổng trong Redis phải đúng với số lượng allowed * 50
const actualUsage = await quotaService.getCurrentUsage(userId);
expect(actualUsage % 50).toBe(0); // phải là bội số của 50
expect(actualUsage).toBeLessThanOrEqual(quotaLimit);
});
});
Test này sẽ fail nếu bạn dùng check-then-increment thông thường thay vì Lua atomic script. Đó là điểm mấu chốt — viết test này trước, để nó fail, rồi fix implementation.
Layer 4: Test Stripe Webhook Idempotency
Payment webhook có một yêu cầu đặc biệt: phải idempotent — gọi 2 lần với cùng event ID phải cho kết quả giống như gọi 1 lần.
// webhook-handler.test.ts
describe('Stripe Webhook Handler', () => {
it('xử lý payment thành công và add credits', async () => {
const event = createMockStripeEvent({
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_test_123',
metadata: { userId: 'user-1', credits: '5000' },
payment_status: 'paid',
},
},
});
await webhookHandler.handle(event);
const credits = await db
.from('subscriptions')
.select('purchased_credits')
.eq('user_id', 'user-1')
.single();
expect(credits.purchased_credits).toBe(5000);
});
it('IDEMPOTENCY: xử lý cùng event 2 lần không double-add credits', async () => {
const event = createMockStripeEvent({
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_test_456', // cùng session ID
metadata: { userId: 'user-2', credits: '1000' },
payment_status: 'paid',
},
},
});
// Gọi 2 lần — mô phỏng Stripe retry
await webhookHandler.handle(event);
await webhookHandler.handle(event);
const credits = await db
.from('subscriptions')
.select('purchased_credits')
.eq('user_id', 'user-2')
.single();
// Phải là 1000, không phải 2000
expect(credits.purchased_credits).toBe(1000);
});
it('không add credits nếu signature không hợp lệ', async () => {
const event = createMockStripeEvent({
type: 'checkout.session.completed',
data: { object: { id: 'cs_test_789', metadata: { userId: 'user-3', credits: '5000' } } },
});
// Tamper với signature
await expect(
webhookHandler.handle(event, 'invalid-signature')
).rejects.toThrow('Invalid webhook signature');
const credits = await db
.from('subscriptions')
.select('purchased_credits')
.eq('user_id', 'user-3')
.single();
expect(credits.purchased_credits).toBe(0);
});
});
Idempotency implement bằng cách lưu processed_event_ids — trước khi xử lý, check xem event ID này đã xử lý chưa:
async function handleWebhook(event: Stripe.Event) {
// Idempotency check
const alreadyProcessed = await db
.from('processed_webhook_events')
.select('id')
.eq('stripe_event_id', event.id)
.single();
if (alreadyProcessed) return; // skip silently
// Process event
await addCredits(event.data.object.metadata.userId, event.data.object.metadata.credits);
// Mark as processed
await db.from('processed_webhook_events').insert({
stripe_event_id: event.id,
processed_at: new Date(),
});
}
Layer 5: Test Quota Reset
// quota-reset.test.ts
describe('Quota Reset Cron', () => {
it('reset quota_used về 0 khi hết kỳ', async () => {
// Setup: subscription đã expired
await db.from('subscriptions').insert({
user_id: 'user-reset',
plan_id: 'plus',
period_start: subWeeks(new Date(), 1),
period_end: subHours(new Date(), 1), // expired 1 giờ trước
quota_used_credits: 800_000,
purchased_credits: 2000,
});
await quotaResetJob.run();
const sub = await db
.from('subscriptions')
.select('*')
.eq('user_id', 'user-reset')
.order('period_start', { ascending: false })
.limit(1)
.single();
expect(sub.quota_used_credits).toBe(0); // đã reset
expect(sub.purchased_credits).toBe(2000); // carry over
expect(new Date(sub.period_start) > new Date(sub.period_end)).toBe(false);
});
it('IDEMPOTENCY: chạy reset 2 lần không tạo 2 kỳ mới', async () => {
await db.from('subscriptions').insert({
user_id: 'user-idem',
plan_id: 'plus',
period_end: subHours(new Date(), 1),
quota_used_credits: 500_000,
});
await quotaResetJob.run();
await quotaResetJob.run(); // chạy lại
const count = await db
.from('subscriptions')
.select('count')
.eq('user_id', 'user-idem');
expect(count).toBe(2); // 1 cũ (archived) + 1 mới, không phải 3
});
it('không reset subscription chưa hết kỳ', async () => {
await db.from('subscriptions').insert({
user_id: 'user-active',
plan_id: 'plus',
period_end: addDays(new Date(), 3), // còn 3 ngày
quota_used_credits: 300_000,
});
await quotaResetJob.run();
const sub = await db
.from('subscriptions')
.select('quota_used_credits')
.eq('user_id', 'user-active')
.single();
expect(sub.quota_used_credits).toBe(300_000); // không đổi
});
});
Load Test: Kiểm tra hệ thống ở scale
Unit và integration test đủ để verify correctness. Nhưng để verify performance, cần load test riêng.
// load-test.ts — dùng k6 hoặc artillery
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
scenarios: {
// Scenario 1: Normal sustained load
normal_load: {
executor: 'constant-vus',
vus: 50,
duration: '2m',
},
// Scenario 2: Spike — mô phỏm burst cuối kỳ
end_of_period_spike: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 200 },
{ duration: '1m', target: 200 },
{ duration: '30s', target: 0 },
],
startTime: '2m', // bắt đầu sau normal load
},
},
thresholds: {
http_req_duration: ['p95<200'], // 95th percentile < 200ms
http_req_failed: ['rate<0.01'], // error rate < 1%
},
};
export default function () {
const res = http.post(
'http://localhost:3000/api/chat',
JSON.stringify({ prompt: 'Hello', maxTokens: 100 }),
{ headers: { Authorization: `Bearer ${__ENV.TEST_TOKEN}` } }
);
check(res, {
'status 200 hoặc 429': (r) => r.status === 200 || r.status === 429,
'response time < 200ms': (r) => r.timings.duration < 200,
'không có 500': (r) => r.status !== 500,
});
sleep(0.1);
}
Metrics cần watch khi load test:
- Redis CPU khi 200 concurrent users
- Quota check latency (p95, p99)
- Rate của 429 responses — phải tăng dần khi users tiếp cận limit
- Không có 500 errors — hệ thống phải fail gracefully
Test Matrix: Tóm tắt coverage cần có
| Scenario | Test Type | Tool |
|---|---|---|
| Credit calculation đúng | Unit | Jest/Vitest |
| Quota enforce single user | Integration | Testcontainers |
| Race condition concurrent | Concurrency | Jest + Promise.all |
| Stripe Webhook idempotency | Integration | Mock Stripe |
| Quota reset định kỳ | Integration | Mock time |
| Reset idempotency | Integration | DB + Cron |
| Performance under load | Load | k6 / Artillery |
| Redis failure fallback | Chaos | Kill Redis mid-test |
Chaos Test: Điều gì xảy ra khi Redis chết?
Một test thường bị bỏ quên nhưng cực kỳ quan trọng: what if Redis is down?
it('fallback gracefully khi Redis không available', async () => {
// Kill Redis connection
await redis.disconnect();
const result = await quotaService.consumeCredits({
userId: 'user-chaos',
credits: 100,
quotaLimit: 1000,
});
// Behavior phải được define rõ:
// Option A: Fail open (allow request, log warning) — tốt cho UX
// Option B: Fail closed (block request) — tốt cho billing accuracy
// Với AI coding tool, fail open thường tốt hơn
expect(result.allowed).toBe(true);
expect(result.fallback).toBe(true); // flag để alert on-call
});
Quyết định fail open hay fail closed phải là product decision, không phải technical decision. Với developer tool như Codex, fail open (và charge sau) tốt hơn. Với financial app, fail closed an toàn hơn.
Kết luận
Testing token limit system không thể chỉ dừng ở unit test. Những bug quan trọng nhất đều ở các lớp sâu hơn:
- Race condition chỉ bắt được bằng concurrency test với
Promise.all - Double billing chỉ bắt được bằng idempotency test
- Reset bug chỉ bắt được bằng time-mocked integration test
- Scale issue chỉ bắt được bằng load test
Nguyên tắc tổng quát: viết test theo failure mode, không theo happy path. Với billing system, cost của một bug production cao hơn nhiều so với thời gian viết thêm vài test case.
Series 3 bài về token limit system kết thúc ở đây. Từ thiết kế → flow → testing — đủ để bạn implement một hệ thống production-ready cho AI SaaS của mình.
Chưa có bình luận
Để lại bình luận
Bình luận sẽ được phê duyệt trước khi hiển thị.