chore: crush git history - reborn from consolidation on 2026-03-10

This commit is contained in:
John Dvorak
2026-03-10 00:00:00 -07:00
commit d278c4b105
313 changed files with 87549 additions and 0 deletions
@@ -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