Skip to content

RateLimit与配额状态

从 HTTP 头和 WebSocket 事件追踪 RateLimitSnapshot 的解析、稀疏合并、Session 持久状态与 TokenCount 通知。

基于rust-v0.150.0
CodexRustModelRateLimit

RateLimit与配额状态 ​

本文承接 Responses重试策略、模型错误分类 和 流式推理与消息Delta。前文已经说明 429、usage limit 和 retry delay 的错误路径;本文回答另一个问题:一次响应里的限额信息如何变成 Session 中可被 UI 和下一次状态查询消费的快照?

本文关注的是 RateLimitSnapshot 的生命周期,而不是服务端如何计算配额。源码中有两类输入:HTTP response headers 提供请求开始时的初始快照,WebSocket 的 codex.rate_limits 事件提供会话中的更新;两类输入最终都变成 ResponseEvent::RateLimits,由 Core 归并到 SessionState::latest_rate_limits。

读完后,读者应能解释三个看似矛盾的现象:为什么空 HTTP headers 仍可能产生默认 codex 快照,为什么后续稀疏更新不会清空旧 credits,为什么 Core 收到多个限额事件却不会为每个事件都立即发送一个 TokenCount。

1. 快照主线 ​

限额状态不是从 parser 直接写入 UI,而是经过 API、Core Session 和协议事件三层。API 只负责把异构输入转换成结构化快照;Session 是持久 owner;TokenCount 是对外通知载体。

阶段owner输入输出生效时机
解析codex-apiheader 或 JSON eventRateLimitSnapshotstream 创建或收到事件
归一化ResponseEvent单个 snapshot统一 API 事件API→Core channel
合并SessionState新旧 snapshotlatest snapshotCore 消费事件时
通知Sessiontoken info + latest limitsTokenCountturn 安全点或显式更新
展示TUI/App Server/SDK协议事件状态栏、状态接口各消费者自己的刷新周期

这个分层也限定了覆盖范围:RateLimitSnapshot 只表示客户端观察到的配额状态,不代表服务端限额实时一致,更不代表 used_percent 可以直接推导下一次请求一定成功或失败。

2. 数据结构 ​

协议层的快照同时容纳窗口、credits、计划和触达类型。Option 的含义不是“字段永远不存在”,而是该来源没有提供该字段;后面的 merge 逻辑会利用这一点保留旧值。

源码位置:codex-rs/protocol/src/protocol.rs :: RateLimitSnapshot、RateLimitWindow、CreditsSnapshot

rust
pub struct RateLimitSnapshot {
    pub limit_id: Option<String>,
    pub limit_name: Option<String>,
    pub primary: Option<RateLimitWindow>,
    pub secondary: Option<RateLimitWindow>,
    pub credits: Option<CreditsSnapshot>,
    pub individual_limit: Option<SpendControlLimitSnapshot>,
    pub spend_control_reached: Option<bool>,
    pub plan_type: Option<crate::account::PlanType>,
    pub rate_limit_reached_type: Option<RateLimitReachedType>,
}

pub struct RateLimitWindow {
    pub used_percent: f64,
    pub window_minutes: Option<i64>,
    pub resets_at: Option<i64>,
}

pub struct CreditsSnapshot {
    pub has_credits: bool,
    pub unlimited: bool,
    pub balance: Option<String>,
}

primary 和 secondary 是两个滚动窗口,不是两个独立账户;credits 是额度信息,不等价于窗口百分比;rate_limit_reached_type 则描述触达原因。教程阅读时应先区分“窗口消耗”和“账户额度”,再追它们分别由哪个输入填充。

3. Header解析 ​

HTTP headers 由 parse_all_rate_limits 发现限制族,再调用 parse_rate_limit_for_limit 逐族解析。默认族始终先处理,额外族通过 header 名的 -primary-used-percent 后缀反向发现。

源码位置:codex-rs/codex-api/src/rate_limits.rs :: parse_all_rate_limits

