Skip to content

Commit 9e959d9

Browse files
feat: add budget-aware retry helper (closes #45)
Compose retries over remaining budget: ample headroom retries as-is, near-limit asks on_degrade for a cheaper path, and over-cap aborts via check() before spending. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a187fdb commit 9e959d9

9 files changed

Lines changed: 561 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ both packages adhere to [Semantic Versioning](https://semver.org/).
1010

1111
## Unreleased
1212

13+
### Added (py + js)
14+
15+
- **Budget-aware retry helper** (`with_budget_retry` / `withBudgetRetry`,
16+
issue #45): retry normally when budget is healthy, ask a caller-supplied
17+
degrade callback for a cheaper retry plan when `advisory().near_limit` /
18+
`nearLimit` is set, and hard-block an over-budget retry with `check()` before
19+
it runs. Ships with a no-network Python example.
20+
1321
## py 0.8.0 — 2026-07-23
1422

1523
### Added (py)

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,39 @@ ignore it; `check()` is what enforces the ceiling. See
149149
[`examples/budget_aware.py`](examples/budget_aware.py) for a runnable taper demo
150150
(no API key).
151151

152+
### Budget-aware retry
153+
154+
Blind retries can spend the same expensive path again right when the agent is
155+
running out of headroom. `with_budget_retry()` composes over the existing guard:
156+
retry normally while budget is healthy, ask your code for a cheaper retry plan
157+
when `advisory().near_limit` is true, and call `check(estimated_cost)` before
158+
each retry so an over-budget retry never runs.
159+
160+
```python
161+
from floe_guard import BudgetGuard, RetryPlan, with_budget_retry
162+
163+
guard = BudgetGuard(limit_usd=1.00)
164+
165+
def premium_model():
166+
return call_model("gpt-4o")
167+
168+
def mini_model():
169+
return call_model("gpt-4o-mini")
170+
171+
result = with_budget_retry(
172+
guard,
173+
premium_model,
174+
estimated_cost=0.20,
175+
max_attempts=2,
176+
on_degrade=lambda exc, adv: RetryPlan(call=mini_model, estimated_cost=0.01),
177+
)
178+
```
179+
180+
The helper does not rank models or know provider pricing; the caller defines
181+
what "cheaper" means in `on_degrade`. TypeScript exposes the same pattern as
182+
`withBudgetRetry()`. See [`examples/budget_retry.py`](examples/budget_retry.py)
183+
for a no-network demo.
184+
152185
This is the **same advisory shape** hosted Floe returns on every proxied call
153186
(the `X-Floe-Budget-Advisory` header), so the logic you write here ports
154187
unchanged — hosted just answers across *every* vendor and cap with server-truth

examples/budget_retry.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Budget-aware retry / graceful degradation demo.
2+
3+
Run with:
4+
5+
python examples/budget_retry.py
6+
7+
No API key, no network. The first call fails near the cap, so the retry helper
8+
asks the caller for a cheaper path and checks that the cheaper retry fits before
9+
running it.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from floe_guard import BudgetGuard, RetryPlan, with_budget_retry
15+
16+
17+
class TransientProviderError(RuntimeError):
18+
pass
19+
20+
21+
def main() -> None:
22+
guard = BudgetGuard(limit_usd=1.00, near_limit_bps=8000)
23+
guard.record_tool("previous-work", 0.85)
24+
calls = {"premium": 0, "mini": 0}
25+
26+
def premium_model() -> str:
27+
calls["premium"] += 1
28+
raise TransientProviderError("temporary upstream timeout")
29+
30+
def mini_model() -> str:
31+
calls["mini"] += 1
32+
return "retried on cheaper model"
33+
34+
def degrade(_exc: BaseException, _advisory) -> RetryPlan[str]:
35+
return RetryPlan(call=mini_model, estimated_cost=0.01)
36+
37+
result = with_budget_retry(
38+
guard,
39+
premium_model,
40+
estimated_cost=0.20,
41+
max_attempts=2,
42+
on_degrade=degrade,
43+
)
44+
45+
print(result)
46+
print(calls)
47+
48+
49+
if __name__ == "__main__":
50+
main()

js/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,10 @@ export {
3333
resolvePrice,
3434
priceTokens,
3535
} from "./pricing.js";
36+
export {
37+
withBudgetRetry,
38+
type BudgetRetryOptions,
39+
type RetryPlan,
40+
} from "./retry.js";
3641

3742
export * as pricing from "./pricing.js";

js/src/retry.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { BudgetExceeded } from "./errors.js";
2+
import { type BudgetAdvisory, BudgetGuard } from "./guard.js";
3+
4+
export interface RetryPlan<T> {
5+
/** Operation to run for this retry attempt. */
6+
call: () => T | Promise<T>;
7+
/**
8+
* Estimated retry cost passed to `guard.check()` before the retry runs.
9+
* Leave undefined to use the guard's default last-call estimate.
10+
*/
11+
estimatedCost?: number;
12+
}
13+
14+
export interface BudgetRetryOptions<T> {
15+
/** Estimated cost for retrying the original call. */
16+
estimatedCost?: number;
17+
/** Total attempts, including the first call. Default: 2. */
18+
maxAttempts?: number;
19+
/** Choose a cheaper retry plan when `guard.advisory().nearLimit` is true. */
20+
onDegrade?: (
21+
error: unknown,
22+
advisory: BudgetAdvisory,
23+
) => RetryPlan<T> | Promise<RetryPlan<T> | undefined> | undefined;
24+
/** Decide whether an error is retryable. Defaults to all non-budget errors. */
25+
retryIf?: (error: unknown) => boolean;
26+
}
27+
28+
function defaultRetryIf(error: unknown): boolean {
29+
return !(error instanceof BudgetExceeded);
30+
}
31+
32+
/**
33+
* Run `call` with budget-aware retries.
34+
*
35+
* The first attempt runs unchanged. If it fails with a retryable error, the
36+
* helper retries as-is with ample budget, asks `onDegrade` for a cheaper plan
37+
* when near the limit, and always calls `guard.check(estimatedCost)` before a
38+
* retry so an over-budget retry is blocked before it runs.
39+
*/
40+
export async function withBudgetRetry<T>(
41+
guard: BudgetGuard,
42+
call: () => T | Promise<T>,
43+
options: BudgetRetryOptions<T> = {},
44+
): Promise<T> {
45+
const maxAttempts = options.maxAttempts ?? 2;
46+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
47+
throw new RangeError(`maxAttempts must be an integer >= 1, got ${maxAttempts}`);
48+
}
49+
const retryIf = options.retryIf ?? defaultRetryIf;
50+
let plan: RetryPlan<T> = { call, estimatedCost: options.estimatedCost };
51+
52+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
53+
try {
54+
return await plan.call();
55+
} catch (error) {
56+
if (attempt >= maxAttempts || !retryIf(error)) {
57+
throw error;
58+
}
59+
plan = await nextPlan(guard, error, plan, options.onDegrade);
60+
}
61+
}
62+
63+
throw new Error("unreachable");
64+
}
65+
66+
async function nextPlan<T>(
67+
guard: BudgetGuard,
68+
error: unknown,
69+
current: RetryPlan<T>,
70+
onDegrade: BudgetRetryOptions<T>["onDegrade"],
71+
): Promise<RetryPlan<T>> {
72+
const advisory = guard.advisory();
73+
if (advisory.nearLimit && onDegrade !== undefined) {
74+
const degraded = await onDegrade(error, advisory);
75+
if (degraded !== undefined) {
76+
guard.check(degraded.estimatedCost);
77+
return degraded;
78+
}
79+
}
80+
guard.check(current.estimatedCost);
81+
return current;
82+
}

js/test/retry.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { BudgetExceeded, BudgetGuard, type RetryPlan, withBudgetRetry } from "../src/index.js";
4+
5+
class RetryableError extends Error {}
6+
7+
describe("withBudgetRetry", () => {
8+
it("retries the same call when budget is ample", async () => {
9+
const guard = new BudgetGuard(1.0);
10+
let primaryCalls = 0;
11+
12+
const result = await withBudgetRetry(
13+
guard,
14+
() => {
15+
primaryCalls += 1;
16+
if (primaryCalls === 1) throw new RetryableError("temporary failure");
17+
return "primary-ok";
18+
},
19+
{ estimatedCost: 0.05, maxAttempts: 2 },
20+
);
21+
22+
expect(result).toBe("primary-ok");
23+
expect(primaryCalls).toBe(2);
24+
});
25+
26+
it("uses a degraded retry plan when near the limit", async () => {
27+
const guard = new BudgetGuard(1.0, { nearLimitBps: 8000 });
28+
guard.recordTool("seed", 0.85);
29+
let primaryCalls = 0;
30+
let cheapCalls = 0;
31+
32+
const result = await withBudgetRetry(
33+
guard,
34+
() => {
35+
primaryCalls += 1;
36+
throw new RetryableError("temporary failure");
37+
},
38+
{
39+
estimatedCost: 0.2,
40+
maxAttempts: 2,
41+
onDegrade: (error): RetryPlan<string> => {
42+
expect(error).toBeInstanceOf(RetryableError);
43+
return {
44+
estimatedCost: 0.01,
45+
call: () => {
46+
cheapCalls += 1;
47+
return "cheap-ok";
48+
},
49+
};
50+
},
51+
},
52+
);
53+
54+
expect(result).toBe("cheap-ok");
55+
expect(primaryCalls).toBe(1);
56+
expect(cheapCalls).toBe(1);
57+
});
58+
59+
it("aborts before a retry whose estimate would cross the budget", async () => {
60+
const guard = new BudgetGuard(1.0, { onBlock: () => undefined });
61+
guard.recordTool("seed", 0.95);
62+
let primaryCalls = 0;
63+
64+
await expect(
65+
withBudgetRetry(
66+
guard,
67+
() => {
68+
primaryCalls += 1;
69+
throw new RetryableError("temporary failure");
70+
},
71+
{ estimatedCost: 0.1, maxAttempts: 2 },
72+
),
73+
).rejects.toBeInstanceOf(BudgetExceeded);
74+
expect(primaryCalls).toBe(1);
75+
});
76+
77+
it("does not retry non-retryable failures", async () => {
78+
const guard = new BudgetGuard(1.0);
79+
let primaryCalls = 0;
80+
81+
await expect(
82+
withBudgetRetry(
83+
guard,
84+
() => {
85+
primaryCalls += 1;
86+
throw new TypeError("bad request");
87+
},
88+
{
89+
estimatedCost: 0.01,
90+
retryIf: (error) => !(error instanceof TypeError),
91+
},
92+
),
93+
).rejects.toThrow("bad request");
94+
expect(primaryCalls).toBe(1);
95+
});
96+
97+
it("rejects invalid maxAttempts", async () => {
98+
await expect(
99+
withBudgetRetry(new BudgetGuard(1.0), () => "ok", { maxAttempts: 0 }),
100+
).rejects.toBeInstanceOf(RangeError);
101+
});
102+
});

src/floe_guard/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from .hosted import hosted_enforcement_available, hosted_remaining_usd
2727
from .latency import LatencyAdvisory, LatencyBudget
2828
from .pricing import ManualPrice, PricedModel, price_tokens, resolve_price
29+
from .retry import RetryPlan, async_with_budget_retry, with_budget_retry
2930
from .stream import StreamGuard, guard_stream
3031

3132
__version__ = "0.8.0" # keep in lockstep with pyproject.toml
@@ -38,6 +39,9 @@
3839
"LatencyAdvisory",
3940
"StreamGuard",
4041
"guard_stream",
42+
"RetryPlan",
43+
"with_budget_retry",
44+
"async_with_budget_retry",
4145
"BudgetExceeded",
4246
"DeadlineExceeded",
4347
"FloeGuardError",

0 commit comments

Comments
 (0)