Files
apophis-fastify/docs/extensions/TIMEOUTS-REDIRECTS-CONCURRENCY.md

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
  2. Redirect Chains
  3. APOSTL Formula Reference
  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

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: 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:

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 location header

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-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