How does once_cell::sync::Lazy::get_or_try_init enable fallible lazy initialization with custom error handling?
once_cell::sync::Lazy::get_or_try_init provides a way to lazily initialize a value when the initialization process might fail, allowing you to handle initialization errors gracefully rather than panicking. Unlike the standard Lazy::force() or Deref implementations that expect infallible initialization, get_or_try_init returns a Result that propagates initialization errors to the caller. This is essential for scenarios where initialization involves operations that can fail—like parsing configuration files, establishing database connections, or performing I/O—and where you want to retry initialization or take alternative action on failure rather than crashing the application. The method ensures thread-safe, race-free initialization where the initialization closure runs exactly once, even under concurrent access attempts.
Basic Lazy Initialization
use once_cell::sync::Lazy;
// Standard Lazy with infallible initialization
static CONFIG: Lazy<String> = Lazy::new(|| {
// This cannot fail - must always succeed
String::from("default configuration")
});
fn main() {
// Access via Deref
println!("Config: {}", *CONFIG);
// Or via force() - same effect
println!("Config: {}", CONFIG.force());
}Standard Lazy expects initialization that always succeeds, accessed via Deref or force().
The Problem with Fallible Initialization
use once_cell::sync::Lazy;
use std::fs;
// Problem: How to handle initialization that can fail?
static CONFIG: Lazy<String> = Lazy::new(|| {
// This can fail! But Lazy::new() requires fn() -> T
// We can't return Result from here
fs::read_to_string("config.txt")
.expect("Failed to read config - will panic!")
});
fn main() {
// If config.txt doesn't exist, this panics
// There's no way to handle the error gracefully
println!("Config: {}", *CONFIG);
}Standard Lazy cannot handle initialization failures gracefully—it panics on error.
Using get_or_try_init
use once_cell::sync::Lazy;
use std::fs;
// Lazy that can fail during initialization
static CONFIG: Lazy<String> = Lazy::new(|| {
// This closure returns String, but initialization might fail
// We use get_or_try_init to handle this
fs::read_to_string("config.txt")
.expect("Default initialization")
});
fn main() -> Result<(), Box<dyn std::error::Error>> {
// get_or_try_init allows fallible initialization
// The initialization closure returns Result<T, E>
// Use a Lazy<Result<T, E>> pattern for fallible init
static FALLIBLE_CONFIG: Lazy<Result<String, std::io::Error>> = Lazy::new(|| {
fs::read_to_string("config.txt")
});
// Access with error handling
match FALLIBLE_CONFIG.force().as_ref() {
Ok(config) => println!("Config loaded: {}", config),
Err(e) => {
println!("Failed to load config: {}", e);
// Handle the error gracefully
println!("Using default configuration");
}
}
// Or use get_or_try_init with Lazy<OnceCell<T>>
use once_cell::sync::OnceCell;
static LAZY_CONFIG: OnceCell<String> = OnceCell::new();
let config = LAZY_CONFIG.get_or_try_init(|| {
fs::read_to_string("config.txt")
})?;
println!("Config: {}", config);
Ok(())
}OnceCell::get_or_try_init is the primary way to handle fallible initialization.
Lazy with Result Type
use once_cell::sync::Lazy;
use std::fs;
// Common pattern: Lazy<Result<T, E>>
static DATABASE_URL: Lazy<Result<String, std::io::Error>> = Lazy::new(|| {
fs::read_to_string("database_url.txt")
});
static PARSED_CONFIG: Lazy<Result<Config, ConfigError>> = Lazy::new(|| {
let content = fs::read_to_string("config.toml")?;
parse_config(&content)
});
#[derive(Debug)]
struct Config {
port: u16,
host: String,
}
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(String),
}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self {
ConfigError::Io(e)
}
}
fn parse_config(content: &str) -> Result<Config, ConfigError> {
// Simplified parsing
let port = content.lines()
.find(|l| l.starts_with("port="))
.and_then(|l| l.strip_prefix("port="))
.and_then(|v| v.parse().ok())
.unwrap_or(8080);
let host = content.lines()
.find(|l| l.starts_with("host="))
.and_then(|l| l.strip_prefix("host="))
.map(String::from)
.unwrap_or_else(|| "localhost".to_string());
Ok(Config { port, host })
}
fn main() {
// Check if initialization succeeded
match PARSED_CONFIG.force() {
Ok(config) => {
println!("Config: {}:{}", config.host, config.port);
}
Err(e) => {
eprintln!("Failed to load config: {:?}", e);
// Use defaults or exit
}
}
}A common pattern wraps the result in Result<T, E> to capture initialization errors.
OnceCell for Explicit Fallible Initialization
use once_cell::sync::OnceCell;
use std::fs;
// OnceCell with get_or_try_init is cleaner for fallible cases
static CONFIG: OnceCell<String> = OnceCell::new();
fn get_config() -> Result<&'static str, std::io::Error> {
// get_or_try_init runs the closure only once
// Subsequent calls return the cached value
CONFIG.get_or_try_init(|| {
println!("Initializing config...");
fs::read_to_string("config.txt")
})
.map(|s| s.as_str())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// First call - runs initialization
let config = get_config()?;
println!("Config: {}", config);
// Subsequent calls - return cached value
let config2 = get_config()?;
println!("Config again: {}", config2);
Ok(())
}OnceCell::get_or_try_init is designed for fallible lazy initialization.
Thread Safety Guarantees
use once_cell::sync::OnceCell;
use std::sync::Arc;
use std::thread;
static RESOURCE: OnceCell<String> = OnceCell::new();
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize with multiple threads racing
let mut handles = vec![];
for i in 0..5 {
let handle = thread::spawn(move || {
let result = RESOURCE.get_or_try_init(|| {
println!("Thread {} initializing...", i);
thread::sleep(std::time::Duration::from_millis(100));
// Only one thread runs this
if i == 3 {
// Simulate initialization failure
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"Simulated failure"
));
}
Ok(format!("Initialized by thread {}", i))
});
(i, result.is_ok(), result.ok().map(String::as_str))
});
handles.push(handle);
}
for handle in handles {
let (thread_id, success, value) = handle.join().unwrap();
println!("Thread {}: success={}, value={:?}", thread_id, success, value);
}
Ok(())
}get_or_try_init ensures the closure runs only once, even with concurrent access.
Error Handling Strategies
use once_cell::sync::OnceCell;
use std::fs;
static CONFIG: OnceCell<String> = OnceCell::new();
fn get_config_or_default() -> &'static str {
// Strategy 1: Default on error
CONFIG.get_or_try_init(|| {
fs::read_to_string("config.txt")
})
.unwrap_or_else(|_| {
// Return a static default
CONFIG.get_or_init(|| "default config".to_string())
})
.as_str()
}
static DATABASE: OnceCell<Database> = OnceCell::new();
struct Database {
url: String,
}
impl Database {
fn connect(url: &str) -> Result<Self, String> {
// Simulated connection
if url.is_empty() {
Err("Empty URL".to_string())
} else {
Ok(Database { url: url.to_string() })
}
}
}
fn get_database() -> Result<&'static Database, String> {
// Strategy 2: Propagate error to caller
DATABASE.get_or_try_init(|| {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "localhost:5432".to_string());
Database::connect(&url)
})
}
fn main() {
// Default on error
let config = get_config_or_default();
println!("Config: {}", config);
// Propagate error
match get_database() {
Ok(db) => println!("Connected to: {}", db.url),
Err(e) => eprintln!("Failed to connect: {}", e),
}
}Different strategies for handling initialization errors depending on use case.
Retry After Failure
use once_cell::sync::OnceCell;
use std::sync::Mutex;
use std::fs;
// For retry capability, use a different pattern
struct RetryableInit<T> {
cell: OnceCell<T>,
init_failed: Mutex<bool>,
}
impl<T> RetryableInit<T> {
const fn new() -> Self {
Self {
cell: OnceCell::new(),
init_failed: Mutex::new(false),
}
}
fn get_or_try_init<F, E>(&self, f: F) -> Result<&T, E>
where
F: FnOnce() -> Result<T, E>,
{
// Check if we already have a value
if let Some(val) = self.cell.get() {
return Ok(val);
}
// Check if previous attempt failed
{
let failed = self.init_failed.lock().unwrap();
if *failed {
// Allow retry by proceeding
}
}
let result = self.cell.get_or_try_init(f);
if result.is_err() {
*self.init_failed.lock().unwrap() = true;
// Note: OnceCell doesn't support retry by default
// This is a simplified pattern
}
result
}
}
// Simpler approach: Use Lazy<Result<T, E>> and check
static FALLIBLE_RESOURCE: once_cell::sync::Lazy<Result<String, std::io::Error>> =
once_cell::sync::Lazy::new(|| {
fs::read_to_string("resource.txt")
});
fn main() {
// Check result each time (initialization happens once)
match FALLIBLE_RESOURCE.force() {
Ok(resource) => println!("Resource: {}", resource),
Err(e) => {
eprintln!("Failed: {}", e);
// Can't retry - Lazy already initialized
// Would need to use a different pattern for retries
}
}
}OnceCell doesn't natively support retry after failure; alternative patterns are needed.
Comparing Lazy and OnceCell Approaches
use once_cell::sync::{Lazy, OnceCell};
// Approach 1: Lazy<Result<T, E>>
// - Initialization happens on first access
// - Error is cached (can't retry)
// - Access via .force() or Deref
static LAZY_CONFIG: Lazy<Result<String, std::io::Error>> = Lazy::new(|| {
std::fs::read_to_string("config.txt")
});
// Approach 2: OnceCell<T> with get_or_try_init
// - More explicit control
// - Clearer error handling
// - Same caching behavior
static ONCE_CONFIG: OnceCell<String> = OnceCell::new();
fn get_config() -> Result<&'static str, std::io::Error> {
ONCE_CONFIG.get_or_try_init(|| {
std::fs::read_to_string("config.txt")
}).map(|s| s.as_str())
}
// Approach 3: OnceCell<Result<T, E>>
// - Can check if initialization was attempted
// - Error is still cached
static ONCE_RESULT: OnceCell<Result<String, std::io::Error>> = OnceCell::new();
fn get_config_result() -> &'static Result<String, std::io::Error> {
ONCE_RESULT.get_or_init(|| {
std::fs::read_to_string("config.txt")
})
}
fn main() {
// Lazy<Result> approach
match LAZY_CONFIG.force() {
Ok(config) => println!("Lazy config: {}", config),
Err(e) => eprintln!("Lazy error: {}", e),
}
// OnceCell with get_or_try_init approach
match get_config() {
Ok(config) => println!("OnceCell config: {}", config),
Err(e) => eprintln!("OnceCell error: {}", e),
}
// OnceCell<Result> approach
match get_config_result() {
Ok(config) => println!("OnceCell result: {}", config),
Err(e) => eprintln!("OnceCell result error: {}", e),
}
}Different patterns for different error handling needs.
Practical Example: Database Connection
use once_cell::sync::OnceCell;
use std::time::Duration;
struct ConnectionPool {
url: String,
// Simplified - real pool would have actual connections
}
impl ConnectionPool {
fn connect(url: &str, timeout: Duration) -> Result<Self, String> {
// Simulate connection that might fail
if url.is_empty() {
return Err("Empty connection URL".to_string());
}
if url.contains("unreachable") {
return Err("Host unreachable".to_string());
}
// Simulate connection delay
std::thread::sleep(Duration::from_millis(10));
Ok(ConnectionPool { url: url.to_string() })
}
fn query(&self, sql: &str) -> String {
format!("Result from {}: {}", self.url, sql)
}
}
static DB_POOL: OnceCell<ConnectionPool> = OnceCell::new();
fn get_pool() -> Result<&'static ConnectionPool, String> {
DB_POOL.get_or_try_init(|| {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "localhost:5432/mydb".to_string());
let timeout = Duration::from_secs(5);
ConnectionPool::connect(&url, timeout)
})
}
fn query_database(sql: &str) -> Result<String, String> {
let pool = get_pool()?;
Ok(pool.query(sql))
}
fn main() {
match query_database("SELECT * FROM users") {
Ok(result) => println!("Query result: {}", result),
Err(e) => {
eprintln!("Database error: {}", e);
// Application can continue without database
// Or exit, or use fallback
}
}
}Fallible initialization is essential for resources that might not be available at startup.
Practical Example: Configuration Parsing
use once_cell::sync::OnceCell;
use std::collections::HashMap;
#[derive(Debug)]
struct AppConfig {
database_url: String,
api_keys: HashMap<String, String>,
max_connections: usize,
}
#[derive(Debug)]
enum ConfigError {
IoError(std::io::Error),
ParseError(String),
}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self {
ConfigError::IoError(e)
}
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::IoError(e) => write!(f, "IO error: {}", e),
ConfigError::ParseError(s) => write!(f, "Parse error: {}", s),
}
}
}
impl std::error::Error for ConfigError {}
fn parse_config(content: &str) -> Result<AppConfig, ConfigError> {
let mut config = AppConfig {
database_url: String::new(),
api_keys: HashMap::new(),
max_connections: 10,
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() != 2 {
return Err(ConfigError::ParseError(format!("Invalid line: {}", line)));
}
match parts[0] {
"database_url" => config.database_url = parts[1].to_string(),
"max_connections" => {
config.max_connections = parts[1].parse()
.map_err(|_| ConfigError::ParseError("Invalid max_connections".to_string()))?;
}
key if key.starts_with("api_key_") => {
let name = key.strip_prefix("api_key_").unwrap();
config.api_keys.insert(name.to_string(), parts[1].to_string());
}
_ => {} // Ignore unknown keys
}
}
if config.database_url.is_empty() {
return Err(ConfigError::ParseError("Missing database_url".to_string()));
}
Ok(config)
}
static CONFIG: OnceCell<AppConfig> = OnceCell::new();
fn get_config() -> Result<&'static AppConfig, ConfigError> {
CONFIG.get_or_try_init(|| {
let content = std::fs::read_to_string("app.conf")
.map_err(ConfigError::from)?;
parse_config(&content)
})
}
fn main() {
match get_config() {
Ok(config) => {
println!("Database: {}", config.database_url);
println!("Max connections: {}", config.max_connections);
}
Err(e) => {
eprintln!("Failed to load config: {}", e);
std::process::exit(1);
}
}
}Complex initialization with multiple failure modes benefits from get_or_try_init.
Synthesis
When to use get_or_try_init:
| Scenario | Reason |
|---|---|
| File I/O | Files might not exist or be readable |
| Network connections | Remote resources might be unavailable |
| Configuration parsing | Config might be malformed |
| Resource acquisition | External resources might fail |
| Validation-required init | Input might be invalid |
Lazy vs OnceCell for fallible init:
| Pattern | Access | Error Handling | Retry |
|---|---|---|---|
Lazy<Result<T, E>> |
force() or deref |
Check Result | No |
OnceCell::get_or_try_init |
Method call | Propagates | No |
| Custom wrapper | Custom | Custom | Possible |
Key characteristics:
| Property | Description |
|---|---|
| Thread-safe | Safe for concurrent access |
| Runs once | Initialization closure executes exactly once |
| Caches result | Success or failure is cached |
| No retry | Once failed, stays failed (in OnceCell) |
| Error propagation | Returns Result<&T, E> to caller |
Key insight: get_or_try_init bridges the gap between lazy initialization's convenience and fallible operations' reality. Pure lazy initialization assumes success, which works for in-memory computations but breaks down for I/O, parsing, or any operation that can fail. By returning a Result, get_or_try_init lets callers decide how to handle initialization failures—whether to use defaults, exit gracefully, or attempt alternative initialization paths. The thread-safety guarantee ensures that even with multiple threads racing to initialize, the closure runs exactly once and all threads receive the same result. The main limitation to understand is that once initialization fails, the OnceCell caches that failure—subsequent calls will return the same error without retrying. If retry is needed, a different pattern (like storing an Option in a Mutex or using a custom wrapper) becomes necessary.
