342 lines
7.9 KiB
Markdown
342 lines
7.9 KiB
Markdown
|
|
# 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
|