rust
pub fn parse_all_rate_limits(headers: &HeaderMap) -> Vec<RateLimitSnapshot> {
    let mut snapshots = Vec::new();
    if let Some(snapshot) = parse_default_rate_limit(headers) {
        snapshots.push(snapshot);
    }

    let mut limit_ids: BTreeSet<String> = BTreeSet::new();
    for name in headers.keys() {
        let header_name = name.as_str().to_ascii_lowercase();
        if let Some(limit_id) = header_name_to_limit_id(&header_name)
            && limit_id != "codex"
        {
            limit_ids.insert(limit_id);
        }
    }

    snapshots.extend(limit_ids.into_iter().filter_map(|limit_id| {
        let snapshot = parse_rate_limit_for_limit(headers, Some(limit_id.as_str()))?;
        has_rate_limit_data(&snapshot).then_some(snapshot)
    }));
    snapshots
}

这里有一个容易误判的边界:默认快照不经过 has_rate_limit_data 过滤,因此空 headers 也会返回一个 limit_id = codex、窗口和 credits 均为空的记录;额外限制族则必须有窗口或 credits 数据才会保留。这是兼容默认 bucket 的策略,不是 parser 忘记过滤。

按族解析时,字段名由 normalized limit id 拼成 header 前缀,并且窗口只有在存在非零数据时才产生。

源码位置:codex-rs/codex-api/src/rate_limits.rs :: parse_rate_limit_for_limit、parse_rate_limit_window

rust
pub fn parse_rate_limit_for_limit(
    headers: &HeaderMap,
    limit_id: Option<&str>,
) -> Option<RateLimitSnapshot> {
    let normalized_limit = limit_id
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .unwrap_or("codex")
        .to_ascii_lowercase()
        .replace('_', "-");
    let prefix = format!("x-{normalized_limit}");
    let primary = parse_rate_limit_window(
        headers,
        &format!("{prefix}-primary-used-percent"),
        &format!("{prefix}-primary-window-minutes"),
        &format!("{prefix}-primary-reset-at"),
    );
    let secondary = parse_rate_limit_window(
        headers,
        &format!("{prefix}-secondary-used-percent"),
        &format!("{prefix}-secondary-window-minutes"),
        &format!("{prefix}-secondary-reset-at"),
    );
    let normalized_limit_id = normalize_limit_id(normalized_limit);
    let credits = parse_credits_snapshot(headers);
    let limit_name_header = format!("{prefix}-limit-name");
    let parsed_limit_name = parse_header_str(headers, &limit_name_header)
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .map(std::string::ToString::to_string);
    Some(RateLimitSnapshot {
        limit_id: Some(normalized_limit_id),
        limit_name: parsed_limit_name,
        primary,
        secondary,
        credits,
        individual_limit: None,
        spend_control_reached: None,
        plan_type: None,
        rate_limit_reached_type: None,
    })
}

fn parse_rate_limit_window(
    headers: &HeaderMap,
    used_percent_header: &str,
    window_minutes_header: &str,
    resets_at_header: &str,
) -> Option<RateLimitWindow> {
    let used_percent: Option<f64> = parse_header_f64(headers, used_percent_header);
    used_percent.and_then(|used_percent| {
        let window_minutes = parse_header_i64(headers, window_minutes_header);
        let resets_at = parse_header_i64(headers, resets_at_header);
        let has_data = used_percent != 0.0
            || window_minutes.is_some_and(|minutes| minutes != 0)
            || resets_at.is_some();
        has_data.then_some(RateLimitWindow {
            used_percent,
            window_minutes,
            resets_at,
        })
    })
}

parse_header_f64 还会拒绝非有限浮点数,布尔字段只接受 true/false 或 1/0。因此错误 header 通常表现为“字段缺失”,而不是让整个 stream 失败;这是一种局部降级,读者调试时要区分“没有快照”和“快照存在但窗口为空”。

4. SSE初始值 ​

HTTP stream 建立成功后,spawn_response_stream 先从响应头解析快照,再把每个快照作为 channel 的前置事件发送。它与后续 SSE body 共用同一个 ResponseStream,所以 Core 不需要知道数据来自 headers 还是 body。

源码位置:codex-rs/codex-api/src/sse/responses.rs :: spawn_response_stream

