chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
# APOPHIS v1.0 Extension Specification: Timeouts and Redirects
|
||||
|
||||
## Document Information
|
||||
- **Version**: 1.0
|
||||
- **Status**: Implemented
|
||||
- **Scope**: APOPHIS v1.0 core extension
|
||||
- **Date**: 2026-04-24
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Request Timeouts](#1-request-timeouts)
|
||||
2. [Redirect Chains](#2-redirect-chains)
|
||||
3. [APOSTL Formula Reference](#3-apostl-formula-reference)
|
||||
4. [Integration Guide](#4-integration-guide)
|
||||
|
||||
---
|
||||
|
||||
## 1. Request Timeouts
|
||||
|
||||
### 1.1 Overview
|
||||
|
||||
Timeout support enables APOPHIS to detect slow endpoints and treat timeout violations as first-class contract violations. Timeouts are configurable at three levels (from highest to lowest precedence):
|
||||
|
||||
1. **Per-route schema annotation**: `x-timeout: 5000`
|
||||
2. **Test configuration**: `config.timeout`
|
||||
3. **No timeout**: Default behavior (no timeout enforced)
|
||||
|
||||
### 1.2 Configuration
|
||||
|
||||
#### Global Plugin Timeout
|
||||
|
||||
```typescript
|
||||
await fastify.register(apophis, {
|
||||
timeout: 5000, // 5 seconds for all routes
|
||||
})
|
||||
```
|
||||
|
||||
#### Per-Test Timeout
|
||||
|
||||
```typescript
|
||||
const suite = await fastify.apophis.contract({
|
||||
timeout: 1000, // 1 second for this test run
|
||||
})
|
||||
```
|
||||
|
||||
#### Per-Route Timeout (Schema Annotation)
|
||||
|
||||
```typescript
|
||||
fastify.get('/slow-endpoint', {
|
||||
schema: {
|
||||
'x-timeout': 10000, // 10 seconds for this route
|
||||
'x-ensures': [
|
||||
'timeout_occurred(this) == false',
|
||||
'response_code(this) == 200',
|
||||
]
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
// Implementation
|
||||
})
|
||||
```
|
||||
|
||||
### 1.3 HTTP Executor Behavior
|
||||
|
||||
When a timeout is configured, `executeHttp` uses an abortable timer where supported. The timeout must be cleared in `finally`; Fastify injection may continue running after timeout if the underlying transport cannot be cancelled.
|
||||
|
||||
```typescript
|
||||
// In src/infrastructure/http-executor.ts
|
||||
if (timeoutMs && timeoutMs > 0) {
|
||||
response = await Promise.race([
|
||||
fastify.inject(injectOptions),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => {
|
||||
timedOut = true
|
||||
reject(new Error(`Request timeout after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
),
|
||||
])
|
||||
}
|
||||
```
|
||||
|
||||
On timeout, the executor returns a special `EvalContext` with:
|
||||
- `timedOut: true`
|
||||
- `timeoutMs: <configured timeout>`
|
||||
- `response.statusCode: 0`
|
||||
- `response.body: undefined`
|
||||
- `redirects: []`
|
||||
|
||||
### 1.4 APOSTL Formulas
|
||||
|
||||
New operation headers for timeout inspection:
|
||||
|
||||
| Formula | Description |
|
||||
|---------|-------------|
|
||||
| `timeout_occurred(this)` | Returns `true` if request timed out, `false` otherwise |
|
||||
| `timeout_value(this)` | Returns configured timeout in milliseconds, or `null` |
|
||||
|
||||
Example formulas:
|
||||
|
||||
```apostl
|
||||
timeout_occurred(this) == false
|
||||
timeout_value(this) == 5000
|
||||
response_time(this) <= timeout_value(this)
|
||||
```
|
||||
|
||||
### 1.5 Type Changes
|
||||
|
||||
#### `EvalContext` Extension
|
||||
|
||||
```typescript
|
||||
export interface EvalContext {
|
||||
// ... existing fields ...
|
||||
timedOut?: boolean // True if request hit timeout
|
||||
timeoutMs?: number // Configured timeout value
|
||||
redirects?: RedirectEntry[]
|
||||
}
|
||||
```
|
||||
|
||||
#### `RouteContract` Extension
|
||||
|
||||
```typescript
|
||||
export interface RouteContract {
|
||||
// ... existing fields ...
|
||||
timeout?: number // Per-route timeout in milliseconds
|
||||
}
|
||||
```
|
||||
|
||||
#### `TestConfig` Extension
|
||||
|
||||
```typescript
|
||||
export interface TestConfig {
|
||||
// ... existing fields ...
|
||||
timeout?: number // Request timeout in milliseconds
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Redirect Chains
|
||||
|
||||
### 2.1 Overview
|
||||
|
||||
Redirect support captures a 3xx response returned by `inject()` with its `Location` header. Multi-hop redirect following is not implemented here. When a response has:
|
||||
- Status code 300-399
|
||||
- A `location` header
|
||||
|
||||
APOPHIS captures the redirect entry in `EvalContext.redirects`.
|
||||
|
||||
### 2.2 HTTP Executor Behavior
|
||||
|
||||
After executing the request, `executeHttp` checks for redirects:
|
||||
|
||||
```typescript
|
||||
const redirectChain: RedirectEntry[] = []
|
||||
const location = response.headers['location']
|
||||
if (location && (response.statusCode >= 300 && response.statusCode < 400)) {
|
||||
redirectChain.push({
|
||||
statusCode: response.statusCode,
|
||||
location: String(location),
|
||||
headers: stringifyHeaders(response.headers),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Note: Fastify injection returns the redirect response unless the caller implements redirect following. To test redirect behavior itself, assert the 3xx response and `location` header directly.
|
||||
|
||||
### 2.3 APOSTL Formulas
|
||||
|
||||
New operation headers for redirect inspection:
|
||||
|
||||
| Formula | Description |
|
||||
|---------|-------------|
|
||||
| `redirect_count(this)` | Returns number of redirect hops captured |
|
||||
| `redirect_url(this).N` | Returns location URL of Nth redirect (0-indexed) |
|
||||
| `redirect_status(this).N` | Returns status code of Nth redirect (0-indexed) |
|
||||
|
||||
Example formulas:
|
||||
|
||||
```apostl
|
||||
redirect_count(this) == 0
|
||||
redirect_count(this) <= 3
|
||||
redirect_status(this).0 == 301
|
||||
redirect_url(this).0 == "/v2/legacy"
|
||||
```
|
||||
|
||||
### 2.4 Type Changes
|
||||
|
||||
#### `RedirectEntry`
|
||||
|
||||
```typescript
|
||||
export interface RedirectEntry {
|
||||
readonly statusCode: number
|
||||
readonly location: string
|
||||
readonly headers: Record<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
#### `EvalContext` Extension
|
||||
|
||||
```typescript
|
||||
export interface EvalContext {
|
||||
// ... existing fields ...
|
||||
redirects?: RedirectEntry[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. APOSTL Formula Reference
|
||||
|
||||
### Complete Operation Header List
|
||||
|
||||
```typescript
|
||||
export type OperationHeader =
|
||||
| 'request_body' | 'response_body' | 'response_code'
|
||||
| 'request_headers' | 'response_headers' | 'query_params' | 'cookies' | 'response_time'
|
||||
| 'redirect_count' | 'redirect_url' | 'redirect_status'
|
||||
| 'timeout_occurred' | 'timeout_value'
|
||||
```
|
||||
|
||||
### Formula Examples
|
||||
|
||||
```apostl
|
||||
# Timeout assertions
|
||||
timeout_occurred(this) == false
|
||||
timeout_value(this) == 5000
|
||||
response_time(this) <= timeout_value(this)
|
||||
|
||||
# Redirect assertions
|
||||
redirect_count(this) == 1
|
||||
redirect_count(this) <= 3
|
||||
redirect_status(this).0 == 301
|
||||
redirect_url(this).0 == "/new-path"
|
||||
redirect_status(this).1 == 302
|
||||
|
||||
# Combined
|
||||
timeout_occurred(this) == false && redirect_count(this) == 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Integration Guide
|
||||
|
||||
### 4.1 Fastify Route Examples
|
||||
|
||||
#### Health Check with Timeout
|
||||
|
||||
```typescript
|
||||
fastify.get('/health', {
|
||||
schema: {
|
||||
'x-timeout': 100,
|
||||
'x-ensures': [
|
||||
'timeout_occurred(this) == false',
|
||||
'response_code(this) == 200',
|
||||
'response_body(this).status == "ok"',
|
||||
]
|
||||
}
|
||||
}, async () => ({ status: 'ok' }))
|
||||
```
|
||||
|
||||
#### Legacy Endpoint with Redirect
|
||||
|
||||
```typescript
|
||||
fastify.get('/legacy', {
|
||||
schema: {
|
||||
'x-ensures': [
|
||||
'redirect_count(this) == 1',
|
||||
'redirect_status(this).0 == 301',
|
||||
'redirect_url(this).0 == "/v2/resource"',
|
||||
]
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
reply.code(301).header('location', '/v2/resource')
|
||||
return { moved: true }
|
||||
})
|
||||
```
|
||||
|
||||
#### API Endpoint with Combined Checks
|
||||
|
||||
```typescript
|
||||
fastify.get('/api/resource', {
|
||||
schema: {
|
||||
'x-timeout': 5000,
|
||||
'x-ensures': [
|
||||
'timeout_occurred(this) == false',
|
||||
'redirect_count(this) == 0',
|
||||
'response_code(this) == 200',
|
||||
'response_body(this).id != null',
|
||||
]
|
||||
}
|
||||
}, handler)
|
||||
```
|
||||
|
||||
### 4.2 Test Configuration Examples
|
||||
|
||||
```typescript
|
||||
// Quick test with 1 second timeout
|
||||
const quick = await fastify.apophis.contract({
|
||||
depth: 'quick',
|
||||
timeout: 1000,
|
||||
})
|
||||
|
||||
// Thorough test with 30 second timeout
|
||||
const thorough = await fastify.apophis.contract({
|
||||
depth: 'thorough',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Stateful test with timeout
|
||||
const stateful = await fastify.apophis.stateful({
|
||||
depth: 'standard',
|
||||
timeout: 5000,
|
||||
seed: 42,
|
||||
})
|
||||
```
|
||||
|
||||
### 4.3 Extension Plugin Integration
|
||||
|
||||
The timeout and redirect features integrate with the extension plugin system. Extensions can access timeout and redirect data via `PredicateContext.evalContext`:
|
||||
|
||||
```typescript
|
||||
const myExtension: ApophisExtension = {
|
||||
name: 'timeout-monitor',
|
||||
predicates: {
|
||||
slow_endpoint: (ctx) => ({
|
||||
value: ctx.evalContext.timedOut === true,
|
||||
success: true,
|
||||
}),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
All timeout and redirect features are additive:
|
||||
- Routes without `x-timeout` have no timeout enforced
|
||||
- Routes without redirects have empty `redirects` array
|
||||
- Formulas without timeout/redirect operations work unchanged
|
||||
- Default behavior is unchanged from v0.9
|
||||
Reference in New Issue
Block a user