Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ both packages adhere to [Semantic Versioning](https://semver.org/).

## Unreleased

## py 0.9.1 — 2026-07-23

### Fixed (py)

- **Budget-aware retry**: reject non-integer `max_attempts`, and catch
`Exception` (not `BaseException`) so `KeyboardInterrupt` /
`SystemExit` / `CancelledError` propagate instead of being retried.

## py 0.9.0 / js 0.6.0 — 2026-07-23

### Added (py + js)

- **Budget-aware retry helper** (`with_budget_retry` / `withBudgetRetry`,
issue #45): retry normally when budget is healthy, ask a caller-supplied
degrade callback for a cheaper retry plan when `advisory().near_limit` /
`nearLimit` is set, and hard-block an over-budget retry with `check()` before
it runs. Ships with a no-network Python example.

## py 0.8.0 — 2026-07-23

### Added (py)
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,39 @@ ignore it; `check()` is what enforces the ceiling. See
[`examples/budget_aware.py`](examples/budget_aware.py) for a runnable taper demo
(no API key).

### Budget-aware retry

Blind retries can spend the same expensive path again right when the agent is
running out of headroom. `with_budget_retry()` composes over the existing guard:
retry normally while budget is healthy, ask your code for a cheaper retry plan
when `advisory().near_limit` is true, and call `check(estimated_cost)` before
each retry so an over-budget retry never runs.

```python
from floe_guard import BudgetGuard, RetryPlan, with_budget_retry

guard = BudgetGuard(limit_usd=1.00)

def premium_model():
return call_model("gpt-4o")

def mini_model():
return call_model("gpt-4o-mini")

result = with_budget_retry(
guard,
premium_model,
estimated_cost=0.20,
max_attempts=2,
on_degrade=lambda exc, adv: RetryPlan(call=mini_model, estimated_cost=0.01),
)
```

The helper does not rank models or know provider pricing; the caller defines
what "cheaper" means in `on_degrade`. TypeScript exposes the same pattern as
`withBudgetRetry()`. See [`examples/budget_retry.py`](examples/budget_retry.py)
for a no-network demo.

This is the **same advisory shape** hosted Floe returns on every proxied call
(the `X-Floe-Budget-Advisory` header), so the logic you write here ports
unchanged — hosted just answers across *every* vendor and cap with server-truth
Expand Down
50 changes: 50 additions & 0 deletions examples/budget_retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Budget-aware retry / graceful degradation demo.

Run with:

python examples/budget_retry.py

No API key, no network. The first call fails near the cap, so the retry helper
asks the caller for a cheaper path and checks that the cheaper retry fits before
running it.
"""

from __future__ import annotations

from floe_guard import BudgetGuard, RetryPlan, with_budget_retry


class TransientProviderError(RuntimeError):
pass


def main() -> None:
guard = BudgetGuard(limit_usd=1.00, near_limit_bps=8000)
guard.record_tool("previous-work", 0.85)
calls = {"premium": 0, "mini": 0}

def premium_model() -> str:
calls["premium"] += 1
raise TransientProviderError("temporary upstream timeout")

def mini_model() -> str:
calls["mini"] += 1
return "retried on cheaper model"

def degrade(_exc: BaseException, _advisory) -> RetryPlan[str]:
return RetryPlan(call=mini_model, estimated_cost=0.01)

result = with_budget_retry(
guard,
premium_model,
estimated_cost=0.20,
max_attempts=2,
on_degrade=degrade,
)

print(result)
print(calls)


if __name__ == "__main__":
main()
4 changes: 2 additions & 2 deletions js/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "floe-guard",
"version": "0.5.0",
"version": "0.6.0",
"description": "A local budget guardrail for AI agents — hard-stops your agent before its next LLM call crosses a USD ceiling. Vercel AI SDK middleware.",
"type": "module",
"license": "MIT",
Expand Down
5 changes: 5 additions & 0 deletions js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,10 @@ export {
resolvePrice,
priceTokens,
} from "./pricing.js";
export {
withBudgetRetry,
type BudgetRetryOptions,
type RetryPlan,
} from "./retry.js";

export * as pricing from "./pricing.js";
82 changes: 82 additions & 0 deletions js/src/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { BudgetExceeded } from "./errors.js";
import { type BudgetAdvisory, BudgetGuard } from "./guard.js";

export interface RetryPlan<T> {
/** Operation to run for this retry attempt. */
call: () => T | Promise<T>;
/**
* Estimated retry cost passed to `guard.check()` before the retry runs.
* Leave undefined to use the guard's default last-call estimate.
*/
estimatedCost?: number;
}

export interface BudgetRetryOptions<T> {
/** Estimated cost for retrying the original call. */
estimatedCost?: number;
/** Total attempts, including the first call. Default: 2. */
maxAttempts?: number;
/** Choose a cheaper retry plan when `guard.advisory().nearLimit` is true. */
onDegrade?: (
error: unknown,
advisory: BudgetAdvisory,
) => RetryPlan<T> | Promise<RetryPlan<T> | undefined> | undefined;
/** Decide whether an error is retryable. Defaults to all non-budget errors. */
retryIf?: (error: unknown) => boolean;
}

function defaultRetryIf(error: unknown): boolean {
return !(error instanceof BudgetExceeded);
}

/**
* Run `call` with budget-aware retries.
*
* The first attempt runs unchanged. If it fails with a retryable error, the
* helper retries as-is with ample budget, asks `onDegrade` for a cheaper plan
* when near the limit, and always calls `guard.check(estimatedCost)` before a
* retry so an over-budget retry is blocked before it runs.
*/
export async function withBudgetRetry<T>(
guard: BudgetGuard,
call: () => T | Promise<T>,
options: BudgetRetryOptions<T> = {},
): Promise<T> {
const maxAttempts = options.maxAttempts ?? 2;
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
throw new RangeError(`maxAttempts must be an integer >= 1, got ${maxAttempts}`);
}
const retryIf = options.retryIf ?? defaultRetryIf;
let plan: RetryPlan<T> = { call, estimatedCost: options.estimatedCost };

for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await plan.call();
} catch (error) {
if (attempt >= maxAttempts || !retryIf(error)) {
throw error;
}
plan = await nextPlan(guard, error, plan, options.onDegrade);
}
}

throw new Error("unreachable");
}

async function nextPlan<T>(
guard: BudgetGuard,
error: unknown,
current: RetryPlan<T>,
onDegrade: BudgetRetryOptions<T>["onDegrade"],
): Promise<RetryPlan<T>> {
const advisory = guard.advisory();
if (advisory.nearLimit && onDegrade !== undefined) {
const degraded = await onDegrade(error, advisory);
if (degraded !== undefined) {
guard.check(degraded.estimatedCost);
return degraded;
}
}
guard.check(current.estimatedCost);
return current;
}
102 changes: 102 additions & 0 deletions js/test/retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";

import { BudgetExceeded, BudgetGuard, type RetryPlan, withBudgetRetry } from "../src/index.js";

class RetryableError extends Error {}

describe("withBudgetRetry", () => {
it("retries the same call when budget is ample", async () => {
const guard = new BudgetGuard(1.0);
let primaryCalls = 0;

const result = await withBudgetRetry(
guard,
() => {
primaryCalls += 1;
if (primaryCalls === 1) throw new RetryableError("temporary failure");
return "primary-ok";
},
{ estimatedCost: 0.05, maxAttempts: 2 },
);

expect(result).toBe("primary-ok");
expect(primaryCalls).toBe(2);
});

it("uses a degraded retry plan when near the limit", async () => {
const guard = new BudgetGuard(1.0, { nearLimitBps: 8000 });
guard.recordTool("seed", 0.85);
let primaryCalls = 0;
let cheapCalls = 0;

const result = await withBudgetRetry(
guard,
() => {
primaryCalls += 1;
throw new RetryableError("temporary failure");
},
{
estimatedCost: 0.2,
maxAttempts: 2,
onDegrade: (error): RetryPlan<string> => {
expect(error).toBeInstanceOf(RetryableError);
return {
estimatedCost: 0.01,
call: () => {
cheapCalls += 1;
return "cheap-ok";
},
};
},
},
);

expect(result).toBe("cheap-ok");
expect(primaryCalls).toBe(1);
expect(cheapCalls).toBe(1);
});

it("aborts before a retry whose estimate would cross the budget", async () => {
const guard = new BudgetGuard(1.0, { onBlock: () => undefined });
guard.recordTool("seed", 0.95);
let primaryCalls = 0;

await expect(
withBudgetRetry(
guard,
() => {
primaryCalls += 1;
throw new RetryableError("temporary failure");
},
{ estimatedCost: 0.1, maxAttempts: 2 },
),
).rejects.toBeInstanceOf(BudgetExceeded);
expect(primaryCalls).toBe(1);
});

it("does not retry non-retryable failures", async () => {
const guard = new BudgetGuard(1.0);
let primaryCalls = 0;

await expect(
withBudgetRetry(
guard,
() => {
primaryCalls += 1;
throw new TypeError("bad request");
},
{
estimatedCost: 0.01,
retryIf: (error) => !(error instanceof TypeError),
},
),
).rejects.toThrow("bad request");
expect(primaryCalls).toBe(1);
});

it("rejects invalid maxAttempts", async () => {
await expect(
withBudgetRetry(new BudgetGuard(1.0), () => "ok", { maxAttempts: 0 }),
).rejects.toBeInstanceOf(RangeError);
});
});
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "floe-guard"
version = "0.8.0"
version = "0.9.1"
description = "Local budget guardrail for AI agents — hard-stops a runaway loop before its next LLM call crosses a spend ceiling. No account, no network."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
6 changes: 5 additions & 1 deletion src/floe_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
from .hosted import hosted_enforcement_available, hosted_remaining_usd
from .latency import LatencyAdvisory, LatencyBudget
from .pricing import ManualPrice, PricedModel, price_tokens, resolve_price
from .retry import RetryPlan, async_with_budget_retry, with_budget_retry
from .stream import StreamGuard, guard_stream

__version__ = "0.8.0" # keep in lockstep with pyproject.toml
__version__ = "0.9.1" # keep in lockstep with pyproject.toml

__all__ = [
"BudgetGuard",
Expand All @@ -38,6 +39,9 @@
"LatencyAdvisory",
"StreamGuard",
"guard_stream",
"RetryPlan",
"with_budget_retry",
"async_with_budget_retry",
"BudgetExceeded",
"DeadlineExceeded",
"FloeGuardError",
Expand Down
Loading
Loading