rust
pub fn spawn_response_stream(
    stream_response: StreamResponse,
    idle_timeout: Duration,
    telemetry: Option<Arc<dyn SseTelemetry>>,
    turn_state: Option<Arc<OnceLock<String>>>,
) -> ResponseStream {
    let rate_limit_snapshots = parse_all_rate_limits(&stream_response.headers);
    let models_etag = stream_response
        .headers
        .get("X-Models-Etag")
        .and_then(|v| v.to_str().ok())
        .map(ToString::to_string);
    let server_model = stream_response
        .headers
        .get(OPENAI_MODEL_HEADER)
        .and_then(|v| v.to_str().ok())
        .map(ToString::to_string);
    let (tx_event, rx_event) = mpsc::channel::<Result<ResponseEvent, ApiError>>(1600);
    tokio::spawn(async move {
        if let Some(model) = server_model {
            let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await;
        }
        for snapshot in rate_limit_snapshots {
            let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
        }
        if let Some(etag) = models_etag {
            let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await;
        }
        process_sse_with_treatment(
            stream_response.bytes,
            tx_event,
            idle_timeout,
            telemetry,
            safety_buffering_treatment,
        )
        .await;
    });
    ResponseStream { rx_event }
}

初始 headers 的快照在 SSE parser 读取第一帧前就排入 channel,因此它描述的是“本次 response 建立时服务端给出的状态”。它不是上一次 Session 状态的替换命令;真正的合并发生在 Core 消费 ResponseEvent::RateLimits 时。

5. WebSocket更新 ​

WebSocket 没有 HTTP body 的 SSE 解帧,但它把文本 JSON 解析为同一类 ResponsesStreamEvent。遇到 codex.rate_limits 时,专用 parser 生成快照并立即发送 ResponseEvent::RateLimits;其他事件继续走普通 Responses 事件分类。

源码位置:codex-rs/codex-api/src/endpoint/responses_websocket.rs :: rate-limit event branch

rust
if event.kind() == "codex.rate_limits" {
    if let Some(snapshot) = parse_rate_limit_event(&text) {
        let _ = tx_event
            .send(Ok(ResponseEvent::RateLimits(snapshot)))
            .await;
    }
    continue;
}

parse_rate_limit_event 只接受 type = codex.rate_limits,并从 metered_limit_name 或 limit_name 生成限制族 id;事件可以同时提供窗口、credits 和 plan type。未知事件类型或 JSON 解码失败会被忽略,不会把整个 WebSocket 流标成 API error。

源码位置:codex-rs/codex-api/src/rate_limits.rs :: parse_rate_limit_event

rust
pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
    let event: RateLimitEvent = serde_json::from_str(payload).ok()?;
    if event.kind != "codex.rate_limits" {
        return None;
    }
    let (primary, secondary) = if let Some(details) = event.rate_limits.as_ref() {
        (
            map_event_window(details.primary.as_ref()),
            map_event_window(details.secondary.as_ref()),
        )
    } else {
        (None, None)
    };
    let credits = event.credits.map(|credits| CreditsSnapshot {
        has_credits: credits.has_credits,
        unlimited: credits.unlimited,
        balance: credits.balance,
    });
    let limit_id = event
        .metered_limit_name
        .or(event.limit_name)
        .map(normalize_limit_id);
    Some(RateLimitSnapshot {
        limit_id: Some(limit_id.unwrap_or_else(|| "codex".to_string())),
        limit_name: None,
        primary,
        secondary,
        credits,
        individual_limit: None,
        spend_control_reached: None,
        plan_type: event.plan_type,
        rate_limit_reached_type: None,
    })
}

HTTP header快照和 WebSocket事件快照并不完全对称:headers 可以带 limit_name 和 credits,但没有 plan type;WebSocket 事件可以带 plan type,却把 limit_name 留空。后续 Session merge 正是为了让这类异构、稀疏输入组合成可用状态。

6. Core归并 ​

Core 的 turn reducer 收到快照后,不立即发送事件,而是写入 Session,并设置 should_emit_token_count。这个延迟让 token usage、工具等待和取消路径有机会在同一个安全点统一发出状态。

