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.
Data types
Section titled “Data types”#[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,}Step definitions
Section titled “Step definitions”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 workflow
Section titled “The workflow”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(), })}Running the workflow
Section titled “Running the workflow”// 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 neededlet 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);What you’ll see
Section titled “What you’ll see”=== 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 ...Key concepts
Section titled “Key concepts”#[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.