// 限流重试示例:为什么「错误响应」也必须被录下来。
//
// 运行:
// moon run examples/rate_limit_retry --target js
//
// 场景很常见:第一次调用被限流(429),退避后重试成功(200)。
//
// 如果只录「最终成功」,能被离线回归的也只有「最终成功」。把 429 一起录下来
// 之后,**重试路径本身**也进入了回归范围 —— 而重试逻辑恰恰是写错了也不会
// 立刻暴露的那类代码:少重试一次、多重试一次、或对不该重试的错误重试,
// 「最终成功」的断言全都不会变红。
///|
let output_path : String = "examples/rate_limit_retry.cassette.json"
///|
/// 429 的响应体,按 OpenAI 的真实形状(含 `code` 字段,重试逻辑通常依据它)。
let rate_limited_body : String = "{\"error\":{\"message\":\"Rate limit reached for gpt-4o on requests per min (RPM): Limit 3, Used 3, Requested 1.\",\"type\":\"rate_limit_error\",\"param\":null,\"code\":\"rate_limit_exceeded\"}}"
///|
/// 重试成功后的报文。
let ok_body : String = "{\"id\":\"chatcmpl-retry\",\"object\":\"chat.completion\",\"created\":1677652288,\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"你好!我是月兔助手。\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":5,\"total_tokens\":14}}"
///|
fn chat_request(content : String) -> @core.Request {
let body : Map[String, Json] = Map([])
body.set("model", Json::string("gpt-4o"))
let messages : Array[Json] = []
let message : Map[String, Json] = Map([])
message.set("role", Json::string("user"))
message.set("content", Json::string(content))
messages.push(Json::object(message))
body.set("messages", Json::array(messages))
@core.Request::new("openai", "gpt-4o", Json::object(body))
}
///|
/// 朴素的「遇到 429 就再试」包装。
///
/// 重试逻辑属于业务侧,mooncassette 只负责让这条路径能被离线回归 ——
/// 这里把它写成普通函数,正是为了说明「引擎不介入业务逻辑」。
fn call_with_retry(
session : @recorder.Session,
request : @core.Request,
max_attempts : Int,
) -> @core.Response raise @core.CassetteError {
let mut attempt = 1
let mut response = session.send(request)
while response.status == 429 && attempt < max_attempts {
println(" attempt " + attempt.to_string() + " -> 429, retrying")
attempt = attempt + 1
response = session.send(request)
}
response
}
///|
/// 调一次,并把「成功」或「失败的种类」描述成一行文字。
///
/// 把抛错也当成一种结果来展示,是因为**失败的种类本身就是信息**:
/// 「录制条数不够」要补录制,「请求对不上」要改请求或换匹配策略。
fn describe_call(
session : @recorder.Session,
request : @core.Request,
) -> String {
let response = session.send(request) catch {
error => return describe_error(error)
}
"status " + response.status.to_string()
}
///|
fn describe_error(error : @core.CassetteError) -> String {
match error {
@core.CassetteError::Exhausted(_) =>
"Exhausted —— 录制条数不够,应补录制"
@core.CassetteError::NoMatch(_) =>
"NoMatch —— 请求对不上,应改请求或换匹配策略"
_ => "error: " + error.to_string()
}
}
///|
fn print_statuses(cassette : @core.Cassette) -> Unit {
println(
" recorded : " + cassette.length().to_string() + " interaction(s)",
)
for i = 0; i < cassette.length(); i = i + 1 {
println(
" #" +
i.to_string() +
" status=" +
cassette.interactions[i].response.status.to_string(),
)
}
}
///|
fn main {
// ── 第一步:录制。同一个请求,先 429 再 200 ────────────────────────
let protocol : &@providers.Protocol = @providers.OpenAiChat::new(
"sk-demo-key",
)
let sender = @providers.ScriptedHttpSender::new()
let request = chat_request("你好,请介绍一下自己")
let outgoing = protocol.encode(request) catch {
error => {
println("encode failed: " + error.to_string())
return
}
}
// 关键的一步:给**同一个请求**登记一串应答,而不是一个应答。
sender.on_sequence(outgoing, [
@providers.HttpResponse::new(429, rate_limited_body),
@providers.HttpResponse::new(200, ok_body),
])
let transport = @providers.ProviderTransport::new(protocol, sender)
let session = @mooncassette.record_session(
@core.Cassette::new("rate-limit-retry", recorded_at="2026-09-13T12:00:00Z"),
transport,
)
println("recording:")
let response = call_with_retry(session, request, 3) catch {
error => {
println(" failed: " + error.to_string())
return
}
}
println(" final status : " + response.status.to_string())
println(" answer : " + @canon.to_canonical_string(response.body))
println(" http calls : " + sender.call_count().to_string())
print_statuses(session.cassette())
// ── 第二步:落盘。429 与 200 都在文件里 ──────────────────────────
let text = @mooncassette.save(session)
@fs.write_string_to_file(output_path, text) catch {
_ => println("failed to write " + output_path)
}
println("written : " + output_path)
// ── 第三步:离线回放。同样的重试代码,零网络 ──────────────────────
let playback = @mooncassette.replay_session(text) catch {
error => {
println("load failed: " + error.to_string())
return
}
}
println("replaying (default ring policy):")
let replayed = call_with_retry(playback, request, 3) catch {
error => {
println(" failed: " + error.to_string())
return
}
}
println(" final status : " + replayed.status.to_string())
println(" answer : " + @canon.to_canonical_string(replayed.body))
// 与录制阶段同一个数字:回放没有发起任何网络调用。
println(" http calls : " + sender.call_count().to_string())
println(" retry path reproduced: yes")
// ── 第四步:调用次数多于录制条数时,两种策略的差别 ────────────────
// 环形策略(默认)会回卷复用最早的一条,因此第三次调用又拿到 429。
// 这在「稳态场景下调用次数本就不固定」时是有用的,但也会掩盖「录制不足」。
println("one call too many, ring policy:")
println(" " + describe_call(playback, request))
// 顺序策略不做回卷:游标越界即代表录制条数不够,如实报出来。
// 两种失败的修法不同 —— 这就是把它们分开的理由。
let raw = @codec.decode(text) catch {
error => {
println("load failed: " + error.to_string())
return
}
}
let strict = @recorder.Session::new(
raw,
mode=@recorder.Mode::Playback,
policy=@matcher.MatchPolicy::Sequential,
)
println("one call too many, sequential policy:")
println(" first : " + describe_call(strict, request))
println(" second : " + describe_call(strict, request))
println(" third : " + describe_call(strict, request))
println("ok")
}