源码位置:codex-rs/core/src/session/turn.rs :: ResponseEvent::RateLimits

rust
let mut should_emit_token_count = false;

ResponseEvent::RateLimits(snapshot) => {
    // 先更新内部状态,等 token usage 可用时再发送,避免重复 TokenCount。
    sess.record_rate_limits_info(snapshot).await;
    should_emit_token_count = true;
}

// 工具 drain、完成或取消处理之后:
if should_emit_token_count {
    // 等待需要用户输入的工具结束,避免把暂停中的进度提前发给客户端。
    sess.send_token_count_event(&turn_context).await;
}

这里的 owner 是 Session,不是当前 ResponseEvent。一个 turn 可能收到多个 header/event 快照,最后在 reducer 的收尾点只发一次聚合通知;如果工具请求用户输入,通知还会推迟到 pending tools resolve 之后。

7. 稀疏合并 ​

SessionState::set_rate_limits 调用 merge_rate_limit_fields。合并不是简单的 struct 覆盖:缺少 limit_id 时归入默认 codex bucket,缺少 credits、计划、individual limit 或 spend-control 状态时保留旧值;窗口字段则使用新 snapshot 的值。

源码位置:codex-rs/core/src/state/session.rs :: set_rate_limits、merge_rate_limit_fields

rust
pub(crate) fn set_rate_limits(&mut self, snapshot: RateLimitSnapshot) {
    self.latest_rate_limits = Some(merge_rate_limit_fields(
        self.latest_rate_limits.as_ref(),
        snapshot,
    ));
}

fn merge_rate_limit_fields(
    previous: Option<&RateLimitSnapshot>,
    mut snapshot: RateLimitSnapshot,
) -> RateLimitSnapshot {
    if snapshot.limit_id.is_none() {
        snapshot.limit_id = Some("codex".to_string());
    }
    if snapshot.credits.is_none() {
        snapshot.credits = previous.and_then(|prior| prior.credits.clone());
    }
    if snapshot.individual_limit.is_none() {
        snapshot.individual_limit = previous.and_then(|prior| prior.individual_limit.clone());
    }
    if snapshot.spend_control_reached.is_none() {
        snapshot.spend_control_reached = previous.and_then(|prior| prior.spend_control_reached);
    }
    if snapshot.plan_type.is_none() {
        snapshot.plan_type = previous.and_then(|prior| prior.plan_type);
    }
    snapshot
}

这个算法不是按 limit_id 保存多个 map,而是把每个新 snapshot 合并到单个 latest_rate_limits。因此同一个 Session 如果先收到 codex 再收到 codex_other,后者会成为当前 latest;它能保留旧的 account metadata,但不能证明 Session 同时展示所有限制族。额外族的全量展示需要上层另行维护或消费原始事件。

8. 通知收口 ​

update_rate_limits 是显式更新入口,适用于 Core 已经知道需要立刻把新快照带给客户端的路径;普通 turn reducer 则使用 record_rate_limits_info 延后发送。两者最终都读取 token_info_and_rate_limits,构造同一个 TokenCountEvent。

源码位置:codex-rs/core/src/session/mod.rs :: update_rate_limits、send_token_count_event

rust
pub(crate) async fn update_rate_limits(
    &self,
    turn_context: &TurnContext,
    new_rate_limits: RateLimitSnapshot,
) {
    self.record_rate_limits_info(new_rate_limits).await;
    self.send_token_count_event(turn_context).await;
}

pub(crate) async fn record_rate_limits_info(&self, new_rate_limits: RateLimitSnapshot) {
    {
        let mut state = self.state.lock().await;
        state.set_rate_limits(new_rate_limits);
    }
}

pub(crate) async fn send_token_count_event(&self, turn_context: &TurnContext) {
    let (info, rate_limits) = {
        let state = self.state.lock().await;
        state.token_info_and_rate_limits()
    };
    let event = EventMsg::TokenCount(TokenCountEvent { info, rate_limits });
    self.send_event(turn_context, event).await;
}

