Skip to content

Zart is in active development — breaking API changes may occur despite our best efforts to keep contracts stable.

Brewery Finder

This example builds a durable workflow that takes a US ZIP code, resolves it to a city via the Zippopotamus API, then fetches breweries in that city from the Open Brewery DB. All steps are persisted — if the process crashes mid-flight, completed API calls are not retried.

Features demonstrated: #[zart_durable], #[zart_step], sequential steps, structured output.

#[derive(Debug, Clone, Serialize, Deserialize)]
struct FinderInput {
zip_code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BreweryInfo {
name: String,
brewery_type: String,
city: String,
state: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FinderOutput {
zip_code: String,
city: String,
state: String,
breweries: Vec<BreweryInfo>,
found_at: String,
}

Each step is defined as a standalone async function using #[zart_step]. The macro generates a step struct and implements ZartStep automatically.

use zart::error::StepError;
use zart::prelude::*;
use zart::zart_step;
#[zart_step("lookup-zip", retry = "exponential(3, 1s)")]
async fn lookup_zip(
client: &reqwest::Client,
zip_code: &str,
) -> Result<(String, String), StepError> {
println!("[lookup-zip] Attempt {}", zart::context().current_attempt + 1);
let resp = client
.get(format!("https://api.zippopotam.us/us/{zip_code}"))
.send().await
.map_err(|e| StepError::Failed {
step: "lookup-zip".into(), reason: e.to_string(),
})?;
let zip_resp: ZipResponse = resp.json().await
.map_err(|e| StepError::Failed {
step: "lookup-zip".into(), reason: format!("parse error: {e}"),
})?;
let place = zip_resp.places.first().ok_or_else(|| StepError::Failed {
step: "lookup-zip".into(),
reason: format!("no place found for ZIP {zip_code}"),
})?;
Ok((place.place_name.clone(), place.state.clone()))
}
#[zart_step("find-breweries", retry = "exponential(3, 1s)")]
async fn find_breweries(
client: &reqwest::Client,
city: &str,
) -> Result<Vec<BreweryRaw>, StepError> {
println!("[find-breweries] Attempt {}", zart::context().current_attempt + 1);
let resp = client
.get("https://api.openbrewerydb.org/v1/breweries")
.query(&[("by_city", city)])
.send().await
.map_err(|e| StepError::Failed {
step: "find-breweries".into(), reason: e.to_string(),
})?;
resp.json().await.map_err(|e| StepError::Failed {
step: "find-breweries".into(),
reason: format!("parse error: {e}"),
})
}
#[zart_step("transform-results")]
async fn transform_results(
raw: &[BreweryRaw],
city: &str,
state: &str,
) -> Result<Vec<BreweryInfo>, StepError> {
Ok(raw.iter().map(|b| BreweryInfo {
name: b.name.clone(),
brewery_type: b.brewery_type.clone().unwrap_or_else(|| "unknown".into()),
city: b.city.clone().unwrap_or_else(|| city.to_string()),
state: b.state.clone().unwrap_or_else(|| state.to_string()),
}).collect())
}

The durable handler composes steps by calling them directly with .await. Each step call is persisted before moving on.

#[zart_durable("brewery-finder", timeout = "5m")]
async fn brewery_finder(data: FinderInput) -> Result<FinderOutput, TaskError> {
let client = reqwest::Client::new();
// Step 1: resolve ZIP → city/state, retry up to 3× with exponential backoff
let (city, state) = lookup_zip(&client, &data.zip_code).await?;
// Step 2: find breweries in that city, retry on transient failures
let raw_breweries = find_breweries(&client, &city).await?;
// Step 3: transform raw API data into structured output (no retries needed)
let breweries = transform_results(&raw_breweries, &city, &state).await?;
Ok(FinderOutput {
zip_code: data.zip_code,
city,
state,
breweries,
found_at: Utc::now().to_rfc3339(),
})
}
// Register the macro-generated handler (struct name is PascalCase of the fn name)
let mut registry = TaskRegistry::new();
registry.register("brewery-finder", BreweryFinder);
let registry = Arc::new(registry);
let durable = DurableScheduler::new(sched.clone());
// Start and wait for typed output — no manual deserialization needed
let output = durable
.start_and_wait_for::<BreweryFinder>(
"my-run-1",
"brewery-finder",
&FinderInput { zip_code: "97209".to_string() },
Duration::from_secs(60),
)
.await?;
println!(
"Found {} breweries in {}, {}",
output.breweries.len(), output.city, output.state
);
=== Zart Brewery Finder Example ===
Starting execution 'brewery-finder-demo-...' for ZIP 97209...
Initial execution status: Pending
Waiting for execution to complete...
Execution completed!
ZIP: 97209
City: Portland
State: Oregon
Breweries: 20
1. Breakside Brewery (micro) — Portland, Oregon
2. Cascade Brewing (brewpub) — Portland, Oregon
...

#[zart_step] macro — turns an async function into a step struct that implements ZartStep. The step name and retry config are specified as attributes. Each parameter becomes a field on the generated struct.

retry attribute"exponential(3, 1s)" generates a RetryConfig::exponential(3, Duration::from_secs(1)). Use "fixed(n, dur)" for constant-delay retries. Omit the attribute for steps that don’t need retry.

Direct .await — steps produced by the macro implement IntoFuture, so calling step_fn(args).await? runs the step through the framework. The step name and retry policy are applied automatically.

Sequential data flow — each step’s return value is available to the next step as a normal Rust variable. There is no special API for passing data between steps.