7.9 KiB
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.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):
- Per-route schema annotation:
x-timeout: 5000 - Test configuration:
config.timeout - No timeout: Default behavior (no timeout enforced)
1.2 Configuration
Global Plugin Timeout
await fastify.register(apophis, {
timeout: 5000, // 5 seconds for all routes
})
Per-Test Timeout
const suite = await fastify.apophis.contract({
timeout: 1000, // 1 second for this test run
})
Per-Route Timeout (Schema Annotation)
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.
// 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: truetimeoutMs: <configured timeout>response.statusCode: 0response.body: undefinedredirects: []
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:
timeout_occurred(this) == false
timeout_value(this) == 5000
response_time(this) <= timeout_value(this)
1.5 Type Changes
EvalContext Extension
export interface EvalContext {
// ... existing fields ...
timedOut?: boolean // True if request hit timeout
timeoutMs?: number // Configured timeout value
redirects?: RedirectEntry[]
}
RouteContract Extension
export interface RouteContract {
// ... existing fields ...
timeout?: number // Per-route timeout in milliseconds
}
TestConfig Extension
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
locationheader
APOPHIS captures the redirect entry in EvalContext.redirects.
2.2 HTTP Executor Behavior
After executing the request, executeHttp checks for redirects:
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:
redirect_count(this) == 0
redirect_count(this) <= 3
redirect_status(this).0 == 301
redirect_url(this).0 == "/v2/legacy"
2.4 Type Changes
RedirectEntry
export interface RedirectEntry {
readonly statusCode: number
readonly location: string
readonly headers: Record<string, string>
}
EvalContext Extension
export interface EvalContext {
// ... existing fields ...
redirects?: RedirectEntry[]
}
3. APOSTL Formula Reference
Complete Operation Header List
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
# 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
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
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
fastify.get('/api/resource', {
schema: {
'x-timeout': 5000,
'x-ensures': [
'timeout_occurred(this) == false',
'redirect_count(this) == 0',
// Behavioral: created resource must be retrievable
'response_code(GET /api/resource/{response_body(this).id}) == 200',
]
}
}, handler)
4.2 Test Configuration Examples
// 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:
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-timeouthave no timeout enforced - Routes without redirects have empty
redirectsarray - Formulas without timeout/redirect operations work unchanged
- Default behavior is unchanged from v0.9