floopfloop 0.1.0-alpha.2

Official Rust SDK for the FloopFloop API (https://www.floopfloop.com)
Documentation
# Cookbook

Concrete `floopfloop` (crates.io) patterns you can copy-paste. Every snippet uses only the SDK's public surface — no undocumented endpoints, no private helpers.

For the basics (install, client setup, resource tour) see the [README](../README.md). This file is the **"I know the basics, now how do I actually build X"** layer.

These recipes mirror the [Node](https://github.com/FloopFloopAI/floop-node-sdk/blob/main/docs/recipes.md), [Python](https://github.com/FloopFloopAI/floop-python-sdk/blob/main/docs/recipes.md), and [Go](https://github.com/FloopFloopAI/floop-go-sdk/blob/main/docs/recipes.md) cookbooks, translated to async-Rust idioms (tokio runtime, `match` on `FloopErrorCode`, callback-based `stream`, generic backoff helper).

All snippets assume:

```toml
# Cargo.toml
[dependencies]
floopfloop = "0.1"
tokio = { version = "1", features = ["full"] }
```

---

## 1. Ship a project from prompt to live URL

The canonical one-call flow: create, wait, done. `wait_for_live` returns `FloopError { code: FloopErrorCode::BuildFailed | BuildCancelled | Timeout, .. }` on non-success terminals, so plain `match` is enough.

```rust
use floopfloop::{Client, CreateProjectInput, FloopErrorCode, StreamOptions};
use std::error::Error;
use std::time::Duration;

async fn ship(client: &Client, prompt: &str, subdomain: &str) -> Result<String, Box<dyn Error>> {
    let created = client.projects().create(CreateProjectInput {
        prompt: prompt.into(),
        subdomain: Some(subdomain.into()),
        bot_type: Some("site".into()),
        ..Default::default()
    }).await?;

    // Polls status every 2s; bounds the total wait to 10 minutes so a
    // stuck build doesn't hang forever.
    let live = client.projects().wait_for_live(
        &created.project.id,
        Some(StreamOptions {
            interval: Duration::from_secs(2),
            max_wait: Duration::from_secs(10 * 60),
        }),
    ).await?;

    // FloopError's constructor is pub(crate), so application code uses
    // its own error type at the boundary. Box<dyn Error> + a string
    // literal is the lowest-overhead choice.
    live.url.ok_or_else(|| "project is live but has no URL yet".into())
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new(std::env::var("FLOOP_API_KEY")?)?;
    match ship(&client, "A single-page portfolio for a landscape photographer", "landscape-portfolio").await {
        Ok(url) => println!("Live at {url}"),
        Err(e) if matches!(e.code, FloopErrorCode::BuildFailed) => {
            eprintln!("build failed: {}", e.message);
        }
        Err(e) => return Err(e.into()),
    }
    Ok(())
}
```

**`#[non_exhaustive]` on `FloopErrorCode`** means you must include a `_` wildcard arm in any `match` against it — new variants can ship in a non-breaking minor release. Use `matches!` (or `if let`) when you only care about a specific variant.

**When to prefer `stream` over `wait_for_live`:** when you want to show progress to a user. `wait_for_live` only returns at the end — no visibility into what the build is doing.

---

## 2. Watch a build progress in real time

`projects().stream(reference, opts, handler)` calls `handler` for every unique status transition and returns when the project reaches a terminal state (live / failed / cancelled), `max_wait` elapses, or the handler returns `Err`. Events are de-duplicated on `(status, step, progress, queue_position)` so the handler doesn't fire on every poll.

```rust
use floopfloop::{Client, FloopError, FloopErrorCode};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new(std::env::var("FLOOP_API_KEY")?)?;

    let result = client.projects().stream("recipe-blog", None, |ev| {
        let progress = ev.progress
            .map(|p| format!(" {p:.0}%"))
            .unwrap_or_default();
        println!("[{}]{} step={}/{} — {}",
            ev.status, progress, ev.step, ev.total_steps, ev.message);
        Ok(())  // return Err to stop polling early
    }).await;

    match result {
        Ok(()) => {
            // Reached "live" cleanly — fetch the hydrated project.
            let done = client.projects().get("recipe-blog", Default::default()).await?;
            if let Some(url) = done.url {
                println!("Live at {url}");
            }
        }
        Err(e) if matches!(e.code, FloopErrorCode::BuildFailed) => {
            eprintln!("build failed: {}", e.message);
        }
        Err(e) if matches!(e.code, FloopErrorCode::Timeout) => {
            eprintln!("build stalled past max_wait");
        }
        Err(e) => return Err(e.into()),
    }
    Ok(())
}
```

**Early abort.** Return a sentinel `FloopError` from the handler. The SDK propagates it verbatim, so callers can tell their own sentinel apart from the SDK's terminal errors. Use `FloopErrorCode::Other(...)` for caller-defined codes the SDK doesn't have a dedicated variant for:

```rust
use floopfloop::{FloopError, FloopErrorCode};

let result = client.projects().stream("recipe-blog", None, |ev| {
    if let Some(p) = ev.progress {
        if p >= 50.0 {
            return Err(FloopError::new(
                FloopErrorCode::Other("ENOUGH_PROGRESS".into()),
                0,
                "saw enough progress, stopping early",
            ));
        }
    }
    Ok(())
}).await;

// Caller distinguishes its own sentinel from the SDK's terminal errors
// by matching on the Other("...") string with the same payload.
match result {
    Err(e) if e.code.as_str() == "ENOUGH_PROGRESS" => println!("early exit"),
    Err(e) if matches!(e.code, FloopErrorCode::BuildFailed) => eprintln!("build failed"),
    Err(e) => return Err(e.into()),
    Ok(()) => println!("reached live"),
}
```

`FloopError::new` was `pub(crate)` in 0.1.0-alpha.1 and the cookbook recommended a `Cell<T>` shared-state workaround. As of 0.1.0-alpha.2 the constructor is public, plus there are `with_request_id` and `with_retry_after` builders if you need to fill in those fields too.

---

## 3. Refine a project, even when it's mid-build

`projects().refine` returns a `RefineResult` with three `Option` fields — exactly one is `Some`:

- `queued: Some(RefineQueued { message_id })` — project is currently deploying; your message is queued.
- `processing: Some(RefineProcessing { deployment_id, queue_priority })` — your message triggered a new build immediately.
- `saved_only: Some(RefineSavedOnly)` — saved as a conversation entry without triggering a build.

```rust
use floopfloop::{Client, RefineInput};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new(std::env::var("FLOOP_API_KEY")?)?;

    let res = client.projects().refine("recipe-blog", RefineInput {
        message: "Add a search bar to the header".into(),
        ..Default::default()
    }).await?;

    if let Some(p) = res.processing {
        println!("Build started (deployment {})", p.deployment_id);
        client.projects().wait_for_live("recipe-blog", None).await?;
    } else if let Some(q) = res.queued {
        println!("Queued behind current build (message {})", q.message_id);
        // Poll once — when "live", your queued message has been processed.
        client.projects().wait_for_live("recipe-blog", None).await?;
    } else if res.saved_only.is_some() {
        println!("Saved as a chat message, no build triggered");
    }
    Ok(())
}
```

**Why three `Option` fields instead of an enum?** Rust's enum would be the natural fit, but `serde` deserialisation against the JSON shape is cleaner with the three-`Option` layout — the fields exactly mirror what's on the wire. The SDK guarantees exactly one is `Some` on success.

---

## 4. Upload an image and refine with it as context

Uploads are two-step: `uploads().create` presigns an S3 URL and does the direct PUT, returning an `UploadedAttachment`. **There's a type-shape gotcha:** `RefineInput.attachments` is `Vec<RefineAttachment>`, not `Vec<UploadedAttachment>`. The fields are the same but `file_size` is `u64` on one and `i64` on the other — needs a cast.

```rust
use bytes::Bytes;
use floopfloop::{Client, CreateUploadInput, RefineAttachment, RefineInput, UploadedAttachment};
use tokio::fs;

fn into_refine_attachment(up: UploadedAttachment) -> RefineAttachment {
    RefineAttachment {
        key: up.key,
        file_name: up.file_name,
        file_type: up.file_type,
        file_size: up.file_size as i64,  // u64 -> i64
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::new(std::env::var("FLOOP_API_KEY")?)?;

    let bytes = fs::read("./mockup.png").await?;
    let up = client.uploads().create(CreateUploadInput {
        file_name: "mockup.png".into(),
        bytes: Bytes::from(bytes),
        file_type: None,  // None = guess from extension
    }).await?;

    client.projects().refine("recipe-blog", RefineInput {
        message: "Make the homepage look like this mockup.".into(),
        attachments: Some(vec![into_refine_attachment(up)]),
        ..Default::default()
    }).await?;
    Ok(())
}
```

**Supported types:** `png`, `jpg/jpeg`, `gif`, `svg`, `webp`, `ico`, `pdf`, `txt`, `csv`, `doc`, `docx`. Max 5 MB per upload. The SDK validates client-side before hitting the network, so bad inputs return `FloopError { code: ValidationError, .. }` with no round-trip.

Attachments only flow through `refine` today — `create` doesn't accept them via the SDK. If you need to anchor a brand-new project against images, create with a prompt first, then refine with the attachments as a follow-up.

---

## 5. Rotate an API key from a CI job

Three-step rotation: create the new key, write it to your secret store, then revoke the old one. The order matters — you must revoke with a **different** key than the one making the call (the backend returns `400 ValidationError` if you try to revoke the key you're authenticated with).

```rust
use floopfloop::{Client, CreateApiKeyInput};

async fn rotate(victim_name: &str) -> Result<(), Box<dyn std::error::Error>> {
    // Use a long-lived bootstrap key (stored as a CI secret) to do the
    // rotation. Don't use the key we're about to revoke — that hits the
    // self-revoke guard.
    let bootstrap = Client::new(std::env::var("FLOOP_BOOTSTRAP_KEY")?)?;

    // 1. Find the key we want to rotate by its name. (Each name is
    //    unique per account because the dashboard enforces it; matching
    //    by name is more reliable than matching the prefix substring.)
    let keys = bootstrap.api_keys().list().await?;
    let victim = keys.iter()
        .find(|k| k.name == victim_name)
        .ok_or_else(|| format!("key not found: {victim_name}"))?;

    // 2. Mint the replacement.
    let fresh = bootstrap.api_keys().create(CreateApiKeyInput {
        name: format!("{victim_name}-new"),
    }).await?;
    write_secret("FLOOP_API_KEY", &fresh.raw_key).await?;

    // 3. Revoke the old one. remove() accepts an id OR a name.
    bootstrap.api_keys().remove(&victim.id).await?;
    Ok(())
}

async fn write_secret(_name: &str, _value: &str) -> Result<(), Box<dyn std::error::Error>> {
    // wire into your CI secret store — AWS Secrets Manager, Vault,
    // GitHub Actions `gh secret set`, etc.
    Ok(())
}
```

**Can't I just reuse the bootstrap key forever?** Technically yes — if it's tightly scoped and audited. In practice, a single long-lived "rotator key" is a common compromise: it only has permission to mint/list/revoke keys, never appears in application traffic, and itself gets rotated manually on a rare cadence (annually, or on compromise).

The 5-keys-per-account cap applies to active keys, so make sure to revoke old rotations rather than accumulating them.

---

## 6. Retry with backoff on `RateLimited` and `NetworkError`

`FloopError` carries everything you need to implement backoff correctly:

- `retry_after: Option<Duration>` — populated from the `Retry-After` header on 429s (parsed from delta-seconds OR HTTP-date).
- `code: FloopErrorCode` — distinguishes retryable (`RateLimited`, `NetworkError`, `Timeout`, `ServiceUnavailable`, `ServerError`) from permanent (`Unauthorized`, `Forbidden`, `ValidationError`, `NotFound`, `Conflict`, `BuildFailed`, `BuildCancelled`).

```rust
use floopfloop::{FloopError, FloopErrorCode};
use std::future::Future;
use std::time::Duration;

fn is_retryable(code: &FloopErrorCode) -> bool {
    matches!(
        code,
        FloopErrorCode::RateLimited
            | FloopErrorCode::NetworkError
            | FloopErrorCode::Timeout
            | FloopErrorCode::ServiceUnavailable
            | FloopErrorCode::ServerError
    )
}

pub async fn with_retry<F, Fut, T>(max_attempts: u32, mut fn_: F) -> Result<T, FloopError>
where
    F: FnMut() -> Fut,
    Fut: Future<Output = Result<T, FloopError>>,
{
    let mut attempt: u32 = 0;
    loop {
        attempt += 1;
        match fn_().await {
            Ok(v) => return Ok(v),
            Err(e) if !is_retryable(&e.code) || attempt >= max_attempts => return Err(e),
            Err(e) => {
                // Prefer the server's hint; fall back to exponential backoff
                // with jitter capped at 30 s.
                let server_hint = e.retry_after;
                let expo = Duration::from_millis(
                    (250u64 << attempt.min(7)).min(30_000),
                );
                let jitter = Duration::from_millis(rand_jitter_ms());
                let wait = server_hint.unwrap_or(expo) + jitter;

                eprintln!(
                    "floop: {} (attempt {}/{}), retrying in {:?}{}",
                    e.code.as_str(),
                    attempt,
                    max_attempts,
                    wait,
                    e.request_id.as_deref().map(|r| format!(" — request {r}")).unwrap_or_default(),
                );
                tokio::time::sleep(wait).await;
            }
        }
    }
}

// Avoid a `rand` dep just for this — std-only nanos jitter is fine.
fn rand_jitter_ms() -> u64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.subsec_nanos() as u64 % 250)
        .unwrap_or(50)
}

// Usage:
//   let projects = with_retry(5, || async {
//       client.projects().list(Default::default()).await
//   }).await?;
```

**Don't retry everything.** `ValidationError`, `Unauthorized`, and `Forbidden` are not going to fix themselves between attempts — retrying them just burns rate-limit budget and delays the real error reaching your logs.

**Cancellation.** If you need a hard ceiling on the whole retry loop, wrap the call in `tokio::time::timeout`:

```rust
let result = tokio::time::timeout(
    Duration::from_secs(60),
    with_retry(5, || async { client.projects().list(Default::default()).await }),
).await;
```

The outer `timeout` aborts the in-flight retry sleep cleanly because `tokio::time::sleep` is cancellation-safe.

---

## Got a pattern worth adding?

Open an issue at [FloopFloopAI/floop-rust-sdk/issues](https://github.com/FloopFloopAI/floop-rust-sdk/issues) describing the use case. Recipes live in this file, not in `src/`, so they're easy to update without an SDK release.