锁只保护 SessionState 的读取和写入,事件发送在释放锁之后进行,避免下游消费者回调期间持有状态锁。TokenCountEvent 同时带 token info 和 rate limits,所以 UI 可以在一个协议事件中刷新两种进度。

9. 触达类型 ​

配额触达原因通过 x-codex-rate-limit-reached-type header 进入 RateLimitReachedType,并在 429 usage-limit bridge 中附加到 UsageLimitReachedError 及其快照。它不是普通窗口百分比的替代字段:同一个窗口可能还未达到 100%,但账户 credits 或 workspace usage 已经成为拒绝原因。

源码位置:codex-rs/codex-api/src/api_bridge.rs :: usage-limit mapping

rust
let rate_limit_reached_type =
    headers.as_ref().and_then(parse_rate_limit_reached_type);
let rate_limits = headers
    .as_ref()
    .and_then(|map| parse_rate_limit_for_limit(map, limit_id.as_deref()))
    .map(|mut snapshot| {
        snapshot.rate_limit_reached_type = rate_limit_reached_type;
        snapshot
    });

return CodexErr::UsageLimitReached(UsageLimitReachedError {
    plan_type: err.error.plan_type,
    resets_at,
    rate_limits: rate_limits.map(Box::new),
    promo_message,
    rate_limit_reached_type,
});

未知的 reached type 会被 parse() 拒绝并变成 None,而不是阻断整个错误映射。这让新服务端枚举可以在旧客户端中退化为普通 usage limit;证明的是兼容性策略,不是客户端已经理解新原因。

10. 配额测试 ​

本文使用三类测试:header parser 证明限制族发现和字段解析,Session 测试证明稀疏 merge,API bridge 测试证明 usage-limit header 的触达类型不会丢失。

源码位置:codex-rs/codex-api/src/rate_limits.rs :: parser tests

rust
#[test]
fn parse_all_rate_limits_reads_all_limit_families() {
    let mut headers = HeaderMap::new();
    headers.insert(
        "x-codex-primary-used-percent",
        HeaderValue::from_static("12.5"),
    );
    headers.insert(
        "x-codex-secondary-primary-used-percent",
        HeaderValue::from_static("80"),
    );

    let updates = parse_all_rate_limits(&headers);
    assert_eq!(updates.len(), 2);
    assert_eq!(updates[0].limit_id.as_deref(), Some("codex"));
    assert_eq!(updates[1].limit_id.as_deref(), Some("codex_secondary"));
}

#[test]
fn parse_all_rate_limits_includes_default_codex_snapshot() {
    let updates = parse_all_rate_limits(&HeaderMap::new());
    assert_eq!(updates.len(), 1);
    assert_eq!(updates[0].limit_id.as_deref(), Some("codex"));
    assert_eq!(updates[0].primary, None);
}

第一个测试证明默认族和额外族的发现顺序;第二个证明空 headers 的默认快照行为。它们没有证明 header 来源一定来自真实 OpenAI endpoint,也没有证明额外限制族在所有消费者中都会并列展示。

源码位置:codex-rs/core/src/session/tests.rs :: set_rate_limits_retains_previous_credits

rust
state.set_rate_limits(initial.clone());

let update = RateLimitSnapshot {
    limit_id: Some("codex_other".to_string()),
    limit_name: Some("codex_other".to_string()),
    primary: Some(RateLimitWindow {
        used_percent: 40.0,
        window_minutes: Some(30),
        resets_at: Some(1_800),
    }),
    secondary: None,
    credits: None,
    individual_limit: None,
    spend_control_reached: None,
    plan_type: None,
    rate_limit_reached_type: None,
};
state.set_rate_limits(update.clone());

assert_eq!(
    state.latest_rate_limits,
    Some(RateLimitSnapshot {
        limit_id: Some("codex_other".to_string()),
        limit_name: Some("codex_other".to_string()),
        primary: update.primary.clone(),
        secondary: update.secondary,
        credits: initial.credits,
        individual_limit: initial.individual_limit,
        spend_control_reached: initial.spend_control_reached,
        plan_type: initial.plan_type,
        rate_limit_reached_type: None,
    })
);

