- Posts
- Write a Push Handler in Rust, Then Prove It Works
Write a Push Handler in Rust, Then Prove It Works
Your team writes Rust. Ironflow has SDKs for Go, Node and Python, but push mode is plain HTTP. The engine sends a signed JSON request to your endpoint, and your endpoint answers with JSON. Any language with an HTTP server can do that.
Getting a 200 back is the easy part. The hard part is the memo. Your handler
computes a step ID on every call, and the engine stores the same ID. If the two
differ by one byte, a completed step runs again. For a card charge, that is a
second charge.
ironflow conformance push, added in Ironflow v0.43.1,
checks a push handler against the push protocol.
It sends the requests the engine sends and checks each answer. It needs no
Ironflow server. This walkthrough builds a handler in Rust, one level at a time,
and runs the suite after each level. Every report below comes from a real run
against Ironflow v0.44.1.
Create the project
Section titled “Create the project”You need Rust 1.88 or newer, the floor set by sha2 0.11, and the ironflow
CLI. The installation guide has the
CLI downloads. The commands below use fish.
cargo new push-rustcd push-rustcargo add axum@0.8 serde_json@1 hmac@0.13 sha2@0.11 hex@0.4 humantime@2cargo add tokio@1 --features macros,rt-multi-threadset -gx IRONFLOW_SIGNING_KEY dev-signing-keyThe engine signs push requests with IRONFLOW_SIGNING_KEY. The conformance
suite reads the same variable, and so does the handler in this post. Set it
once in each terminal.
The suite cannot know what your real functions return. Your endpoint must therefore host four fixture functions with fixed behavior. The push conformance page defines them, and each level of the suite needs more of them.
Level 1: transport and signatures
Section titled “Level 1: transport and signatures”Level 1 needs two fixtures. conformance-echo returns the run fields it
received. conformance-fail fails without a retry. Here is my first attempt.
Save it as src/main.rs:
use std::sync::LazyLock;use std::time::{SystemTime, UNIX_EPOCH};
use axum::{Json, Router, http::HeaderMap, http::StatusCode, routing::post};use hmac::{Hmac, KeyInit, Mac};use serde_json::{Value, json};use sha2::Sha256;
static KEY: LazyLock<String> = LazyLock::new(|| std::env::var("IRONFLOW_SIGNING_KEY").expect("set IRONFLOW_SIGNING_KEY"));
#[tokio::main]async fn main() { LazyLock::force(&KEY); let app = Router::new().route("/ironflow", post(handle)); let listener = tokio::net::TcpListener::bind("127.0.0.1:8099").await.unwrap(); axum::serve(listener, app).await.unwrap();}
async fn handle(headers: HeaderMap, Json(req): Json<Value>) -> (StatusCode, Json<Value>) { let signature = headers.get("x-ironflow-signature").and_then(|h| h.to_str().ok()); let body = serde_json::to_vec(&req).unwrap(); if !verify(signature.unwrap_or(""), &body) { return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "bad signature" }))); }
let response = match req["function_id"].as_str().unwrap_or("") { "conformance-echo" => json!({ "status": "completed", "result": { "run_id": req["run_id"], "function_id": req["function_id"], "attempt": req["attempt"], "data": req["event"]["data"], }, }), "conformance-fail" => json!({ "status": "failed", "error": { "message": req["event"]["data"]["message"], "retryable": false }, }), _ => return (StatusCode::NOT_FOUND, Json(json!({ "error": "unknown function" }))), }; (StatusCode::OK, Json(response))}
fn verify(header: &str, body: &[u8]) -> bool { let (mut t, mut v1) = (None, None); for pair in header.split(',') { match pair.split_once('=') { Some(("t", value)) => t = Some(value), Some(("v1", value)) => v1 = hex::decode(value).ok(), _ => {} } } let (Some(t), Some(v1)) = (t, v1) else { return false }; let Ok(sent) = t.parse::<i64>() else { return false }; let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; if (now - sent).abs() > 300 { return false; }
let mut mac = Hmac::<Sha256>::new_from_slice(KEY.as_bytes()).unwrap(); mac.update(t.as_bytes()); mac.update(b"."); mac.update(body); mac.verify_slice(&v1).is_ok()}verify follows the four signature rules. It splits each header pair on the
first = only and ignores pairs it does not know. It rejects a timestamp more
than five minutes from now. verify_slice compares the bytes in constant time.
A GET gets 405 because the route accepts only post.
Run the handler in one terminal and the suite in a second terminal:
cargo runironflow conformance push --url http://127.0.0.1:8099/ironflow --level 1PASS L1 transport.method-not-allowedFAIL L1 transport.unknown-function function_id "conformance-missing" returned HTTP 401, want 404 (if the handler verifies signatures, pass --signing-key) contract: https://docs.ironflow.run/reference/api/push-protocol/#pushrequestFAIL L1 transport.echo HTTP 401, want 200: {"error":"bad signature"} (if the handler verifies signatures, pass --signing-key) contract: https://docs.ironflow.run/reference/api/push-protocol/#pushrequestFAIL L1 transport.unknown-fields HTTP 401, want 200: {"error":"bad signature"} (if the handler verifies signatures, pass --signing-key) contract: https://docs.ironflow.run/reference/api/push-protocol/#pushrequestFAIL L1 transport.fail-encoding HTTP 401: the engine retries every non-2xx answer, 4xx included. Return 200 with status "failed" and retryable false contract: https://docs.ironflow.run/reference/api/push-protocol/#errorsFAIL L1 signature.valid HTTP 401, want 200: {"error":"bad signature"} contract: https://docs.ironflow.run/reference/api/push-protocol/#signature-verificationPASS L1 signature.missingPASS L1 signature.tampered-bodyPASS L1 signature.wrong-keyPASS L1 signature.skew-pastPASS L1 signature.skew-futureFAIL L1 signature.unknown-scheme HTTP 401, want 200: {"error":"bad signature"} contract: https://docs.ironflow.run/reference/api/push-protocol/#signature-verificationPASS L1 signature.malformedSKIP L2 steps.first-run (above --level 1)...7 passed, 6 failed, 8 skippedError: push conformance: one or more checks failedThe suite reads the key from IRONFLOW_SIGNING_KEY, so the hint about
--signing-key does not apply here. The key is correct. The handler rejects
every signed request.
Look at what passed. A handler that rejects everything passes every check that
expects a rejection: missing signature, tampered body, wrong key, old and future
timestamps. A unit test that sends only bad signatures would pass too.
signature.valid is the check that proves the handler accepts a good request.
The bug is the Json<Value> extractor. It parses the body, and the handler then
serializes the value again to verify it. Those are not the bytes the engine
signed. serde_json writes object keys in sorted order by default. The engine
encodes <a&b> as <a&b>, and serde_json writes it back as
plain <a&b>. The echo fixture puts those characters in event.data to catch
this bug.
The fix is to take the raw bytes, verify them, and only then parse. Replace the
use axum line, and the start of handle up to and including the signature
check, with:
use axum::{Json, Router, body::Bytes, http::HeaderMap, http::StatusCode, routing::post};
async fn handle(headers: HeaderMap, body: Bytes) -> (StatusCode, Json<Value>) { let signature = headers.get("x-ironflow-signature").and_then(|h| h.to_str().ok()); if !verify(signature.unwrap_or(""), &body) { return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "bad signature" }))); } let Ok(req) = serde_json::from_slice::<Value>(&body) else { return (StatusCode::BAD_REQUEST, Json(json!({ "error": "body is not JSON" }))); };
// The match on function_id is unchanged.Restart the handler and run level 1 again. Every level 1 check passes, and the last line now reads:
13 passed, 0 failed, 8 skippedLevel 2: durable steps
Section titled “Level 2: durable steps”Level 2 adds conformance-steps. It calls step.run twice with the same name,
once with a name that contains : and \, and then runs two parallel branches.
Each step returns a fixed value, so the suite can tell a replayed step from a
step that ran again.
The handler needs a small step context. It holds the completed steps from the
request, the current ID scope, a counter per step name, and the steps that ran
in this call. Add use std::collections::HashMap; to the imports, then add this
code:
fn conformance_steps(step: &mut Step) -> Value { let o1 = step.run("plain", || json!({ "s": "plain#1" })); let o2 = step.run("plain", || json!({ "s": "plain#2" })); let o3 = step.run("a:b\\c", || json!({ "s": "escaped" })); let branches = step.parallel("fan:out", 2, |step, branch| { let mut last = step.run("leaf", || json!({ "s": format!("leaf-{branch}-0") })); if branch == 0 { last = step.run("leaf", || json!({ "s": "leaf-0-1" })); } last }); json!([o1, o2, o3, branches[0], branches[1]])}
struct Step { memo: HashMap<String, Value>, scope: String, counters: HashMap<String, usize>, delta: Vec<Value>,}
impl Step { fn new(req: &Value) -> Self { let mut memo = HashMap::new(); for entry in req["steps"].as_array().into_iter().flatten() { if entry["status"] == "completed" { let id = entry["id"].as_str().unwrap_or("").to_string(); memo.insert(id, entry["output"].clone()); } } let scope = req["run_id"].as_str().unwrap_or("").to_string(); Step { memo, scope, counters: HashMap::new(), delta: Vec::new() } }
fn next_id(&mut self, name: &str) -> String { let index = self.counters.entry(name.to_string()).or_insert(0); let id = format!("{}:{}:{}", self.scope, escape(name), index); *index += 1; id }
fn run(&mut self, name: &str, body: impl FnOnce() -> Value) -> Value { let id = self.next_id(name); if let Some(output) = self.memo.get(&id) { return output.clone(); } let started_at = now_rfc3339(); let output = body(); self.delta.push(json!({ "id": id, "name": name, "type": "invoke", "status": "completed", "started_at": started_at, "ended_at": now_rfc3339(), "output": output, })); output }
fn parallel<T>(&mut self, name: &str, branches: usize, mut body: impl FnMut(&mut Step, usize) -> T) -> Vec<T> { let parent = format!("{}:{}", self.scope, escape(name)); let mut results = Vec::new(); for branch in 0..branches { let scope = std::mem::replace(&mut self.scope, format!("{parent}:{branch}")); let counters = std::mem::take(&mut self.counters); results.push(body(self, branch)); self.scope = scope; self.counters = counters; } results }}
fn escape(part: &str) -> String { part.replace('\\', "\\\\").replace(':', "\\:")}
fn now_rfc3339() -> String { humantime::format_rfc3339_seconds(SystemTime::now()).to_string()}Each rule of the step ID formula is one line here:
- The ID is
{scope}:{escape(name)}:{index}, and the index counts per name. The twoplainsteps get:plain:0and:plain:1. escapereplaces the backslash before the colon. In the other order, the backslash that escapes a colon is escaped again.- A parallel branch gets the scope
{parent}:{escape(name)}:{branch}and its own counters, which start at zero.std::mem::takegives each branch an empty map and puts the parent’s map back after the branch. runreturns a memoized output without calling the body, and it adds only the steps that ran todelta. The responsestepsarray carries this delta, not the full history.
The branches run one after the other. The protocol cares about IDs and outputs, not about threads.
Now let handle build a Step and return its delta. Replace the rest of
handle, from let response = match to the closing brace of the function,
with:
let mut step = Step::new(&req); let result = match req["function_id"].as_str().unwrap_or("") { "conformance-echo" => json!({ "run_id": req["run_id"], "function_id": req["function_id"], "attempt": req["attempt"], "data": req["event"]["data"], }), "conformance-fail" => { let error = json!({ "message": req["event"]["data"]["message"], "retryable": false }); return (StatusCode::OK, Json(json!({ "status": "failed", "error": error }))); } "conformance-steps" => conformance_steps(&mut step), _ => return (StatusCode::NOT_FOUND, Json(json!({ "error": "unknown function" }))), }; let response = json!({ "status": "completed", "steps": step.delta, "result": result }); (StatusCode::OK, Json(response))}Restart the handler and run level 2:
ironflow conformance push --url http://127.0.0.1:8099/ironflow --level 2The last line of the report:
19 passed, 0 failed, 2 skippedThe if entry["status"] == "completed" line in Step::new matters more than
it looks. My first version put every entry from steps into the memo. The
suite caught it:
FAIL L2 steps.failed-memo-reruns delta step IDs want: run_conf_42hfgbz76rbn6wrf7cohyuwptt:plain:0 got: (none) contract: https://docs.ironflow.run/reference/api/push-protocol/#completed-step-entries
18 passed, 1 failed, 2 skippedError: push conformance: one or more checks failedThe suite makes a new run ID for each check, so your IDs differ from these.
Today the engine puts only completed steps in steps. The protocol still says
that only "completed" means memoized, and the suite holds you to that rule. A
handler that skips the check works now, and it breaks on an engine that sends a
failed entry: it never runs that step again, and the run continues with an
output the step never produced.
Level 3: sleep
Section titled “Level 3: sleep”Level 3 adds conformance-sleep. It runs a step, sleeps for an hour, and runs
a second step. A push handler does not wait for an hour. It returns
"status": "yielded" with the wake time, and the engine sends a new request
when the time comes. That request marks the sleep step as completed in steps,
so the memo check from level 2 is enough to resume.
Add Duration to the std::time import, then add the fixture, a Yield
type and a sleep method on Step. Rust accepts a second impl Step block, so
you can paste this as it is, or move sleep into the first block:
fn conformance_sleep(step: &mut Step) -> Result<Value, Yield> { let before = step.run("before", || json!({ "s": "before" })); step.sleep("nap", Duration::from_secs(3600))?; let after = step.run("after", || json!({ "s": "after" })); Ok(json!([before, after]))}
struct Yield(Value);
impl Step { fn sleep(&mut self, name: &str, duration: Duration) -> Result<(), Yield> { let id = self.next_id(name); if self.memo.contains_key(&id) { return Ok(()); } let until = humantime::format_rfc3339_seconds(SystemTime::now() + duration); Err(Yield(json!({ "step_id": id, "type": "sleep", "until": until.to_string() }))) }}The ? operator stops the handler at the sleep. Now replace handle again,
from let mut step = Step::new(&req); to the closing brace of the function.
This version wraps each completed result in Ok, adds the new fixture, and
turns a Yield into a yielded response:
let mut step = Step::new(&req); let outcome = match req["function_id"].as_str().unwrap_or("") { "conformance-echo" => Ok(json!({ "run_id": req["run_id"], "function_id": req["function_id"], "attempt": req["attempt"], "data": req["event"]["data"], })), "conformance-fail" => { let error = json!({ "message": req["event"]["data"]["message"], "retryable": false }); return (StatusCode::OK, Json(json!({ "status": "failed", "error": error }))); } "conformance-steps" => Ok(conformance_steps(&mut step)), "conformance-sleep" => conformance_sleep(&mut step), _ => return (StatusCode::NOT_FOUND, Json(json!({ "error": "unknown function" }))), }; let response = match outcome { Ok(result) => json!({ "status": "completed", "steps": step.delta, "result": result }), Err(Yield(y)) => json!({ "status": "yielded", "steps": step.delta, "yield": y }), }; (StatusCode::OK, Json(response))}Restart the handler and run the whole suite. Level 3 is the default:
ironflow conformance push --url http://127.0.0.1:8099/ironflowPASS L1 transport.method-not-allowedPASS L1 transport.unknown-functionPASS L1 transport.echoPASS L1 transport.unknown-fieldsPASS L1 transport.fail-encodingPASS L1 signature.validPASS L1 signature.missingPASS L1 signature.tampered-bodyPASS L1 signature.wrong-keyPASS L1 signature.skew-pastPASS L1 signature.skew-futurePASS L1 signature.unknown-schemePASS L1 signature.malformedPASS L2 steps.first-runPASS L2 steps.empty-arrayPASS L2 steps.full-replayPASS L2 steps.partial-replayPASS L2 steps.failed-memo-rerunsPASS L2 steps.attempt-stablePASS L3 yield.sleepPASS L3 yield.resume
21 passed, 0 failed, 0 skippedThe push conformance page sets the bar: a language is supported for push mode
when its handler passes every check at level 3. This handler does. Here is the
complete file, with sleep moved into the first impl Step block. It is 169
lines.
The complete src/main.rs
use std::collections::HashMap;use std::sync::LazyLock;use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::{Json, Router, body::Bytes, http::HeaderMap, http::StatusCode, routing::post};use hmac::{Hmac, KeyInit, Mac};use serde_json::{Value, json};use sha2::Sha256;
static KEY: LazyLock<String> = LazyLock::new(|| std::env::var("IRONFLOW_SIGNING_KEY").expect("set IRONFLOW_SIGNING_KEY"));
#[tokio::main]async fn main() { LazyLock::force(&KEY); let app = Router::new().route("/ironflow", post(handle)); let listener = tokio::net::TcpListener::bind("127.0.0.1:8099").await.unwrap(); axum::serve(listener, app).await.unwrap();}
async fn handle(headers: HeaderMap, body: Bytes) -> (StatusCode, Json<Value>) { let signature = headers.get("x-ironflow-signature").and_then(|h| h.to_str().ok()); if !verify(signature.unwrap_or(""), &body) { return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "bad signature" }))); } let Ok(req) = serde_json::from_slice::<Value>(&body) else { return (StatusCode::BAD_REQUEST, Json(json!({ "error": "body is not JSON" }))); };
let mut step = Step::new(&req); let outcome = match req["function_id"].as_str().unwrap_or("") { "conformance-echo" => Ok(json!({ "run_id": req["run_id"], "function_id": req["function_id"], "attempt": req["attempt"], "data": req["event"]["data"], })), "conformance-fail" => { let error = json!({ "message": req["event"]["data"]["message"], "retryable": false }); return (StatusCode::OK, Json(json!({ "status": "failed", "error": error }))); } "conformance-steps" => Ok(conformance_steps(&mut step)), "conformance-sleep" => conformance_sleep(&mut step), _ => return (StatusCode::NOT_FOUND, Json(json!({ "error": "unknown function" }))), }; let response = match outcome { Ok(result) => json!({ "status": "completed", "steps": step.delta, "result": result }), Err(Yield(y)) => json!({ "status": "yielded", "steps": step.delta, "yield": y }), }; (StatusCode::OK, Json(response))}
fn conformance_steps(step: &mut Step) -> Value { let o1 = step.run("plain", || json!({ "s": "plain#1" })); let o2 = step.run("plain", || json!({ "s": "plain#2" })); let o3 = step.run("a:b\\c", || json!({ "s": "escaped" })); let branches = step.parallel("fan:out", 2, |step, branch| { let mut last = step.run("leaf", || json!({ "s": format!("leaf-{branch}-0") })); if branch == 0 { last = step.run("leaf", || json!({ "s": "leaf-0-1" })); } last }); json!([o1, o2, o3, branches[0], branches[1]])}
fn conformance_sleep(step: &mut Step) -> Result<Value, Yield> { let before = step.run("before", || json!({ "s": "before" })); step.sleep("nap", Duration::from_secs(3600))?; let after = step.run("after", || json!({ "s": "after" })); Ok(json!([before, after]))}
struct Yield(Value);
struct Step { memo: HashMap<String, Value>, scope: String, counters: HashMap<String, usize>, delta: Vec<Value>,}
impl Step { fn new(req: &Value) -> Self { let mut memo = HashMap::new(); for entry in req["steps"].as_array().into_iter().flatten() { if entry["status"] == "completed" { let id = entry["id"].as_str().unwrap_or("").to_string(); memo.insert(id, entry["output"].clone()); } } let scope = req["run_id"].as_str().unwrap_or("").to_string(); Step { memo, scope, counters: HashMap::new(), delta: Vec::new() } }
fn next_id(&mut self, name: &str) -> String { let index = self.counters.entry(name.to_string()).or_insert(0); let id = format!("{}:{}:{}", self.scope, escape(name), index); *index += 1; id }
fn run(&mut self, name: &str, body: impl FnOnce() -> Value) -> Value { let id = self.next_id(name); if let Some(output) = self.memo.get(&id) { return output.clone(); } let started_at = now_rfc3339(); let output = body(); self.delta.push(json!({ "id": id, "name": name, "type": "invoke", "status": "completed", "started_at": started_at, "ended_at": now_rfc3339(), "output": output, })); output }
fn sleep(&mut self, name: &str, duration: Duration) -> Result<(), Yield> { let id = self.next_id(name); if self.memo.contains_key(&id) { return Ok(()); } let until = humantime::format_rfc3339_seconds(SystemTime::now() + duration); Err(Yield(json!({ "step_id": id, "type": "sleep", "until": until.to_string() }))) }
fn parallel<T>(&mut self, name: &str, branches: usize, mut body: impl FnMut(&mut Step, usize) -> T) -> Vec<T> { let parent = format!("{}:{}", self.scope, escape(name)); let mut results = Vec::new(); for branch in 0..branches { let scope = std::mem::replace(&mut self.scope, format!("{parent}:{branch}")); let counters = std::mem::take(&mut self.counters); results.push(body(self, branch)); self.scope = scope; self.counters = counters; } results }}
fn escape(part: &str) -> String { part.replace('\\', "\\\\").replace(':', "\\:")}
fn now_rfc3339() -> String { humantime::format_rfc3339_seconds(SystemTime::now()).to_string()}
fn verify(header: &str, body: &[u8]) -> bool { let (mut t, mut v1) = (None, None); for pair in header.split(',') { match pair.split_once('=') { Some(("t", value)) => t = Some(value), Some(("v1", value)) => v1 = hex::decode(value).ok(), _ => {} } } let (Some(t), Some(v1)) = (t, v1) else { return false }; let Ok(sent) = t.parse::<i64>() else { return false }; let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64; if (now - sent).abs() > 300 { return false; }
let mut mac = Hmac::<Sha256>::new_from_slice(KEY.as_bytes()).unwrap(); mac.update(t.as_bytes()); mac.update(b"."); mac.update(body); mac.verify_slice(&v1).is_ok()}Run it in CI
Section titled “Run it in CI”The suite is a CLI with exit codes, so a CI job can run it against a test build
of your service. Exit code 0 means no check failed, 1 means a check failed,
and 3 means the endpoint did not answer. --json prints one result per check:
ironflow conformance push --url http://127.0.0.1:8099/ironflow --json{ "results": [ { "id": "transport.method-not-allowed", "level": 1, "outcome": "PASS", "contract": "https://docs.ironflow.run/reference/api/push-protocol/#transport" }, ... ]}Run it on every change to the handler code. The step ID rules are easy to break in a refactor, and the engine does not reject a wrong ID. It runs the step again.
What 21 passes do not prove
Section titled “What 21 passes do not prove”The suite checks the parts of the protocol that its fixtures use. The push conformance page lists what it does not check:
wait_for_event,invoke_functionandinvoke_function_asyncyields. Await_for_eventresume carries the matched event inresume.data, and this handler does not readresume.- The
compensate:andpublish:step-name prefixes.escapehere escapes the whole name, so it would change the ID of a compensation step. - The capacity fencing headers,
secrets, and trace headers. started_atandended_aton step results.- Constant-time comparison. The suite cannot see timing from outside the
handler.
verify_slicedoes it here.
This handler also has no step that can fail. A real step returns an error, and the handler must report a failed step result and decide if the error is retryable. The errors section of the protocol covers that encoding.
Two more limits come from push mode itself. The handler runs inside one HTTP request, so it suits short tasks. Pull mode, where a worker polls for jobs with no request timeout, ships only in the Go, Node and Python SDKs. And the suite checks only the engine-to-you direction. To emit events or start runs from Rust, generate a client from the OpenAPI spec. Use Ironflow from any language covers the generated client and the function registration call that points the engine at your endpoint.