forked from
smokesignal.events/quickdid
QuickDID is a high-performance AT Protocol identity resolution service written in Rust. It provides handle-to-DID resolution with Redis-backed caching and queue processing.
1//! Configuration management for QuickDID service
2//!
3//! This module handles all configuration parsing, validation, and error handling
4//! for the QuickDID AT Protocol identity resolution service.
5//!
6//! ## Configuration Sources
7//!
8//! Configuration is provided exclusively through environment variables following
9//! the 12-factor app methodology.
10//!
11//! ## Example
12//!
13//! ```bash
14//! # Minimal configuration
15//! HTTP_EXTERNAL=quickdid.example.com \
16//! quickdid
17//!
18//! # Full configuration with Redis and custom settings
19//! HTTP_EXTERNAL=quickdid.example.com \
20//! HTTP_PORT=3000 \
21//! REDIS_URL=redis://localhost:6379 \
22//! CACHE_TTL_MEMORY=300 \
23//! CACHE_TTL_REDIS=86400 \
24//! QUEUE_ADAPTER=redis \
25//! QUEUE_REDIS_TIMEOUT=10 \
26//! quickdid
27//! ```
28
29use std::env;
30use thiserror::Error;
31
32/// Configuration-specific errors following the QuickDID error format
33///
34/// All errors follow the pattern: `error-quickdid-config-{number} {message}: {details}`
35#[derive(Debug, Error)]
36pub enum ConfigError {
37 /// Missing required environment variable or command-line argument
38 ///
39 /// Example: When HTTP_EXTERNAL is not provided
40 #[error("error-quickdid-config-1 Missing required environment variable: {0}")]
41 MissingRequired(String),
42
43 /// Invalid configuration value that doesn't meet expected format or constraints
44 ///
45 /// Example: Invalid QUEUE_ADAPTER value (must be 'mpsc', 'redis', 'sqlite', 'noop', or 'none')
46 #[error("error-quickdid-config-2 Invalid configuration value: {0}")]
47 InvalidValue(String),
48
49 /// Invalid TTL (Time To Live) value
50 ///
51 /// TTL values must be positive integers representing seconds
52 #[error("error-quickdid-config-3 Invalid TTL value (must be positive): {0}")]
53 InvalidTtl(String),
54
55 /// Invalid timeout value
56 ///
57 /// Timeout values must be positive integers representing seconds
58 #[error("error-quickdid-config-4 Invalid timeout value (must be positive): {0}")]
59 InvalidTimeout(String),
60}
61
62/// Helper function to get an environment variable with an optional default
63fn get_env_or_default(key: &str, default: Option<&str>) -> Option<String> {
64 match env::var(key) {
65 Ok(val) if !val.is_empty() => Some(val),
66 _ => default.map(String::from),
67 }
68}
69
70/// Helper function to parse an environment variable as a specific type
71fn parse_env<T: std::str::FromStr>(key: &str, default: T) -> Result<T, ConfigError>
72where
73 T::Err: std::fmt::Display,
74{
75 match env::var(key) {
76 Ok(val) if !val.is_empty() => val
77 .parse::<T>()
78 .map_err(|e| ConfigError::InvalidValue(format!("{}: {}", key, e))),
79 _ => Ok(default),
80 }
81}
82
83/// Validated configuration for QuickDID service
84///
85/// This struct contains all configuration after validation and processing.
86/// Use `Config::from_env()` to create from environment variables.
87///
88/// ## Example
89///
90/// ```rust,no_run
91/// use quickdid::config::Config;
92///
93/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
94/// let config = Config::from_env()?;
95/// config.validate()?;
96///
97/// println!("Service running at: {}", config.http_external);
98/// # Ok(())
99/// # }
100/// ```
101#[derive(Clone)]
102pub struct Config {
103 /// HTTP server port (e.g., "8080", "3000")
104 pub http_port: String,
105
106 /// PLC directory hostname (e.g., "plc.directory")
107 pub plc_hostname: String,
108
109 /// External hostname for service endpoints (e.g., "quickdid.example.com")
110 pub http_external: String,
111
112 /// HTTP User-Agent for outgoing requests (e.g., "quickdid/1.0.0 (+https://...)")
113 pub user_agent: String,
114
115 /// Custom DNS nameservers, comma-separated (e.g., "8.8.8.8,8.8.4.4")
116 pub dns_nameservers: Option<String>,
117
118 /// Additional CA certificate bundles, comma-separated paths
119 pub certificate_bundles: Option<String>,
120
121 /// Redis URL for caching (e.g., "redis://localhost:6379/0")
122 pub redis_url: Option<String>,
123
124 /// SQLite database URL for caching (e.g., "sqlite:./quickdid.db")
125 pub sqlite_url: Option<String>,
126
127 /// Queue adapter type: "mpsc", "redis", "sqlite", or "noop"
128 pub queue_adapter: String,
129
130 /// Redis URL for queue operations (falls back to redis_url)
131 pub queue_redis_url: Option<String>,
132
133 /// Redis key prefix for queues (e.g., "queue:handleresolver:")
134 pub queue_redis_prefix: String,
135
136 /// Worker ID for queue operations (defaults to "worker1")
137 pub queue_worker_id: String,
138
139 /// Buffer size for MPSC queue (e.g., 1000)
140 pub queue_buffer_size: usize,
141
142 /// TTL for in-memory cache in seconds (e.g., 600 = 10 minutes)
143 pub cache_ttl_memory: u64,
144
145 /// TTL for Redis cache in seconds (e.g., 7776000 = 90 days)
146 pub cache_ttl_redis: u64,
147
148 /// TTL for SQLite cache in seconds (e.g., 7776000 = 90 days)
149 pub cache_ttl_sqlite: u64,
150
151 /// Redis blocking timeout for queue operations in seconds (e.g., 5)
152 pub queue_redis_timeout: u64,
153
154 /// Enable deduplication for Redis queue to prevent duplicate handles
155 /// Default: false
156 pub queue_redis_dedup_enabled: bool,
157
158 /// TTL for Redis queue deduplication keys in seconds
159 /// Default: 60 (1 minute)
160 pub queue_redis_dedup_ttl: u64,
161
162 /// Maximum queue size for SQLite adapter work shedding (e.g., 10000)
163 /// When exceeded, oldest entries are deleted to maintain this limit.
164 /// Set to 0 to disable work shedding (unlimited queue size).
165 pub queue_sqlite_max_size: u64,
166
167 /// Maximum concurrent handle resolutions allowed (rate limiting).
168 /// When set to > 0, enables rate limiting using a semaphore.
169 /// Default: 0 (disabled)
170 pub resolver_max_concurrent: usize,
171
172 /// Timeout for acquiring rate limit permit in milliseconds.
173 /// When set to > 0, requests will timeout if they can't acquire a permit within this time.
174 /// Default: 0 (no timeout)
175 pub resolver_max_concurrent_timeout_ms: u64,
176
177 /// Seed value for ETAG generation to allow cache invalidation.
178 /// This value is incorporated into ETAG checksums, allowing server admins
179 /// to invalidate client-cached responses after major changes.
180 /// Default: application version
181 pub etag_seed: String,
182
183 /// Maximum age for HTTP cache control in seconds.
184 /// When set to 0, Cache-Control header is disabled.
185 /// Default: 86400 (24 hours)
186 pub cache_max_age: u64,
187
188 /// Stale-if-error directive for Cache-Control in seconds.
189 /// Allows stale content to be served if backend errors occur.
190 /// Default: 172800 (48 hours)
191 pub cache_stale_if_error: u64,
192
193 /// Stale-while-revalidate directive for Cache-Control in seconds.
194 /// Allows stale content to be served while fetching fresh content.
195 /// Default: 86400 (24 hours)
196 pub cache_stale_while_revalidate: u64,
197
198 /// Max-stale directive for Cache-Control in seconds.
199 /// Maximum time client will accept stale responses.
200 /// Default: 172800 (48 hours)
201 pub cache_max_stale: u64,
202
203 /// Min-fresh directive for Cache-Control in seconds.
204 /// Minimum time response must remain fresh.
205 /// Default: 3600 (1 hour)
206 pub cache_min_fresh: u64,
207
208 /// Pre-calculated Cache-Control header value.
209 /// Calculated at startup for efficiency.
210 /// None if cache_max_age is 0 (disabled).
211 pub cache_control_header: Option<String>,
212
213 /// Metrics adapter type: "noop" or "statsd"
214 /// Default: "noop" (no metrics collection)
215 pub metrics_adapter: String,
216
217 /// StatsD host for metrics collection (e.g., "localhost:8125")
218 /// Required when metrics_adapter is "statsd"
219 pub metrics_statsd_host: Option<String>,
220
221 /// Bind address for StatsD UDP socket (e.g., "0.0.0.0:0" for IPv4 or "[::]:0" for IPv6)
222 /// Default: "[::]:0" (IPv6 any address, random port)
223 pub metrics_statsd_bind: String,
224
225 /// Metrics prefix for all metrics (e.g., "quickdid")
226 /// Default: "quickdid"
227 pub metrics_prefix: String,
228
229 /// Default tags for all metrics (comma-separated key:value pairs)
230 /// Example: "env:production,service:quickdid"
231 pub metrics_tags: Option<String>,
232
233 /// Enable proactive cache refresh for frequently accessed handles.
234 /// When enabled, cache entries that have reached the refresh threshold
235 /// will be queued for background refresh to keep the cache warm.
236 /// Default: false
237 pub proactive_refresh_enabled: bool,
238
239 /// Threshold as a percentage (0.0-1.0) of cache TTL when to trigger proactive refresh.
240 /// For example, 0.8 means refresh when an entry has lived for 80% of its TTL.
241 /// Default: 0.8 (80%)
242 pub proactive_refresh_threshold: f64,
243
244 /// Directory path for serving static files.
245 /// When set, the root handler will serve files from this directory.
246 /// Default: "www" (relative to working directory)
247 pub static_files_dir: String,
248
249 /// Enable Jetstream consumer for AT Protocol events.
250 /// When enabled, the service will consume Account and Identity events
251 /// to maintain cache consistency.
252 /// Default: false
253 pub jetstream_enabled: bool,
254
255 /// Jetstream WebSocket hostname for consuming AT Protocol events.
256 /// Example: "jetstream.atproto.tools" or "jetstream1.us-west.bsky.network"
257 /// Default: "jetstream.atproto.tools"
258 pub jetstream_hostname: String,
259}
260
261impl Config {
262 /// Create a validated Config from environment variables
263 ///
264 /// This method:
265 /// 1. Reads configuration from environment variables
266 /// 2. Validates required fields (HTTP_EXTERNAL)
267 /// 3. Applies defaults where appropriate
268 ///
269 /// ## Example
270 ///
271 /// ```rust,no_run
272 /// use quickdid::config::Config;
273 ///
274 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
275 /// // Parse from environment variables
276 /// let config = Config::from_env()?;
277 ///
278 /// # Ok(())
279 /// # }
280 /// ```
281 ///
282 /// ## Errors
283 ///
284 /// Returns `ConfigError::MissingRequired` if:
285 /// - HTTP_EXTERNAL is not provided
286 pub fn from_env() -> Result<Self, ConfigError> {
287 // Required fields
288 let http_external = env::var("HTTP_EXTERNAL")
289 .ok()
290 .filter(|s| !s.is_empty())
291 .ok_or_else(|| ConfigError::MissingRequired("HTTP_EXTERNAL".to_string()))?;
292
293 // Generate default user agent
294 let default_user_agent = format!(
295 "quickdid/{} (+https://github.com/smokesignal.events/quickdid)",
296 env!("CARGO_PKG_VERSION")
297 );
298
299 let mut config = Config {
300 http_port: get_env_or_default("HTTP_PORT", Some("8080")).unwrap(),
301 plc_hostname: get_env_or_default("PLC_HOSTNAME", Some("plc.directory")).unwrap(),
302 http_external,
303 user_agent: get_env_or_default("USER_AGENT", None).unwrap_or(default_user_agent),
304 dns_nameservers: get_env_or_default("DNS_NAMESERVERS", None),
305 certificate_bundles: get_env_or_default("CERTIFICATE_BUNDLES", None),
306 redis_url: get_env_or_default("REDIS_URL", None),
307 sqlite_url: get_env_or_default("SQLITE_URL", None),
308 queue_adapter: get_env_or_default("QUEUE_ADAPTER", Some("mpsc")).unwrap(),
309 queue_redis_url: get_env_or_default("QUEUE_REDIS_URL", None),
310 queue_redis_prefix: get_env_or_default(
311 "QUEUE_REDIS_PREFIX",
312 Some("queue:handleresolver:"),
313 )
314 .unwrap(),
315 queue_worker_id: get_env_or_default("QUEUE_WORKER_ID", Some("worker1")).unwrap(),
316 queue_buffer_size: parse_env("QUEUE_BUFFER_SIZE", 1000)?,
317 cache_ttl_memory: parse_env("CACHE_TTL_MEMORY", 600)?,
318 cache_ttl_redis: parse_env("CACHE_TTL_REDIS", 7776000)?,
319 cache_ttl_sqlite: parse_env("CACHE_TTL_SQLITE", 7776000)?,
320 queue_redis_timeout: parse_env("QUEUE_REDIS_TIMEOUT", 5)?,
321 queue_redis_dedup_enabled: parse_env("QUEUE_REDIS_DEDUP_ENABLED", false)?,
322 queue_redis_dedup_ttl: parse_env("QUEUE_REDIS_DEDUP_TTL", 60)?,
323 queue_sqlite_max_size: parse_env("QUEUE_SQLITE_MAX_SIZE", 10000)?,
324 resolver_max_concurrent: parse_env("RESOLVER_MAX_CONCURRENT", 0)?,
325 resolver_max_concurrent_timeout_ms: parse_env("RESOLVER_MAX_CONCURRENT_TIMEOUT_MS", 0)?,
326 etag_seed: get_env_or_default("ETAG_SEED", Some(env!("CARGO_PKG_VERSION"))).unwrap(),
327 cache_max_age: parse_env("CACHE_MAX_AGE", 86400)?, // 24 hours
328 cache_stale_if_error: parse_env("CACHE_STALE_IF_ERROR", 172800)?, // 48 hours
329 cache_stale_while_revalidate: parse_env("CACHE_STALE_WHILE_REVALIDATE", 86400)?, // 24 hours
330 cache_max_stale: parse_env("CACHE_MAX_STALE", 172800)?, // 48 hours
331 cache_min_fresh: parse_env("CACHE_MIN_FRESH", 3600)?, // 1 hour
332 cache_control_header: None, // Will be calculated below
333 metrics_adapter: get_env_or_default("METRICS_ADAPTER", Some("noop")).unwrap(),
334 metrics_statsd_host: get_env_or_default("METRICS_STATSD_HOST", None),
335 metrics_statsd_bind: get_env_or_default("METRICS_STATSD_BIND", Some("[::]:0")).unwrap(),
336 metrics_prefix: get_env_or_default("METRICS_PREFIX", Some("quickdid")).unwrap(),
337 metrics_tags: get_env_or_default("METRICS_TAGS", None),
338 proactive_refresh_enabled: parse_env("PROACTIVE_REFRESH_ENABLED", false)?,
339 proactive_refresh_threshold: parse_env("PROACTIVE_REFRESH_THRESHOLD", 0.8)?,
340 static_files_dir: get_env_or_default("STATIC_FILES_DIR", Some("www")).unwrap(),
341 jetstream_enabled: parse_env("JETSTREAM_ENABLED", false)?,
342 jetstream_hostname: get_env_or_default(
343 "JETSTREAM_HOSTNAME",
344 Some("jetstream.atproto.tools"),
345 )
346 .unwrap(),
347 };
348
349 // Calculate the Cache-Control header value if enabled
350 config.cache_control_header = config.calculate_cache_control_header();
351
352 Ok(config)
353 }
354
355 /// Calculate the Cache-Control header value based on configuration.
356 /// Returns None if cache_max_age is 0 (disabled).
357 fn calculate_cache_control_header(&self) -> Option<String> {
358 if self.cache_max_age == 0 {
359 return None;
360 }
361
362 Some(format!(
363 "public, max-age={}, stale-while-revalidate={}, stale-if-error={}, max-stale={}, min-fresh={}",
364 self.cache_max_age,
365 self.cache_stale_while_revalidate,
366 self.cache_stale_if_error,
367 self.cache_max_stale,
368 self.cache_min_fresh
369 ))
370 }
371
372 /// Validate the configuration for correctness and consistency
373 ///
374 /// Checks:
375 /// - Cache TTL values are positive (> 0)
376 /// - Queue timeout is positive (> 0)
377 /// - Queue adapter is a valid value ('mpsc', 'redis', 'sqlite', 'noop', 'none')
378 ///
379 /// ## Example
380 ///
381 /// ```rust,no_run
382 /// # use quickdid::config::Config;
383 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
384 /// let config = Config::from_env()?;
385 /// config.validate()?; // Ensures all values are valid
386 /// # Ok(())
387 /// # }
388 /// ```
389 ///
390 /// ## Errors
391 ///
392 /// Returns `ConfigError::InvalidTtl` if TTL values are 0 or negative
393 /// Returns `ConfigError::InvalidTimeout` if timeout values are 0 or negative
394 /// Returns `ConfigError::InvalidValue` if queue adapter is invalid
395 pub fn validate(&self) -> Result<(), ConfigError> {
396 if self.cache_ttl_memory == 0 {
397 return Err(ConfigError::InvalidTtl(
398 "CACHE_TTL_MEMORY must be > 0".to_string(),
399 ));
400 }
401 if self.cache_ttl_redis == 0 {
402 return Err(ConfigError::InvalidTtl(
403 "CACHE_TTL_REDIS must be > 0".to_string(),
404 ));
405 }
406 if self.cache_ttl_sqlite == 0 {
407 return Err(ConfigError::InvalidTtl(
408 "CACHE_TTL_SQLITE must be > 0".to_string(),
409 ));
410 }
411 if self.queue_redis_timeout == 0 {
412 return Err(ConfigError::InvalidTimeout(
413 "QUEUE_REDIS_TIMEOUT must be > 0".to_string(),
414 ));
415 }
416 if self.queue_redis_dedup_enabled && self.queue_redis_dedup_ttl == 0 {
417 return Err(ConfigError::InvalidTtl(
418 "QUEUE_REDIS_DEDUP_TTL must be > 0 when deduplication is enabled".to_string(),
419 ));
420 }
421 match self.queue_adapter.as_str() {
422 "mpsc" | "redis" | "sqlite" | "noop" | "none" => {}
423 _ => {
424 return Err(ConfigError::InvalidValue(format!(
425 "Invalid QUEUE_ADAPTER '{}', must be 'mpsc', 'redis', 'sqlite', 'noop', or 'none'",
426 self.queue_adapter
427 )));
428 }
429 }
430 if self.resolver_max_concurrent > 10000 {
431 return Err(ConfigError::InvalidValue(
432 "RESOLVER_MAX_CONCURRENT must be between 0 and 10000".to_string(),
433 ));
434 }
435 if self.resolver_max_concurrent_timeout_ms > 60000 {
436 return Err(ConfigError::InvalidTimeout(
437 "RESOLVER_MAX_CONCURRENT_TIMEOUT_MS must be <= 60000 (60 seconds)".to_string(),
438 ));
439 }
440
441 // Validate metrics configuration
442 match self.metrics_adapter.as_str() {
443 "noop" | "statsd" => {}
444 _ => {
445 return Err(ConfigError::InvalidValue(format!(
446 "Invalid METRICS_ADAPTER '{}', must be 'noop' or 'statsd'",
447 self.metrics_adapter
448 )));
449 }
450 }
451
452 // If statsd is configured, ensure host is provided
453 if self.metrics_adapter == "statsd" && self.metrics_statsd_host.is_none() {
454 return Err(ConfigError::MissingRequired(
455 "METRICS_STATSD_HOST is required when METRICS_ADAPTER is 'statsd'".to_string(),
456 ));
457 }
458
459 // Validate proactive refresh threshold
460 if self.proactive_refresh_threshold < 0.0 || self.proactive_refresh_threshold > 1.0 {
461 return Err(ConfigError::InvalidValue(format!(
462 "PROACTIVE_REFRESH_THRESHOLD must be between 0.0 and 1.0, got {}",
463 self.proactive_refresh_threshold
464 )));
465 }
466
467 Ok(())
468 }
469}