@qianxude/tem 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,352 @@
1
+ # Mock Server
2
+
3
+ A lightweight HTTP server for simulating external API services with configurable concurrency and rate limiting constraints. Used for testing TEM's task execution capabilities under various load conditions.
4
+
5
+ ## Overview
6
+
7
+ The mock server provides a controlled environment to test how TEM handles:
8
+
9
+ - **Concurrency limits** - Simulate services that reject requests when too many are in flight
10
+ - **Rate limiting** - Test backoff and retry behavior against rate-limited endpoints
11
+ - **Processing delays** - Verify timeout handling and async processing
12
+
13
+ ## Architecture
14
+
15
+ ```
16
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
17
+ │ HTTP Router │────▶│ MockService │────▶│ RateLimiter │
18
+ │ (router.ts) │ │ (service.ts) │ │ (token bucket) │
19
+ └─────────────────┘ └─────────────────┘ └─────────────────┘
20
+ │ │
21
+ ▼ ▼
22
+ ┌─────────────────┐ ┌─────────────────┐
23
+ │ Service Mgmt │ │ Concurrency │
24
+ │ Endpoints │ │ Controller │
25
+ └─────────────────┘ └─────────────────┘
26
+ ```
27
+
28
+ ### Components
29
+
30
+ | Component | File | Purpose |
31
+ |-----------|------|---------|
32
+ | `startMockServer` | `server.ts` | Server lifecycle management |
33
+ | `createRouter` | `router.ts` | HTTP routing and request handling |
34
+ | `MockService` | `service.ts` | Per-service concurrency and rate limiting |
35
+ | `RejectingRateLimiter` | `service.ts` | Token bucket rate limiter with immediate reject |
36
+
37
+ ## Modes
38
+
39
+ ### Single Mode
40
+
41
+ A simple mode with one pre-configured service accessed at the root path (`/`). No dynamic service management.
42
+
43
+ **Use case:** Simple tests where you just need a constrained endpoint.
44
+
45
+ ```typescript
46
+ startMockServer({
47
+ port: 8080,
48
+ mode: 'single',
49
+ defaultService: {
50
+ maxConcurrency: 3,
51
+ rateLimit: { limit: 10, windowMs: 1000 },
52
+ delayMs: [10, 50]
53
+ }
54
+ });
55
+ ```
56
+
57
+ ### Multi Mode
58
+
59
+ Dynamic service creation and management. Each service has its own concurrency/rate limits and is accessed via `/mock/:name`.
60
+
61
+ **Use case:** Complex tests with multiple services having different constraints.
62
+
63
+ ```typescript
64
+ startMockServer({
65
+ port: 8080,
66
+ mode: 'multi'
67
+ });
68
+
69
+ // Create services dynamically via HTTP API
70
+ ```
71
+
72
+ ## API Reference
73
+
74
+ ### Single Mode Endpoints
75
+
76
+ | Method | Path | Description |
77
+ |--------|------|-------------|
78
+ | `GET` | `/` | Access the default service |
79
+ | `POST` | `/shutdown` | Shutdown the server |
80
+
81
+ ### Multi Mode Endpoints
82
+
83
+ | Method | Path | Description |
84
+ |--------|------|-------------|
85
+ | `POST` | `/service/:name` | Create or replace a service |
86
+ | `DELETE` | `/service/:name` | Delete a service |
87
+ | `GET` | `/mock/:name` | Access a service |
88
+ | `POST` | `/shutdown` | Shutdown the server |
89
+
90
+ ### Create Service (Multi Mode Only)
91
+
92
+ ```http
93
+ POST /service/:name
94
+ Content-Type: application/json
95
+
96
+ {
97
+ "maxConcurrency": 2, // Max concurrent requests (required)
98
+ "rateLimit": {
99
+ "limit": 10, // Requests per window (required)
100
+ "windowMs": 1000 // Window size in ms (required)
101
+ },
102
+ "delayMs": [10, 200] // [min, max] processing delay (optional, default: [10, 200])
103
+ }
104
+ ```
105
+
106
+ **Response:**
107
+ ```http
108
+ HTTP/1.1 201 Created
109
+ Content-Type: application/json
110
+
111
+ {
112
+ "service": "test1",
113
+ "status": "created"
114
+ }
115
+ ```
116
+
117
+ ### Access Service
118
+
119
+ ```http
120
+ GET /mock/:name # Multi mode
121
+ GET / # Single mode
122
+ ```
123
+
124
+ **Success Response (200):**
125
+ ```http
126
+ HTTP/1.1 200 OK
127
+ Content-Type: application/json
128
+
129
+ {
130
+ "requestId": "lxyz123-abc456",
131
+ "meta": {
132
+ "ts": 1699999999999, // Timestamp (ms)
133
+ "rt": 45 // Response time (ms)
134
+ },
135
+ "data": "ok"
136
+ }
137
+ ```
138
+
139
+ ### Shutdown Server
140
+
141
+ ```http
142
+ POST /shutdown
143
+ ```
144
+
145
+ **Response:**
146
+ ```http
147
+ HTTP/1.1 200 OK
148
+ Content-Type: application/json
149
+
150
+ {
151
+ "status": "shutting_down"
152
+ }
153
+ ```
154
+
155
+ ## Error Responses
156
+
157
+ | Status | Error Code | Description |
158
+ |--------|------------|-------------|
159
+ | `400` | `invalid_params` | Missing or invalid request parameters |
160
+ | `400` | `single_mode_no_create` | Attempted to create service in single mode |
161
+ | `404` | `not_found` | Unknown route |
162
+ | `404` | `service_not_found` | Service does not exist |
163
+ | `429` | `rate_limit_exceeded` | Rate limit reached |
164
+ | `503` | `concurrency_limit_exceeded` | Concurrency limit reached |
165
+
166
+ ## Configuration
167
+
168
+ ### ServerConfig
169
+
170
+ ```typescript
171
+ interface ServerConfig {
172
+ port: number; // Server port (required)
173
+ mode?: 'single' | 'multi'; // Server mode (default: 'multi')
174
+ defaultService?: ServiceConfig; // Required for single mode
175
+ }
176
+ ```
177
+
178
+ ### ServiceConfig
179
+
180
+ ```typescript
181
+ interface ServiceConfig {
182
+ maxConcurrency: number; // Max concurrent requests allowed
183
+ rateLimit: {
184
+ limit: number; // Max requests per window
185
+ windowMs: number; // Window duration in milliseconds
186
+ };
187
+ delayMs: [number, number]; // [min, max] simulated processing delay
188
+ }
189
+ ```
190
+
191
+ ## Usage Examples
192
+
193
+ ### Basic Single Mode
194
+
195
+ ```typescript
196
+ import { startMockServer, stopMockServer } from './src/mock-server';
197
+
198
+ // Start server with a single constrained service
199
+ startMockServer({
200
+ port: 8080,
201
+ mode: 'single',
202
+ defaultService: {
203
+ maxConcurrency: 2,
204
+ rateLimit: { limit: 5, windowMs: 1000 },
205
+ delayMs: [50, 100]
206
+ }
207
+ });
208
+
209
+ // Make requests
210
+ const response = await fetch('http://localhost:8080/');
211
+ const data = await response.json();
212
+ // { requestId: '...', meta: { ts: ..., rt: ... }, data: 'ok' }
213
+
214
+ // Cleanup
215
+ stopMockServer();
216
+ ```
217
+
218
+ ### Multi Mode with Dynamic Services
219
+
220
+ ```typescript
221
+ import { startMockServer, stopMockServer } from './src/mock-server';
222
+
223
+ startMockServer({ port: 8080, mode: 'multi' });
224
+
225
+ // Create a service with strict limits
226
+ await fetch('http://localhost:8080/service/strict', {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/json' },
229
+ body: JSON.stringify({
230
+ maxConcurrency: 1,
231
+ rateLimit: { limit: 2, windowMs: 1000 },
232
+ delayMs: [100, 200]
233
+ })
234
+ });
235
+
236
+ // Create another service with relaxed limits
237
+ await fetch('http://localhost:8080/service/relaxed', {
238
+ method: 'POST',
239
+ headers: { 'Content-Type': 'application/json' },
240
+ body: JSON.stringify({
241
+ maxConcurrency: 10,
242
+ rateLimit: { limit: 100, windowMs: 1000 },
243
+ delayMs: [10, 50]
244
+ })
245
+ });
246
+
247
+ // Access services
248
+ const strict = await fetch('http://localhost:8080/mock/strict');
249
+ const relaxed = await fetch('http://localhost:8080/mock/relaxed');
250
+
251
+ // Delete a service
252
+ await fetch('http://localhost:8080/service/strict', { method: 'DELETE' });
253
+
254
+ // Shutdown
255
+ await fetch('http://localhost:8080/shutdown', { method: 'POST' });
256
+ ```
257
+
258
+ ### Testing Concurrency Limits
259
+
260
+ ```typescript
261
+ startMockServer({
262
+ port: 8080,
263
+ mode: 'multi'
264
+ });
265
+
266
+ // Create service with concurrency=1
267
+ await fetch('http://localhost:8080/service/singleton', {
268
+ method: 'POST',
269
+ headers: { 'Content-Type': 'application/json' },
270
+ body: JSON.stringify({
271
+ maxConcurrency: 1,
272
+ rateLimit: { limit: 100, windowMs: 1000 },
273
+ delayMs: [200, 200] // Fixed 200ms delay
274
+ })
275
+ });
276
+
277
+ // First request starts (holds slot for 200ms)
278
+ const req1 = fetch('http://localhost:8080/mock/singleton');
279
+
280
+ // Second request immediately (should fail with 503)
281
+ const req2 = await fetch('http://localhost:8080/mock/singleton');
282
+ console.log(req2.status); // 503
283
+ console.log(await req2.json()); // { error: 'concurrency_limit_exceeded' }
284
+
285
+ await req1; // First request succeeds
286
+ ```
287
+
288
+ ### Testing Rate Limits
289
+
290
+ ```typescript
291
+ startMockServer({
292
+ port: 8080,
293
+ mode: 'multi'
294
+ });
295
+
296
+ // Create service with rate limit of 2 per second
297
+ await fetch('http://localhost:8080/service/ratelimited', {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/json' },
300
+ body: JSON.stringify({
301
+ maxConcurrency: 10,
302
+ rateLimit: { limit: 2, windowMs: 1000 },
303
+ delayMs: [10, 10]
304
+ })
305
+ });
306
+
307
+ // First two succeed
308
+ const r1 = await fetch('http://localhost:8080/mock/ratelimited');
309
+ const r2 = await fetch('http://localhost:8080/mock/ratelimited');
310
+ console.log(r1.status, r2.status); // 200 200
311
+
312
+ // Third is rate limited
313
+ const r3 = await fetch('http://localhost:8080/mock/ratelimited');
314
+ console.log(r3.status); // 429
315
+ console.log(await r3.json()); // { error: 'rate_limit_exceeded' }
316
+ ```
317
+
318
+ ## Rate Limiting Algorithm
319
+
320
+ The mock server uses a **token bucket** algorithm for rate limiting:
321
+
322
+ - Tokens are refilled continuously based on `limit / windowMs` rate
323
+ - Each request consumes 1 token
324
+ - If no tokens available, request is rejected immediately (no queueing)
325
+ - This provides smooth rate limiting without burst issues
326
+
327
+ ## Concurrency Control
328
+
329
+ Concurrency is tracked per-service:
330
+
331
+ - `currentConcurrency` increments on successful `tryAcquire()`
332
+ - Decrements when request completes (in `finally` block)
333
+ - If `currentConcurrency >= maxConcurrency`, new requests get 503
334
+
335
+ ## Programmatic API
336
+
337
+ ```typescript
338
+ import { startMockServer, stopMockServer, getServerState } from './src/mock-server';
339
+
340
+ // Start server
341
+ startMockServer(config: ServerConfig): void
342
+
343
+ // Stop server programmatically
344
+ stopMockServer(): void
345
+
346
+ // Get current state (for testing)
347
+ getServerState(): {
348
+ services: Map<string, MockService>;
349
+ mode: 'single' | 'multi';
350
+ hasDefaultService: boolean;
351
+ }
352
+ ```
@@ -0,0 +1,3 @@
1
+ export { startMockServer, stopMockServer, getServerState, createMockService, createErrorSimulation } from './server';
2
+ export { MockService } from './service';
3
+ export type * as i from './types';
@@ -0,0 +1,235 @@
1
+ import type * as i from './types';
2
+ import { MockService } from './service';
3
+
4
+ // Generate a simple random ID without external deps
5
+ function generateRequestId(): string {
6
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
7
+ }
8
+
9
+ // JSON response helper
10
+ function jsonResponse(data: unknown, status: number): Response {
11
+ return new Response(JSON.stringify(data), {
12
+ status,
13
+ headers: { 'Content-Type': 'application/json' },
14
+ });
15
+ }
16
+
17
+ // Error responses
18
+ const errors = {
19
+ serviceNotFound: () => jsonResponse({ error: 'service_not_found' }, 404),
20
+ concurrencyExceeded: () => jsonResponse({ error: 'concurrency_limit_exceeded' }, 503),
21
+ rateLimitExceeded: () => jsonResponse({ error: 'rate_limit_exceeded' }, 429),
22
+ invalidParams: () => jsonResponse({ error: 'invalid_params' }, 400),
23
+ singleModeNoCreate: () => jsonResponse({ error: 'single_mode_no_create' }, 400),
24
+ serviceError: (message: string) => jsonResponse({ error: message }, 500),
25
+ };
26
+
27
+ // Parse request body safely
28
+ async function parseBody(req: Request): Promise<unknown> {
29
+ try {
30
+ return await req.json();
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ // Validate service config
37
+ function validateServiceConfig(body: unknown): i.ServiceConfig | null {
38
+ if (!body || typeof body !== 'object') return null;
39
+
40
+ const b = body as Record<string, unknown>;
41
+
42
+ // Required: maxConcurrency (number > 0)
43
+ if (typeof b.maxConcurrency !== 'number' || b.maxConcurrency < 1) {
44
+ return null;
45
+ }
46
+
47
+ // Required: rateLimit object with limit and windowMs
48
+ if (!b.rateLimit || typeof b.rateLimit !== 'object') return null;
49
+ const rl = b.rateLimit as Record<string, unknown>;
50
+ if (typeof rl.limit !== 'number' || rl.limit < 1) return null;
51
+ if (typeof rl.windowMs !== 'number' || rl.windowMs < 1) return null;
52
+
53
+ // Optional: delayMs (defaults to [10, 200])
54
+ let delayMs: [number, number];
55
+ if (b.delayMs && Array.isArray(b.delayMs) && b.delayMs.length === 2) {
56
+ delayMs = [b.delayMs[0], b.delayMs[1]];
57
+ } else {
58
+ delayMs = [10, 200];
59
+ }
60
+
61
+ if (delayMs[0] < 0 || delayMs[1] < delayMs[0]) return null;
62
+
63
+ // Optional: errorSimulation config
64
+ let errorSimulation: i.ErrorSimulationConfig | undefined;
65
+ if (b.errorSimulation && typeof b.errorSimulation === 'object') {
66
+ const es = b.errorSimulation as Record<string, unknown>;
67
+
68
+ // Validate rate (required, 0-1)
69
+ if (typeof es.rate !== 'number' || es.rate < 0 || es.rate > 1) {
70
+ return null;
71
+ }
72
+
73
+ errorSimulation = { rate: es.rate };
74
+
75
+ // Optional statusCode
76
+ if (typeof es.statusCode === 'number') {
77
+ errorSimulation.statusCode = es.statusCode;
78
+ }
79
+
80
+ // Optional errorMessage
81
+ if (typeof es.errorMessage === 'string') {
82
+ errorSimulation.errorMessage = es.errorMessage;
83
+ }
84
+ }
85
+
86
+ return {
87
+ maxConcurrency: b.maxConcurrency,
88
+ rateLimit: { limit: rl.limit, windowMs: rl.windowMs },
89
+ delayMs,
90
+ errorSimulation,
91
+ };
92
+ }
93
+
94
+ // Router state interface - passed as object with getters
95
+ interface RouterState {
96
+ services: Map<string, MockService>;
97
+ getMode: () => i.ServerMode;
98
+ getDefaultService: () => MockService | null;
99
+ shutdownFn: () => void;
100
+ }
101
+
102
+ // Create router with state
103
+ export function createRouter(state: RouterState) {
104
+ return async function handleRequest(req: Request): Promise<Response> {
105
+ const url = new URL(req.url);
106
+ const method = req.method;
107
+ const pathname = url.pathname;
108
+ const mode = state.getMode();
109
+
110
+ // POST /shutdown - Shutdown server
111
+ if (method === 'POST' && pathname === '/shutdown') {
112
+ // Schedule shutdown after response
113
+ setTimeout(() => {
114
+ state.shutdownFn();
115
+ }, 100);
116
+
117
+ return jsonResponse({ status: 'shutting_down' }, 200);
118
+ }
119
+
120
+ // POST /service/:name - Create service
121
+ if (method === 'POST' && pathname.startsWith('/service/')) {
122
+ if (mode === 'single') {
123
+ return errors.singleModeNoCreate();
124
+ }
125
+
126
+ const name = pathname.slice('/service/'.length);
127
+ if (!name) return errors.invalidParams();
128
+
129
+ const body = await parseBody(req);
130
+ const config = validateServiceConfig(body);
131
+ if (!config) return errors.invalidParams();
132
+
133
+ if (state.services.has(name)) {
134
+ // Delete old service if exists
135
+ state.services.delete(name);
136
+ }
137
+
138
+ const service = new MockService(name, config);
139
+ state.services.set(name, service);
140
+
141
+ return jsonResponse({ service: name, status: 'created' }, 201);
142
+ }
143
+
144
+ // DELETE /service/:name - Delete service
145
+ if (method === 'DELETE' && pathname.startsWith('/service/')) {
146
+ if (mode === 'single') {
147
+ return errors.singleModeNoCreate();
148
+ }
149
+
150
+ const name = pathname.slice('/service/'.length);
151
+ if (!name) return errors.invalidParams();
152
+
153
+ const existed = state.services.delete(name);
154
+ if (!existed) return errors.serviceNotFound();
155
+
156
+ return jsonResponse({ service: name, status: 'deleted' }, 200);
157
+ }
158
+
159
+ // GET /mock/:name - Access mock service (multi mode)
160
+ // POST /mock/:name - Also supported (body ignored for mock)
161
+ if ((method === 'GET' || method === 'POST') && pathname.startsWith('/mock/')) {
162
+ if (mode === 'single') {
163
+ return errors.invalidParams();
164
+ }
165
+
166
+ const name = pathname.slice('/mock/'.length);
167
+ if (!name) return errors.invalidParams();
168
+
169
+ const service = state.services.get(name);
170
+ if (!service) return errors.serviceNotFound();
171
+
172
+ return handleMockRequest(service);
173
+ }
174
+
175
+ // GET / - Access default service (single mode)
176
+ // POST / - Also supported (body ignored for mock)
177
+ if ((method === 'GET' || method === 'POST') && pathname === '/') {
178
+ const defaultService = state.getDefaultService();
179
+ if (mode !== 'single' || !defaultService) {
180
+ return errors.invalidParams();
181
+ }
182
+
183
+ return handleMockRequest(defaultService);
184
+ }
185
+
186
+ // 404 for unmatched routes
187
+ return jsonResponse({ error: 'not_found' }, 404);
188
+ };
189
+ }
190
+
191
+ // Handle mock service request
192
+ async function handleMockRequest(service: MockService): Promise<Response> {
193
+ const startTime = Date.now();
194
+
195
+ // Check for random service error (before acquiring resources)
196
+ const randomError = service.checkRandomError();
197
+ if (randomError) {
198
+ return errors.serviceError(randomError.errorMessage);
199
+ }
200
+
201
+ // Try to acquire (non-blocking)
202
+ const acquireResult = service.tryAcquire();
203
+
204
+ if (!acquireResult.allowed) {
205
+ if (acquireResult.error === 'concurrency') {
206
+ return errors.concurrencyExceeded();
207
+ }
208
+ if (acquireResult.error === 'rateLimit') {
209
+ return errors.rateLimitExceeded();
210
+ }
211
+ return errors.invalidParams();
212
+ }
213
+
214
+ try {
215
+ // Simulate processing delay
216
+ const delay = service.getDelay();
217
+ await Bun.sleep(delay);
218
+
219
+ const rt = Date.now() - startTime;
220
+
221
+ const response: i.MockResponse = {
222
+ requestId: generateRequestId(),
223
+ meta: {
224
+ ts: startTime,
225
+ rt,
226
+ },
227
+ data: 'ok',
228
+ };
229
+
230
+ return jsonResponse(response, 200);
231
+ } finally {
232
+ // Always release concurrency slot
233
+ service.release();
234
+ }
235
+ }