# 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((_, 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: ` - `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 } ``` #### `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