# Retry HTTP with an offline cassette

URL: /docs/cookbook/http_retry
Markdown: /docs/cookbook/http_retry.md

> Retry a safe GET after a rate limit, with a finite attempt count and virtual time in tests.

Generated by `cargo run -p agent_docs` from `compiler/examples/`. Edit the example files, then regenerate.

Retry a safe GET after a rate limit, with a finite attempt count and virtual time in tests.

`fetch` declares HTTP, clock and random effects. Its policy retries only
`RateLimited`, at most three attempts, with exponential backoff and a one-second
retry deadline. Jitter is disabled for this example. It uses the policy's delays;
it does not interpret `retry_after`. The deadline bounds retry scheduling, not the
duration of an individual request. `HttpError` propagates immediately. Other HTTP
statuses remain responses for the caller to inspect.

The cassette returns 429 then 200 for the identical request. `Http.recording` is
wrapped by `Http.scripted([])`, so an exchange cannot fall through to the network,
even in recording mode. `Clock.virtual` advances without sleeping. Tests check
success after 100 ms, exhaustion after 300 ms, and immediate transport failure.
The harness uses test mode; the manifest grants no network or environment access.

[Source project](https://github.com/icantcodefyi/sakuralang/tree/master/compiler/examples/http_retry) · harness mode: `test`.

From the repository root, with this checkout's `sakura` on `PATH`:

```sh
cd compiler/examples/http_retry
sakura check --json
sakura test --json
```

### Manifest and permissions [#manifest-and-permissions]

```toml
[package]
name = "http_retry"
version = "0.1.0"
edition = "2026"

[permissions]
```

### Source and tests [#source-and-tests]

`src/main.sakura` includes all required imports and tests.

```sakura test id=cookbook-http_retry project=http_retry
// milestone: M3
// mode: test
// spec: design §17.145

import std/duration.{Duration}
import std/fs.{Fs}
import std/http.{Any, Http, RateLimited}
import std/path.{Path}
import std/response.{HttpError, Response, Status}
import std/retry
import std/retry.{Policy}

fn fetch() -> Response ! {Clock, Http, Random, Fail<HttpError | RateLimited>} {
  let policy: Policy<HttpError | RateLimited> = Policy {
    ..retry.policy(
      fn(err: HttpError | RateLimited) {
        match err {
          RateLimited { .. } => true,
          HttpError { .. } => false,
        }
      },
      3,
    ),
    jitter: false,
    deadline: Some(Duration.seconds(1)),
  }
  retry.retry(policy, fn() { Http.get(url"https://recipes.invalid/status") })
}

test "429 then success replays with a virtual backoff" {
  with Fs.rooted(Test.fixtures()),
      Http.scripted([]),
      Http.recording(Path.of("status.jsonl")),
      Clock.virtual() {
    let response = fetch()
    expect_eq(response.status, Status.from(200))
    expect_eq(response.body, "ready")
    expect_eq(Clock.monotonic(), Duration.millis(100))
  }
}

test "three rejected attempts preserve RateLimited" {
  with Http.scripted([(Any, Response.status(429, "busy"))]), Clock.virtual() {
    expect_fails_with(fn() { fetch() }, fn(e) {
      match e {
        RateLimited { .. } => true,
        _ => false,
      }
    })
    expect_eq(Clock.monotonic(), Duration.millis(300))
  }
}

test "a missing exchange does not reach a host or retry" {
  with Http.scripted([]), Clock.virtual() {
    expect_fails_with(fn() { fetch() }, fn(e) {
      match e {
        HttpError { .. } => true,
        _ => false,
      }
    })
    expect_eq(Clock.monotonic(), Duration.millis(0))
  }
}
```

### `tests/fixtures/README.md` [#testsfixturesreadmemd]

```text
# Synthetic cassette

Hand-authored request/response exchanges in the version 1 cassette format.
No live request, provider account, API key or recorded personal data was used.
Requests match method, URL, headers and body; secret headers are absent.
Tests install an empty scripted HTTP fallback, so a missing exchange fails offline.
```

### `tests/fixtures/status.jsonl` [#testsfixturesstatusjsonl]

```jsonl
{"schema_version":"1.0","kind":"cassette"}
{"method":"GET","url":"https://recipes.invalid/status","headers":[],"body":"","status":429,"response_headers":[],"response_body":"busy"}
{"method":"GET","url":"https://recipes.invalid/status","headers":[],"body":"","status":200,"response_headers":[],"response_body":"ready"}
```

### Compiler provenance and regeneration [#compiler-provenance-and-regeneration]

These eight projects teach supported Sakura APIs on the JavaScript backend. Each
project is exercised offline by `cargo test -p sakura_cli --test examples`;
HTTP and LLM exchanges are synthetic committed cassettes, with no network or API keys.
Use Node 26 for execution, including its built-in SQLite implementation.

The initial API baseline is compiler revision
[5cae349516c21714d90d36e87f1cfd6debe980c6](https://github.com/icantcodefyi/sakuralang/commit/5cae349516c21714d90d36e87f1cfd6debe980c6).
CI reruns these projects against the compiler in the same checkout. Build that
checkout's CLI and run `sakura --version` and `sakura prompt` before adapting a recipe;
pre-alpha APIs can change. An installed skill's frontmatter records its compiler
version and build revision. These teaching examples are separate from the agent
evaluation tasks.

The source of each page is its example's `recipe.md`, `sakura.toml`,
`src/main.sakura` and fixture files. Run `cargo run -p agent_docs` from `compiler/`
to regenerate the cookbook and skill reference. Edit those sources, not the rendered pages.
