DEV Community

Cover image for Building a Production-Ready Rust API: Lessons from HNG Stage 0
iamprecieee
iamprecieee

Posted on

Building a Production-Ready Rust API: Lessons from HNG Stage 0

When I started the HNG Stage 0 backend task, I thought it would be straightforward. Build a simple API endpoint that returns user profile data and a random cat fact. How hard could it be?

Turns out, the real value was not in getting it to work, but in getting it to work properly. Let me take you through it.

The Task
The requirements were clear:

  • Create a REST API with a profile endpoint
  • Fetch data from an external API
  • Handle errors properly
  • Add proper documentation
  • Write tests Simple enough.

But as I dove in, I realised this was a good opportunity to level up my Rust skills.

Error Handling
Here's something I used to do most of the time:

let response = reqwest::get(api_url).await.unwrap(); 
let data = response.json().await.unwrap(); 
Enter fullscreen mode Exit fullscreen mode

This works fine until it doesn't. If the cat-fact external API should go down or return unexpected data, my application would crash. So not exactly production-ready. This task forced me to think differently. Instead of unwrapping everything and hoping for the best, I started anticipating failures:

match response { 
    Ok(res) => { 
        let json_result = res.json::<CatFactResponse>().await; 
        match json_result { 
            Ok(cat_fact) => cat_fact.fact, 
            Err(_) => String::from(DEFAULT_CAT_FACT), 
        } 
    } 
    Err(_) => String::from(DEFAULT_CAT_FACT), 
} 
Enter fullscreen mode Exit fullscreen mode

So if the cat-fact API is down, my application wouldn't crash. It would fall back to a default fact and my users still gets valid responses. The difference between a crashed server and a gracefully degraded service.

Logging vs User-Facing Errors
Another thing that clicked for me was the separation between what I log and what I return to users. When configuration fails, I log the detailed error:

let config = GlobalConfig::from_env().map_err(|e| { 
    tracing::error!("Failed to complete configuration: {}", e); 
    anyhow!("Configuration error") 
})?; 
Enter fullscreen mode Exit fullscreen mode

The logs show exactly what went wrong. But my users just see "Configuration error". No stack traces, no internal paths, no sensitive information. Just enough to know something went wrong without exposing my internals. This seems obvious now, but it took implementing it to understand why it matters.

Crate Features
One thing I've come to love about Rust is its crate features. As it turns out, Rust crates use feature flags to keep compile times down and binary sizes small. Each crate has its own set of features. The tokio "full" feature provides everything for its crate, but for production I would pick only what I need. The reqwest "json" feature adds JSON support. Without it, no JSON serialization. It's a powerful system but required that I read the documentation. I couldn't just add a dependency and assume all functionality was available. This tripped me up more than once, but I've gotten the hang of it.

Looking back, this Stage 0 task was less about building an API and more about building good habits. I had to think about what could go wrong and handle it, separate internal errors from user-facing messages, read the feature flags documentation for every crate I used. My code passed all tests. My API works. But more importantly, it works the way production code should work; with proper error handling, useful logging, automatic documentation, and solid test coverage.

Top comments (0)