Navigator Request Flow¶
This document provides a detailed explanation of how Navigator processes incoming HTTP requests, from initial receipt through final response.
Overview¶
Navigator's request handling follows a carefully orchestrated sequence of decision points, each determining whether to process the request immediately or pass it to the next handler in the chain. The flow is implemented primarily in internal/server/handler.go.
Request Flow Diagram¶
┌─────────────────────────────────────────────────────┐
│ 1. Incoming HTTP Request │
│ - Generate Request ID │
│ - Create ResponseRecorder for tracking │
│ - Start idle tracking │
└───────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 2. Health Check? (/up) │
│ → YES: Return 200 OK │
│ → NO: Continue │
└───────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 3. Authentication Check ⚡ EARLY ENFORCEMENT │
│ - Check if path is public (auth exclusions) │
│ - Validate Basic Auth credentials │
│ → FAILED: Return 401 Unauthorized │
│ → PASSED: Continue │
│ │
│ ⚠️ SECURITY: Authentication happens BEFORE all │
│ routing decisions to prevent bypass holes │
└───────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 4. Rewrite Rules │
│ - Check server.rewrite_rules │
│ - Match path patterns and methods │
│ → REDIRECT: Return 302 with new location │
│ → FLY-REPLAY: Route to region/app/machine │
│ → LAST: Rewrite path internally and continue │
│ → NO MATCH: Continue │
└───────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 5. CGI Scripts │
│ - Check server.cgi_scripts │
│ - Match exact paths and methods │
│ → MATCHED: Execute CGI script │
│ • Switch to configured user (Unix only) │
│ • Set CGI environment variables │
│ • Parse CGI response headers │
│ • Reload config if specified │
│ → NO MATCH: Continue │
└───────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 6. Reverse Proxies (Standalone Services) │
│ - Check routes.reverse_proxies │
│ - Match path/prefix patterns │
│ → WEBSOCKET: Establish WebSocket proxy │
│ → HTTP: Proxy to external service │
│ → NO MATCH: Continue │
└───────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 7. Static File Serving │
│ - Check for static file extensions │
│ - Look in configured public_dir │
│ → FOUND: Serve file with cache headers │
│ → NOT FOUND: Continue │
└───────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 8. Try Files (Public Paths Only) │
│ - Only for paths without extensions │
│ - Try configured suffixes (.html, etc.) │
│ → FOUND: Serve file │
│ → NOT FOUND: Continue │
└───────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 9. Web Application Proxy │
│ - Extract tenant from path │
│ - Start or get existing app process │
│ - Wait for app readiness (with timeout) │
│ - Proxy request to tenant application │
│ → SUCCESS: Return proxied response │
│ → TIMEOUT: Serve maintenance page │
│ → ERROR: Return 500 Internal Server Error │
└───────────────────┬─────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 10. Response Completion │
│ - Stop idle tracking │
│ - Log request details │
│ - Return to client │
└─────────────────────────────────────────────────────┘
Detailed Handler Flow¶
1. Request Initialization¶
File: internal/server/handler.go:54 (ServeHTTP)
When a request arrives, Navigator immediately:
- Generates a Request ID - Creates unique identifier if not already set by upstream proxy
- Creates ResponseRecorder - Wraps the response writer to capture status codes, sizes, and metadata
- Starts Idle Tracking - Notifies idle manager that a request is active (prevents premature machine suspension)
// Generate request ID if not present
requestID := r.Header.Get("X-Request-Id")
if requestID == "" {
requestID = utils.GenerateRequestID()
r.Header.Set("X-Request-Id", requestID)
}
// Create response recorder for logging and tracking
recorder := NewResponseRecorder(w, h.idleManager)
defer recorder.Finish(r)
// Start idle tracking
recorder.StartTracking()
2. Health Check Endpoint¶
File: internal/server/handler.go:77 (handleHealthCheck)
The /up endpoint provides a simple health check for load balancers and monitoring systems. It always returns:
- Status: 200 OK
- Body: OK
- No authentication required
- No logging overhead
This is the fastest exit path from the request handler.
4. Rewrite Rules¶
File: internal/server/handler.go:159 (handleRewrites)
Rewrite rules modify request paths or redirect requests before further processing.
Configuration:
server:
rewrite_rules:
- pattern: "^/old-path/(.*)$"
replacement: "/new-path/$1"
flag: redirect
- pattern: "^/region/(.*)$"
replacement: "/app/$1"
flag: fly-replay:sjc
methods: [GET, POST]
Supported Flags:
redirect - External Redirect¶
Returns HTTP 302 with new location:
newPath := rule.Pattern.ReplaceAllString(r.URL.Path, rule.Replacement)
http.Redirect(w, r, newPath, http.StatusFound)
fly-replay:TARGET:STATUS - Fly.io Region Routing¶
Routes requests to specific Fly.io regions, apps, or machines:
- Region: fly-replay:sjc → San Jose datacenter
- App: fly-replay:app=myapp → Specific Fly app
- Machine: fly-replay:machine=abc123:myapp → Specific machine instance
Smart Routing Logic:
File: internal/server/fly_replay.go:23 (ShouldUseFlyReplay)
Navigator automatically chooses between Fly-Replay and reverse proxy based on request size:
- Requests < 1MB: Uses Fly-Replay (lets Fly.io proxy handle it)
- Requests ≥ 1MB: Uses internal reverse proxy (avoids Fly-Replay limitations)
This ensures large uploads work reliably while keeping most requests fast.
Automatic Fallback:
When reverse proxy is needed, Navigator constructs internal Fly.io URLs:
// Region-based: http://sjc.myapp.internal:3000/path
targetURL := fmt.Sprintf("http://%s.%s.internal:%d%s",
region, flyAppName, listenPort, r.URL.Path)
// Machine-based: http://abc123.vm.myapp.internal:3000/path
targetURL := fmt.Sprintf("http://%s.vm.%s.internal:%d%s",
machineID, appName, listenPort, r.URL.Path)
Retry Detection:
Navigator adds X-Navigator-Retry: true header when using Fly-Replay within the same app. If the request comes back (machine unavailable), it serves a maintenance page instead of infinite loops.
last - Internal Rewrite¶
Modifies path and continues processing:
r.URL.Path = rule.Pattern.ReplaceAllString(r.URL.Path, rule.Replacement)
// Continue to next handler with modified path
5. CGI Scripts¶
File: internal/cgi/handler.go / internal/server/handler.go:109 (handleCGI)
CGI (Common Gateway Interface) scripts allow executing standalone scripts directly without starting a full web application. This is ideal for lightweight operations like database synchronization, status checks, or administrative tasks.
Configuration:
server:
cgi_scripts:
- path: /admin/sync
script: /opt/scripts/sync_databases.rb
method: POST
user: appuser
group: appgroup
timeout: 5m
reload_config: config/navigator.yml
env:
RAILS_DB_VOLUME: /mnt/db
CGI Execution Flow:
- Path Matching: Check if request path exactly matches any configured CGI script path
- Method Filtering: Verify HTTP method matches (if specified)
- User Switching: Set process credentials (Unix only, requires root):
- Environment Setup: Set standard CGI environment variables (RFC 3875):
REQUEST_METHOD,QUERY_STRING,SCRIPT_NAMECONTENT_TYPE,CONTENT_LENGTHHTTP_*headers as environment variables- Custom environment from configuration
- Script Execution: Execute script with configured timeout
- Response Parsing: Parse CGI headers and status code:
- Config Reload: After successful execution, check
reload_config: - Only reload if config file path differs OR file was modified during execution
- Avoids unnecessary reloads
Routing Priority:
CGI scripts are evaluated after rewrites but before reverse proxies. This means:
- Rewrite rules can modify paths before CGI matching
- CGI scripts take precedence over reverse proxies and web apps
- Subject to authentication (unless path is in public_paths)
Use Cases: - Database synchronization without starting Rails - Administrative tasks (htpasswd updates, configuration changes) - Lightweight status checks - Webhook handlers
Performance Characteristics: - Each request starts a new process (fork + exec overhead) - Suitable for infrequent operations (not high-traffic endpoints) - Lower memory footprint than persistent web apps - Scripts execute independently (no shared state)
Security Features: - Run scripts as different Unix users (privilege separation) - Timeout protection prevents runaway scripts - Authentication applies to CGI paths (unless excluded) - Environment variable isolation
See Also: CGI Scripts Feature Documentation
6. Reverse Proxy Routes¶
File: internal/server/proxy.go:37 (handleReverseProxies)
Reverse proxies route requests to standalone services like Redis, Action Cable servers, or other external backends.
Configuration:
routes:
reverse_proxies:
- prefix: /cable
target: http://localhost:28080
websocket: true
strip_path: true
headers:
X-Forwarded-For: $remote_addr
- path: "^/api/v1/(.*)$"
target: "http://api-service:8080/v1/$1"
headers:
X-Real-IP: $remote_addr
Matching Logic:
- Prefix matching: Simple string prefix (/cable matches /cable/foo)
- Regex matching: Full pattern match (^/api/v1/(.*)$)
Path Handling:
Navigator supports several path transformation strategies:
- Simple Proxy: Forwards path as-is to target
- Strip Path: Removes matched portion before forwarding
- Capture Groups: Uses regex groups to rebuild path (
$1,$2)
WebSocket Support:
File: internal/server/proxy.go:180 (handleWebSocketProxy)
For WebSocket-enabled routes, Navigator:
- Establishes connection to backend WebSocket server
- Upgrades client connection
- Proxies messages bidirectionally
- Filters hop-by-hop headers (Connection, Upgrade)
- Filters WebSocket handshake headers (connection-specific)
- Forwards application headers (Sec-WebSocket-Protocol)
The proxy maintains two separate WebSocket connections: - Client ↔ Navigator - Navigator ↔ Backend
This allows Navigator to monitor, log, and handle errors for each connection independently.
3. Authentication Check ⚡ EARLY ENFORCEMENT¶
File: internal/server/handler.go:82 (happens immediately after health check)
Implementation: internal/auth/auth.go:46 (CheckAuth)
Navigator supports HTTP Basic Authentication using htpasswd files. IMPORTANT: Authentication is enforced early in the request flow, immediately after the health check and before all routing decisions. This prevents authentication bypass via reverse proxies, fly-replay, redirects, or WebSocket endpoints.
Security Note: Prior to October 2025, authentication happened later in the flow, which created bypass holes where reverse proxy routes (including Action Cable WebSocket) and fly-replay rewrites could be accessed without authentication. This has been fixed by moving auth enforcement to happen early.
Configuration:
auth:
enabled: true
htpasswd: /path/to/.htpasswd
realm: "Private Area"
public_paths:
- /assets/
- /public/
- "*.css"
- "*.js"
auth_patterns:
- pattern: "^/showcase/2025/(raleigh|boston|seattle)/?$"
action: "off"
- pattern: "^/showcase/2025/(raleigh|boston)/public/"
action: "off"
Authentication Flow:
- Check Auth Patterns: Evaluate regex patterns first (most flexible)
- Compiled regex patterns from
auth_patternsconfiguration - Each pattern has an action:
"off"(skip auth) or realm name (require auth with specific realm) - Supports grouped alternations for performance:
(token1|token2|token3) -
Performance tip: Use fewer patterns with alternations instead of many individual patterns
-
Check Public Paths: Skip auth for glob/prefix patterns
- Prefix matches:
/assets/matches/assets/app.js - Glob patterns:
*.cssmatches/styles/main.css -
Simpler than regex for common cases
-
Extract Credentials: Parse Basic Auth header
-
Validate Credentials: Check against htpasswd file
- Supports multiple hash formats: MD5, bcrypt, SHA1, crypt
-
Uses
github.com/tg123/go-htpasswdlibrary -
Send Challenge: If validation fails
Auth Pattern Matching Order:
Navigator evaluates auth exclusions in this order:
- Auth Patterns (regex): Most flexible, checked first
- Allows complex matching with capture groups
- Can override realm on per-pattern basis
-
Example: Match studio index pages but not tenant apps
-
Public Paths (glob/prefix): Simple patterns, checked second
- Fast prefix matching (
/assets/) - Glob pattern matching (
*.css) - Easier to configure for common cases
Auth Patterns vs Public Paths:
Use auth_patterns when you need:
- Complex path matching (e.g., /year/(studio1|studio2|studio3)/?$)
- Exact path matching (e.g., index pages but not subdirectories)
- Per-pattern realm overrides
- Grouped alternations for performance (10 patterns vs 100+)
Use public_paths when you need:
- Simple prefix matching (e.g., /assets/)
- Glob patterns (e.g., *.css)
- Quick configuration without regex
Performance Optimization:
When configuring many auth exclusions, use grouped alternations:
# GOOD: One pattern with alternation (fast)
auth_patterns:
- pattern: "^/showcase/2025/(boston|seattle|raleigh|portland)/?$"
action: "off"
# AVOID: Multiple individual patterns (slower)
auth_patterns:
- pattern: "^/showcase/2025/boston/?$"
action: "off"
- pattern: "^/showcase/2025/seattle/?$"
action: "off"
- pattern: "^/showcase/2025/raleigh/?$"
action: "off"
Grouped alternations reduce: - Regex compilation overhead - Number of patterns to check per request - Memory footprint
7. Static File Serving¶
File: internal/server/static.go:48 (ServeStatic)
Navigator serves static files directly from the filesystem, bypassing the web application for performance.
Configuration:
server:
static:
public_dir: public
allowed_extensions: [html, css, js, png, jpg, svg]
cache_control:
default: "1h"
overrides:
- path: /assets/
max_age: "24h"
Static File Flow:
- Check Extension: Verify file has allowed extension (if configured)
- Build File Path: Join public_dir with request path
- Check Existence: Use
os.Stat()to verify file exists - Set Headers:
- Content-Type: Automatic MIME type detection
- Cache-Control: Based on path-specific configuration
- Serve File: Use
http.ServeFile()for efficient serving
Cache Control:
Navigator supports flexible cache header configuration:
cache_control:
default: "1h" # All static files
overrides:
- path: /assets/ # Fingerprinted assets
max_age: "24h"
- path: /favicon.ico # Specific files
max_age: "7d"
Duration parsing supports:
- Seconds: 3600 or 3600s
- Minutes: 60m
- Hours: 24h
- Days: 7d (parsed as hours: 168h)
Root Path Stripping:
For applications mounted under a prefix (e.g., /showcase), Navigator automatically strips the root path before looking for files:
8. Try Files¶
File: internal/server/static.go:117 (TryFiles)
Try Files allows serving prerendered files with different extensions than the requested path, useful for static sites and SPAs.
Configuration:
Try Files Flow:
- Check Path: Only process paths without extensions
- Try Extensions: Attempt each suffix in order
/studios/millbrae→/studios/millbrae.html/docs/guide→/docs/guide/index.html- Serve First Match: Return first existing file
- Check Tenant Paths: If no static file found, skip paths matching tenant routes
Important: Try Files now checks for static files FIRST before tenant matching. This allows prerendered HTML files (from rake prerender) to be served statically even when their paths match tenant prefixes.
Behavior Change (October 2025): - Before: Tenant paths blocked static file serving - After: Static files served if they exist, tenant apps only handle dynamic requests
Use Cases: - Static site generators (Jekyll, Hugo) - Prerendered HTML pages (Rails prerender task) - Single-page applications (React, Vue) - Clean URLs without extensions
9. Web Application Proxy¶
File: internal/server/handler.go:262 (handleWebAppProxy)
This is the core of Navigator's multi-tenant functionality. It routes requests to the appropriate tenant application.
Configuration:
applications:
startup_timeout: "30s" # Global default
tenants:
- name: "2025/boston"
path: "/2025/boston"
root: /var/www/boston
startup_timeout: "45s" # Tenant-specific override
Tenant Matching:
Navigator uses longest prefix matching to find the appropriate tenant:
// Finds the tenant with the longest matching path prefix
// /2025/boston/classes → matches "2025/boston" (not just "2025")
tenantName, found := h.extractTenantFromPath(r.URL.Path)
Application Lifecycle:
File: internal/process/app_manager.go
- Get or Start App:
- Checks if app is already running
-
If not, starts new app process:
- Allocates dynamic port (4000-4099)
- Sets up environment variables
- Executes configured runtime/server
- Creates PID file for process tracking
-
Wait for Readiness:
Startup Timeout Precedence:
1. Tenant-specific startup_timeout (highest priority)
2. Global applications.startup_timeout
3. Default: 30 seconds
Health Check:
Applications signal readiness through health checks. Navigator polls http://localhost:PORT/up until:
- Returns HTTP 200
- Or timeout expires
- Proxy Request:
File: internal/proxy/proxy.go:274 (ProxyWithWebSocketSupport)
Once app is ready, Navigator proxies the request:
targetURL := fmt.Sprintf("http://localhost:%d", app.Port)
proxy.ProxyWithWebSocketSupport(w, r, targetURL, wsPtr)
Features: - WebSocket detection and handling - WebSocket connection tracking (optional) - X-Forwarded-* headers preserved - Client disconnect detection (499 status) - Automatic retry with exponential backoff
WebSocket Tracking:
Configuration:
applications:
track_websockets: true # Global setting
tenants:
- name: "app1"
track_websockets: false # Per-tenant override
When enabled, Navigator tracks active WebSocket connections: - Increments counter on connection upgrade - Decrements on connection close - Prevents app shutdown while WebSockets active - Useful for idle management and metrics
Retry Logic:
File: internal/proxy/proxy.go:84 (HandleProxyWithRetry)
For regular HTTP requests (GET/HEAD), Navigator implements automatic retry:
- Connection Failures: Retry with exponential backoff
- Initial delay: 10ms
- Max delay: 500ms
-
Max duration: 3 seconds
-
Response Buffering:
- Buffers responses up to 64KB
- Allows retry if connection fails mid-response
-
Large responses stream directly (no buffering)
-
Safety:
- Only retries safe methods (GET, HEAD)
- Non-idempotent methods (POST, PUT) fail immediately
- Prevents duplicate operations
Error Handling:
Navigator distinguishes between different failure scenarios:
- Client Disconnect (499):
- Client closed connection while waiting
- No error logged (expected behavior)
-
Similar to nginx behavior
-
App Startup Timeout:
- Serves maintenance page
- Status: 503 Service Unavailable
-
Configurable maintenance.html
-
Proxy Failure (502):
- Backend connection failed
- After all retries exhausted
- Logged as error
10. Response Completion¶
File: internal/server/handler.go:405 (Finish)
After the request is processed, Navigator finalizes:
-
Stop Idle Tracking:
Decrements active request counter, enabling idle detection. -
Log Request:
File: internal/server/logging.go
Structured logging includes: - Method, path, status code - Response size and duration - Tenant name (if applicable) - Response type (static, proxy, fly-replay) - Backend information - Request ID for tracing
Example Log:
INFO Request completed method=GET path=/2025/boston/classes status=200
size=4523 duration=145ms tenant=2025/boston response_type=proxy
request_id=abc123
- Return Response: All headers and body already written during processing.
Special Cases and Edge Conditions¶
Client Disconnects¶
Navigator detects client disconnects throughout the request lifecycle:
During App Startup:
During Proxy:
if r.Context().Err() == context.Canceled {
// Client disconnected, don't log as error
w.WriteHeader(499)
}
Status code 499 follows nginx convention for client-closed requests.
Maintenance Pages¶
Navigator serves maintenance pages in several scenarios:
- App Startup Timeout: App not ready within configured timeout
- Fly-Replay Retry: Target machine unavailable (retry detected)
Maintenance Page Locations:
1. public/maintenance.html (preferred)
2. Built-in generic message
Large Request Handling¶
For requests ≥ 1MB (Fly-Replay limitation):
-
Automatic Detection:
-
Fallback Construction: Navigator builds internal Fly.io URLs:
-
Transparent to Client: No configuration needed, automatic behavior.
Process Recovery¶
File: internal/process/app_manager.go
If an app process crashes:
- Detection: Proxy connection refused
- Cleanup: Remove stale PID file
- Restart: Call
GetOrStartApp()again - Retry: Original request retried automatically
Graceful Shutdown¶
File: cmd/navigator-refactored/main.go:321 (handleShutdown)
When Navigator receives SIGTERM/SIGINT:
- Stop Idle Manager: Prevent new suspensions
- Shutdown HTTP Server: Stop accepting new connections
- Stop Web Apps: Terminate tenant applications
- Stop Managed Processes: Terminate Redis, Sidekiq, etc.
- Timeout: 30 seconds for complete shutdown
All managers receive context for coordinated shutdown.
Configuration Reload¶
File: cmd/navigator-refactored/main.go:264 (handleReload)
Navigator supports live configuration reload via SIGHUP:
Reload Process:
- Load New Config: Parse YAML, validate settings
- Update Managers:
- App Manager: New tenant configuration
- Process Manager: Managed processes diff
- Idle Manager: New timeout/action
- Reload Auth: Refresh htpasswd file
- Update Handler: Recreate handler with new config
- Execute Start Hooks: Run configured hooks
What Gets Updated: - Tenant configurations - Managed processes (starts new, stops removed) - Authentication settings - Static file configuration - Rewrite rules - Reverse proxy routes - Idle timeouts
What Doesn't Change: - Listen address/port (requires restart) - Running tenant applications (not restarted) - Active connections (continue uninterrupted)
Performance Optimizations¶
Early Exit Paths¶
Navigator prioritizes fast paths:
- Health checks: Immediate 200 OK response
- CGI scripts: Direct script execution (no app startup)
- Static files: Direct filesystem serving
- Reverse proxies: Skip tenant matching
Dynamic Port Allocation¶
Instead of sequential port assignment, Navigator uses availability checking:
Benefits: - Prevents port conflicts - Faster app startup - No coordination needed
Response Buffering¶
For retry capability, Navigator buffers up to 64KB:
if w.body.Len() + len(b) > MaxRetryBufferSize {
// Switch to streaming mode
w.Commit()
w.written = true
}
This balances: - Retry capability for most responses - Memory efficiency for large responses - Streaming for downloads/uploads
WebSocket Optimization¶
Navigator passes WebSocket connections through with minimal overhead:
- Single upgrade per connection
- Direct message forwarding (no buffering)
- Goroutine-based bidirectional proxy
- Optional tracking for idle management
Debugging and Observability¶
Log Levels¶
Set via LOG_LEVEL environment variable:
Debug Level: Shows detailed request routing:
DEBUG Request received method=GET path=/2025/boston/classes
DEBUG Checking static file path=/2025/boston/classes.css
DEBUG Tenant extraction result tenantName=2025/boston found=true
DEBUG Proxying request target=http://localhost:4001
Info Level: Shows request completion and significant events:
INFO Request completed method=GET path=/classes status=200 duration=145ms
INFO App started tenant=2025/boston port=4001
Request Metadata¶
Every request tracks metadata for logging:
recorder.SetMetadata("response_type", "static")
recorder.SetMetadata("tenant", tenantName)
recorder.SetMetadata("file_path", fsPath)
Metadata appears in structured logs for analysis.
Request Tracing¶
Request IDs flow through the entire system:
Enables end-to-end tracing across Navigator and tenant apps.
Summary¶
Navigator's request handling prioritizes:
- Performance: Early exits for common cases
- Reliability: Automatic retry and error recovery
- Flexibility: Multiple routing strategies
- Observability: Comprehensive logging and tracing
- Simplicity: Clear flow with minimal configuration
The modular design allows each component to be tested independently while maintaining a cohesive request flow.
References¶
Key Source Files¶
- Main Handler:
internal/server/handler.go - Static Files:
internal/server/static.go - Reverse Proxy:
internal/server/proxy.go - CGI Scripts:
internal/cgi/handler.go - Fly-Replay:
internal/server/fly_replay.go - Authentication:
internal/auth/auth.go - Proxy Logic:
internal/proxy/proxy.go - Process Management:
internal/process/app_manager.go - Reload Logic:
internal/utils/reload.go - Server Lifecycle:
cmd/navigator-refactored/main.go