断言的关键不是窗口从 10% 变成 40%,而是新快照没有 credits 时旧 credits 仍然存在;同时新的 limit_id 和窗口会生效。它没有证明 merge 会保存所有历史限制族,也没有覆盖 plan type 的另一条保留路径。

源码位置:codex-rs/codex-api/src/api_bridge_tests.rs :: map_api_error_copies_rate_limit_reached_type_to_usage_limit_snapshot

rust
for (active_limit, expected_limit_id) in
    [(None, "codex"), (Some("codex_other"), "codex_other")]
{
    let mut headers = HeaderMap::new();
    if let Some(active_limit) = active_limit {
        headers.insert(
            ACTIVE_LIMIT_HEADER,
            http::HeaderValue::from_static(active_limit),
        );
    }
    for (name, value) in [
        ("x-codex-credits-has-credits", "true"),
        ("x-codex-credits-unlimited", "false"),
        ("x-codex-credits-balance", ""),
        (
            "x-codex-rate-limit-reached-type",
            "workspace_member_usage_limit_reached",
        ),
    ] {
        headers.insert(name, http::HeaderValue::from_static(value));
    }
    let body = serde_json::json!({
        "error": {
            "type": "usage_limit_reached",
            "plan_type": "pro",
        }
    })
    .to_string();

    let err = map_api_error(ApiError::Transport(TransportError::Http {
        status: http::StatusCode::TOO_MANY_REQUESTS,
        url: Some("http://example.com/v1/responses".to_string()),
        headers: Some(headers),
        body: Some(body),
    }));

    let CodexErrorDetails::UsageLimitReached(usage_limit) = err.details() else {
        panic!("expected CodexErrorDetails::UsageLimitReached, got {err:?}");
    };
    assert_eq!(
        usage_limit.rate_limit_reached_type,
        Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
    );
    let snapshot = usage_limit
        .rate_limits
        .as_ref()
        .expect("usage limit snapshot");
    assert_eq!(snapshot.limit_id.as_deref(), Some(expected_limit_id));
    assert_eq!(
        snapshot.rate_limit_reached_type,
        Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached)
    );
}

这证明 429 的 header 诊断会同时进入错误详情和快照;它不证明客户端会自动等待 reset,也不证明所有 429 都有 usage-limit body。

可以在 codex-rs/ 工作区运行:

bash
cargo test -p codex-api parse_all_rate_limits_reads_all_limit_families
cargo test -p codex-api parse_all_rate_limits_includes_default_codex_snapshot
cargo test -p codex-core set_rate_limits_retains_previous_credits
cargo test -p codex-api map_api_error_copies_rate_limit_reached_type_to_usage_limit_snapshot

11. 配额定位 ​

  1. 复述一条 HTTP header 快照和一条 WebSocket codex.rate_limits 事件分别经过哪些函数,在哪一步汇合为 ResponseEvent::RateLimits。
  2. 给定一个新 snapshot:limit_id = None、credits = None、plan_type = None,说明 Session merge 后哪些值来自旧快照,哪些值来自新快照。
  3. 只读定位:如果 UI 没有重复刷新,但 Session 已经收到了多个限额事件,应检查 turn.rs 的 should_emit_token_count 和 send_token_count_event,而不是修改 header parser。

说明: 文中的测试只验证客户端解析、归并和协议投影;它们不能证明服务端配额计算、跨设备一致性或 UI 的最终排版。

12. 技术边界 ​

这些测试覆盖:HTTP headers 与 WebSocket 事件都能投影为 RateLimitSnapshot;默认和额外限制族有不同过滤规则;Session 对稀疏字段执行保留式 merge;Core 延迟到安全收口点发送 TokenCountEvent;429 的触达类型会附加到 usage-limit 错误。

本文未覆盖服务端限额计算、快照的新鲜度、多个限制族在所有前端中的展示策略、reset 时间到达后的主动刷新,以及未来协议字段新增时的 merge 语义。

下一篇将进入 HTTP Client 中间件,继续追踪 headers、代理、TLS、超时和响应体限制如何在这些模型请求之前生效。