mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-15 00:06:24 +00:00
fix: account recovery edge cases
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# OpenAI/Codex 真实链路测试记录
|
||||
|
||||
## 本轮上下文
|
||||
|
||||
- 开始时间:2026-06-23 19:32:16 Asia/Shanghai
|
||||
- 目标:继续真实数据全链路测试,重点覆盖边界情况、封禁链路剥离、防止上下文污染、重试边界。
|
||||
- 运行目录:`.runtime/real-chain-openai-20260623-193216`
|
||||
- 规则:确认问题后先修复并复测,再继续后续场景。
|
||||
|
||||
## 场景矩阵
|
||||
|
||||
| 场景 | 状态 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 本地编译检查 | 通过 | `cargo check` |
|
||||
| 封禁/禁用亲和剥离集成覆盖 | 通过 | `cargo test --test integration proxy::dispatch::chat_upstream::responses_http:: -- --nocapture`;`responses_websocket::` |
|
||||
| 5xx/429/403/402/Cloudflare 重试集成覆盖 | 通过 | `responses_recovery::` 37 个用例;`chat_routes::` 15 个用例;`responses_websocket::` 30 个用例 |
|
||||
| 真实链路基础健康检查 | 通过 | `/debug/diagnostics`:5 accounts,1 active,3 disabled,1 banned |
|
||||
| 真实 Responses 基线请求 | 通过 | `req_e29e505e-4623-4502-9676-922a27c1d247`,`resp_09b95050e9f8ff4b016a3a703bc2f8819a8e248aa2fc31ff4d` |
|
||||
| 真实 previous_response_id 正常续接 | 通过 | `req_d8d28aed-6711-42fa-9ef5-8e1152d20bf3`,返回体保留 previous response |
|
||||
| 真实 banned 账号亲和剥离 | 通过 | `req_e7d5625a-543b-4a64-9038-98e4afc84b57`,返回体 `previous_response_id=null` |
|
||||
| 真实 disabled 账号亲和剥离 | 通过 | `req_9b52e5e8-eb57-471d-8825-0510a7e4017b`,返回体 `previous_response_id=null` |
|
||||
| 真实 all accounts unavailable 边界 | 通过 | `req_63812d11-c9f9-4eac-afde-3d2837ba7754`,SSE `response.failed` |
|
||||
| 真实 quota verify 边界 | 通过 | `req_faf18f0b-1e35-4dc5-8f47-632eb03d2c9e`,`quota_verify_required` 从 1 清回 0 |
|
||||
| 误置 active 的 banned/disabled 账号状态回写 | 通过 | 发现并修复无有效 token exp 时只内存过期、不落库的问题 |
|
||||
| 真实 5xx/429/Cloudflare 重试边界 | 受限 | 真实上游无法稳定强制触发,已由集成夹具覆盖 |
|
||||
|
||||
## 本地验证记录
|
||||
|
||||
- `cargo check`:通过。
|
||||
- `cargo test --test integration proxy::dispatch::chat_upstream::responses_recovery:: -- --nocapture`:37 passed,覆盖 Responses HTTP/SSE 的 401/402/403/429、Cloudflare challenge/path-block、5xx same-account retry、模型不支持 fallback、previous_response_id/函数调用/加密 replay 历史剥离。
|
||||
- `cargo test --test integration proxy::dispatch::chat_upstream::responses_websocket:: -- --nocapture`:30 passed,覆盖 WebSocket 连接复用、复用连接断开后 fresh retry、previous_response_id fallback、隐式 banned 亲和剥离、quota/rate limit/path-block 边界。
|
||||
- `cargo test --test integration proxy::dispatch::chat_upstream::chat_routes:: -- --nocapture`:15 passed,覆盖 Chat Completions 的 401/402/403/429、Cloudflare、模型不支持和 SSE 翻译。
|
||||
- `cargo test --test integration proxy::dispatch::chat_upstream::responses_http:: -- --nocapture`:23 passed,覆盖 HTTP Responses 参数转换、cookie path 作用域、banned 亲和剥离、quota 亲和保留和下游断开。
|
||||
- `cargo test --test integration upstream::accounts::token_refresh:: -- --nocapture`:13 passed,覆盖 transient retry、ambiguous transport 不重试、invalid_grant 二次确认、retry exhaustion 延后恢复、stale refresh token 防复用。
|
||||
- `cargo test --test integration upstream::accounts::account_pool::quota:: -- --nocapture`:9 passed,覆盖 quota/cooldown 选择边界。
|
||||
- `cargo test --test integration upstream::accounts::cloudflare:: -- --nocapture`:2 passed,覆盖 Cloudflare cooldown 升级和过期重置。
|
||||
- `cargo test --test integration mark_banned -- --nocapture`:5 passed,覆盖 401 `account_deactivated` 和 403 banned 信号将账号标记为 banned。
|
||||
- `cargo test --test integration mark_expired_after_401 -- --nocapture`:3 passed,覆盖普通 401 将账号标记为 expired。
|
||||
- `cargo test --test integration runtime_account_pool_should_persist_expired_status_when_jwt_expiry_is_discovered -- --nocapture`:通过,覆盖运行时调度发现 active 账号 token 已过期时持久化为 expired。
|
||||
- `cargo test --test integration upstream::accounts::account_pool:: -- --nocapture`:27 passed,覆盖账号池调度、quota/cooldown、运行时状态回写。
|
||||
|
||||
## 真实链路记录
|
||||
|
||||
### 环境
|
||||
|
||||
- 服务:`CODEX_PROXY_WS_AUDIT_DIR=.runtime/real-chain-openai-20260623-193216/ws-audit target/debug/codex-proxy-rs`
|
||||
- 模型:`gpt-5.4-mini`
|
||||
- `/v1/models`:HTTP 200,返回 `codex-auto-review`、`gpt-5.4-mini`、`gpt-5.5`。
|
||||
- 初始账号池:5 total,1 active,3 disabled,1 banned。
|
||||
- 结束账号池:5 total,1 active,3 disabled,1 banned;active 账号 `quota_verify_required=0`,`quota_cooldown_until=null`,`cloudflare_cooldown_until=null`。
|
||||
|
||||
### 结果
|
||||
|
||||
- 基线 Responses JSON:`req_e29e505e-4623-4502-9676-922a27c1d247`,HTTP 200,`response_id=resp_09b95050e9f8ff4b016a3a703bc2f8819a8e248aa2fc31ff4d`,输出 `EDGE-BASELINE-OK`,event log 记录 `transport=websocket`、`account_id=a18bcfa9ae932857`。
|
||||
- 正常 previous_response_id 续接:`req_d8d28aed-6711-42fa-9ef5-8e1152d20bf3`,HTTP 200,输出 `EDGE-CONTINUE-OK`,返回体 `previous_response_id=resp_09b95050e9f8ff4b016a3a703bc2f8819a8e248aa2fc31ff4d`,prompt cache key 与基线一致。
|
||||
- banned 亲和剥离:预置 `resp_real_edge_seed_banned_20260623 -> acct_be7f5c37f60b44ff8058b1b9b164fd42(banned)`,重启加载后请求 `req_e7d5625a-543b-4a64-9038-98e4afc84b57`,HTTP 200,输出 `EDGE-BANNED-STRIPPED-OK`,返回体 `previous_response_id=null`,event log 落到 active 账号 `a18bcfa9ae932857`。
|
||||
- disabled 亲和剥离:预置 `resp_real_edge_seed_disabled_20260623 -> 10e584b410a5f1ab(disabled)`,重启加载后请求 `req_9b52e5e8-eb57-471d-8825-0510a7e4017b`,HTTP 200,输出 `EDGE-DISABLED-STRIPPED-OK`,返回体 `previous_response_id=null`,event log 落到 active 账号 `a18bcfa9ae932857`。
|
||||
- 防止历史污染:两条 seed affinity 在剥离后已不存在;WS audit 中正常续接请求包含 `previous_response_id`,banned/disabled 剥离请求的上游 payload body 不包含 `previous_response_id` 或 `turn_state`。
|
||||
- all accounts unavailable:临时通过 admin API 将唯一 active 账号置为 disabled,请求 `req_63812d11-c9f9-4eac-afde-3d2837ba7754`,HTTP 200 SSE,返回 `event: response.failed`,message 为 `No active upstream account is available`;随后通过 admin API 恢复 active。
|
||||
- quota verify:停止服务后将 active 账号 `quota_verify_required=1`,重启后请求 `req_faf18f0b-1e35-4dc5-8f47-632eb03d2c9e`,HTTP 200,输出 `EDGE-QUOTA-VERIFY-OK`;请求后 DB 中 `quota_verify_required=0` 且 `quota_fetched_at=2026-06-23T11:44:04.888488741+00:00`。
|
||||
|
||||
### 误置 active 的禁用/封禁账号状态回写
|
||||
|
||||
- 测试方法:临时将原 active 账号 `a18bcfa9ae932857` 置为 disabled,逐个将目标 disabled/banned 账号置为 active,确保请求只会命中目标账号;请求后先读取 DB 状态,再恢复原状态。
|
||||
- banned 账号 `acct_be7f5c37f60b44ff8058b1b9b164fd42`:请求 `req_116f4b30-c8fa-4718-a69a-ccd22b66d562`,真实上游返回 401 `token_invalidated`,请求后状态从 active 变为 expired。该响应不是 banned 信号,所以不会标记为 banned。
|
||||
- disabled 账号 `64e49d697f15dd02`:请求 `req_c2514f79-e429-49c8-9635-2383a2cfe470`,真实上游返回 401 `token_invalidated`,请求后状态从 active 变为 expired。
|
||||
- disabled 账号 `10e584b410a5f1ab` 和 `38907867f4c6c36e`:首次测试时响应为 503 `no_available_accounts`,状态仍为 active。原因是两者 `access_token_expires_at=null` 且 token 无有效 JWT exp,账号池在调度阶段内存判定 expired,没有持久化到 DB。
|
||||
- 修复:`RuntimeAccountPoolService::acquire_with` 持久化账号池调度阶段发现的 runtime-expired 账号状态。
|
||||
- 修复后复测:
|
||||
- `10e584b410a5f1ab`:请求 `req_25d53384-1165-4da5-91f9-918b1709f8bc`,HTTP 503 `no_available_accounts`,请求返回前 DB 状态已从 active 变为 expired。
|
||||
- `38907867f4c6c36e`:请求 `req_4b0181b9-a248-4402-ad7f-b533c8669907`,HTTP 503 `no_available_accounts`,请求返回前 DB 状态已从 active 变为 expired。
|
||||
- 结论:真实打到上游并收到 401/403 时会按信号及时回写;不打上游、但运行时已经能判定 token 过期/无效时,修复后也会及时回写 expired。
|
||||
|
||||
### 未真实强制触发的边界
|
||||
|
||||
- 真实上游 5xx、429、Cloudflare challenge/path-block 不能稳定人工触发,本轮没有对真实 OpenAI 强行制造这些状态。
|
||||
- 对应重试和状态传播已通过集成测试覆盖:HTTP/SSE 5xx same-account retry、429 fallback/exhausted、402 quota fallback/exhausted、403 banned fallback、Cloudflare challenge/path-block cooldown/清 cookie/三次 path-block disable,以及 WebSocket previous_response_id fallback、quota/rate limit/path-block stream error。
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Cloudflare challenge and path-block recovery shared by dispatch routes.
|
||||
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
@@ -57,8 +59,6 @@ impl CloudflareRecovery {
|
||||
account_pool: &RuntimeAccountPoolService,
|
||||
account_id: &str,
|
||||
) {
|
||||
self.delete_account_cookies(account_id, "Cloudflare challenge")
|
||||
.await;
|
||||
let now = Utc::now();
|
||||
let cooldown = self
|
||||
.challenge_tracker
|
||||
@@ -67,6 +67,7 @@ impl CloudflareRecovery {
|
||||
account_pool
|
||||
.set_cloudflare_cooldown_until(account_id, cooldown.cooldown_until)
|
||||
.await;
|
||||
self.clear_challenge_cookies_after_cooldown(account_id, cooldown.delay_seconds);
|
||||
}
|
||||
|
||||
pub async fn apply_path_block(
|
||||
@@ -97,14 +98,29 @@ impl CloudflareRecovery {
|
||||
}
|
||||
|
||||
async fn delete_account_cookies(&self, account_id: &str, reason: &str) {
|
||||
if let Err(error) = self.cookie_store.delete_account_cookies(account_id).await {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
reason,
|
||||
error = %error,
|
||||
"failed to delete account cookies after Cloudflare recovery signal"
|
||||
);
|
||||
}
|
||||
delete_account_cookies(&self.cookie_store, account_id, reason).await;
|
||||
}
|
||||
|
||||
fn clear_challenge_cookies_after_cooldown(&self, account_id: &str, delay_seconds: i64) {
|
||||
let cookie_store = self.cookie_store.clone();
|
||||
let account_id = account_id.to_string();
|
||||
let delay = StdDuration::from_secs(delay_seconds.max(0) as u64);
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
delete_account_cookies(&cookie_store, &account_id, "Cloudflare challenge").await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_account_cookies(cookie_store: &SqliteCookieStore, account_id: &str, reason: &str) {
|
||||
if let Err(error) = cookie_store.delete_account_cookies(account_id).await {
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
reason,
|
||||
error = %error,
|
||||
"failed to delete account cookies after Cloudflare recovery signal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+103
-59
@@ -123,6 +123,12 @@ pub struct AcquiredAccount {
|
||||
pub previous_slot_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AccountAcquireWithStatusRefresh {
|
||||
acquired: Option<AcquiredAccount>,
|
||||
expired_account_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 账号池容量摘要。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountCapacitySummary {
|
||||
@@ -422,48 +428,90 @@ impl AccountPool {
|
||||
|
||||
/// 使用完整调度请求获取账号。
|
||||
pub fn acquire_with(&mut self, request: AccountAcquireRequest) -> Option<AcquiredAccount> {
|
||||
self.acquire_with_status_refresh(request).acquired
|
||||
}
|
||||
|
||||
fn acquire_with_status_refresh(
|
||||
&mut self,
|
||||
request: AccountAcquireRequest,
|
||||
) -> AccountAcquireWithStatusRefresh {
|
||||
self.cleanup_stale_slots(request.now);
|
||||
self.refresh_account_statuses(request.now);
|
||||
let expired_account_ids = self.refresh_account_statuses(request.now);
|
||||
let candidates = self.candidates(&request);
|
||||
let (selected, previous_slot_at) =
|
||||
if let Some(preferred_account_id) = &request.preferred_account_id {
|
||||
let preferred = candidates
|
||||
.iter()
|
||||
.find(|account| account.id == *preferred_account_id)
|
||||
.cloned();
|
||||
preferred.map(|account| (account, self.previous_slot_at(preferred_account_id)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| {
|
||||
Some(match self.options.rotation_strategy {
|
||||
RotationStrategy::LeastUsed => {
|
||||
let account = self.select_least_used(&candidates)?;
|
||||
let previous = self.previous_slot_at(&account.id);
|
||||
(account, previous)
|
||||
}
|
||||
RotationStrategy::RoundRobin => {
|
||||
let account = self.select_round_robin(&candidates)?;
|
||||
let previous = self.previous_slot_at(&account.id);
|
||||
(account, previous)
|
||||
}
|
||||
RotationStrategy::Sticky => {
|
||||
let account = self.select_sticky(&candidates)?;
|
||||
let previous = self.previous_slot_at(&account.id);
|
||||
(account, previous)
|
||||
}
|
||||
})
|
||||
})?;
|
||||
let selected = if let Some(preferred_account_id) = &request.preferred_account_id {
|
||||
let preferred = candidates
|
||||
.iter()
|
||||
.find(|account| account.id == *preferred_account_id)
|
||||
.cloned();
|
||||
preferred.map(|account| (account, self.previous_slot_at(preferred_account_id)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| {
|
||||
Some(match self.options.rotation_strategy {
|
||||
RotationStrategy::LeastUsed => {
|
||||
let account = self.select_least_used(&candidates)?;
|
||||
let previous = self.previous_slot_at(&account.id);
|
||||
(account, previous)
|
||||
}
|
||||
RotationStrategy::RoundRobin => {
|
||||
let account = self.select_round_robin(&candidates)?;
|
||||
let previous = self.previous_slot_at(&account.id);
|
||||
(account, previous)
|
||||
}
|
||||
RotationStrategy::Sticky => {
|
||||
let account = self.select_sticky(&candidates)?;
|
||||
let previous = self.previous_slot_at(&account.id);
|
||||
(account, previous)
|
||||
}
|
||||
})
|
||||
});
|
||||
let Some((selected, previous_slot_at)) = selected else {
|
||||
return AccountAcquireWithStatusRefresh {
|
||||
acquired: None,
|
||||
expired_account_ids,
|
||||
};
|
||||
};
|
||||
|
||||
let selected_id = selected.id.clone();
|
||||
self.push_slot(&selected_id, request.now);
|
||||
let selected = self
|
||||
let acquired = self
|
||||
.mark_usage(&selected_id, request.now)
|
||||
.unwrap_or(selected);
|
||||
Some(AcquiredAccount {
|
||||
account: selected,
|
||||
previous_slot_at,
|
||||
})
|
||||
AccountAcquireWithStatusRefresh {
|
||||
acquired: Some(AcquiredAccount {
|
||||
account: acquired,
|
||||
previous_slot_at,
|
||||
}),
|
||||
expired_account_ids,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_account_statuses(&mut self, now: DateTime<Utc>) -> Vec<String> {
|
||||
let account_ids = self.accounts.keys().cloned().collect::<Vec<_>>();
|
||||
account_ids
|
||||
.into_iter()
|
||||
.filter(|account_id| self.refresh_account_status(account_id, now))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refresh_account_status(&mut self, account_id: &str, now: DateTime<Utc>) -> bool {
|
||||
let mut should_clear_slots = false;
|
||||
let mut expired = false;
|
||||
if let Some(account) = self.accounts.get_mut(account_id) {
|
||||
if account.status == AccountStatus::Active && access_token_expired(account, now) {
|
||||
account.status = AccountStatus::Expired;
|
||||
should_clear_slots = true;
|
||||
expired = true;
|
||||
} else {
|
||||
refresh_quota_window(account, now);
|
||||
refresh_cloudflare_cooldown(account, now);
|
||||
}
|
||||
}
|
||||
if should_clear_slots {
|
||||
self.slots.remove(account_id);
|
||||
}
|
||||
expired
|
||||
}
|
||||
|
||||
/// 释放指定账号的一个在途槽位。
|
||||
@@ -675,29 +723,6 @@ impl AccountPool {
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_account_statuses(&mut self, now: DateTime<Utc>) {
|
||||
let account_ids = self.accounts.keys().cloned().collect::<Vec<_>>();
|
||||
for account_id in account_ids {
|
||||
self.refresh_account_status(&account_id, now);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_account_status(&mut self, account_id: &str, now: DateTime<Utc>) {
|
||||
let mut should_clear_slots = false;
|
||||
if let Some(account) = self.accounts.get_mut(account_id) {
|
||||
if account.status == AccountStatus::Active && access_token_expired(account, now) {
|
||||
account.status = AccountStatus::Expired;
|
||||
should_clear_slots = true;
|
||||
} else {
|
||||
refresh_quota_window(account, now);
|
||||
refresh_cloudflare_cooldown(account, now);
|
||||
}
|
||||
}
|
||||
if should_clear_slots {
|
||||
self.slots.remove(account_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_model_allowed(&self, account: &Account, model: &str) -> bool {
|
||||
let Some(allowed_plans) = self.options.model_plan_allowlist.get(model) else {
|
||||
return true;
|
||||
@@ -830,7 +855,10 @@ impl RuntimeAccountPoolService {
|
||||
|
||||
/// 按请求上下文获取可用账号。
|
||||
pub async fn acquire_with(&self, request: AccountAcquireRequest) -> Option<AcquiredAccount> {
|
||||
let acquired = self.pool.lock().await.acquire_with(request)?;
|
||||
let refresh = self.pool.lock().await.acquire_with_status_refresh(request);
|
||||
self.persist_expired_statuses(refresh.expired_account_ids)
|
||||
.await;
|
||||
let acquired = refresh.acquired?;
|
||||
if let Err(error) = self.store.record_request(&acquired.account.id).await {
|
||||
tracing::warn!(
|
||||
account_id = acquired.account.id,
|
||||
@@ -841,6 +869,22 @@ impl RuntimeAccountPoolService {
|
||||
Some(acquired)
|
||||
}
|
||||
|
||||
async fn persist_expired_statuses(&self, account_ids: Vec<String>) {
|
||||
for account_id in account_ids {
|
||||
if let Err(error) = self
|
||||
.store
|
||||
.set_status(&account_id, AccountStatus::Expired)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
account_id,
|
||||
error = %error,
|
||||
"failed to persist runtime-expired account status"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按账号上一个在途槽位控制请求间隔。
|
||||
pub async fn wait_for_request_interval(&self, acquired: &AcquiredAccount) {
|
||||
if self.request_interval.is_zero() {
|
||||
|
||||
@@ -790,8 +790,9 @@ async fn chat_completions_should_cool_down_cloudflare_403_and_fallback() {
|
||||
cookie_store
|
||||
.cookie_header("acct_primary", "chatgpt.com")
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("cf_clearance=old")
|
||||
);
|
||||
assert_eq!(
|
||||
cookie_store
|
||||
|
||||
@@ -485,7 +485,7 @@ async fn responses_should_return_cloudflare_challenge_error_when_403_fallback_is
|
||||
.cookie_header("acct_chat", "chatgpt.com")
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
Some("cf_clearance=old".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -564,8 +564,9 @@ async fn responses_should_cool_down_cloudflare_403_and_fallback() {
|
||||
cookie_store
|
||||
.cookie_header("acct_primary", "chatgpt.com")
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("cf_clearance=old")
|
||||
);
|
||||
assert_eq!(
|
||||
cookie_store
|
||||
@@ -1607,8 +1608,9 @@ async fn responses_stream_should_cool_down_cloudflare_403_and_fallback() {
|
||||
cookie_store
|
||||
.cookie_header("acct_primary", "chatgpt.com")
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("cf_clearance=old")
|
||||
);
|
||||
assert_eq!(
|
||||
cookie_store
|
||||
|
||||
@@ -64,6 +64,50 @@ fn acquire_should_skip_accounts_with_expired_jwt_when_metadata_is_missing() {
|
||||
assert!(acquired.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_account_pool_should_persist_expired_status_when_jwt_expiry_is_discovered() {
|
||||
let (pool, _dir) =
|
||||
crate::support::sqlite::init_test_db("runtime-pool-expired-status.sqlite").await;
|
||||
let store = codex_proxy_rs::upstream::accounts::store::SqliteAccountStore::new(
|
||||
pool.clone(),
|
||||
codex_proxy_rs::infra::crypto::SecretBox::new([91u8; 32]),
|
||||
);
|
||||
store
|
||||
.insert(codex_proxy_rs::upstream::accounts::store::NewAccount {
|
||||
id: "acct_expired".to_string(),
|
||||
email: None,
|
||||
account_id: Some("chatgpt-expired".to_string()),
|
||||
user_id: None,
|
||||
label: None,
|
||||
plan_type: Some("free".to_string()),
|
||||
access_token: secrecy::SecretString::new(test_jwt(-60).into()),
|
||||
refresh_token: None,
|
||||
access_token_expires_at: None,
|
||||
status: AccountStatus::Active,
|
||||
added_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let runtime_pool = codex_proxy_rs::upstream::accounts::pool::RuntimeAccountPoolService::new(
|
||||
std::sync::Arc::new(store),
|
||||
AccountPoolOptions::default(),
|
||||
0,
|
||||
);
|
||||
runtime_pool.restore_from_repository().await.unwrap();
|
||||
|
||||
let acquired = runtime_pool
|
||||
.acquire_with(AccountAcquireRequest::new("gpt-5.5", Utc::now()))
|
||||
.await;
|
||||
let status: (String,) = sqlx::query_as("select status from accounts where id = ?")
|
||||
.bind("acct_expired")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(acquired.is_none());
|
||||
assert_eq!(status.0, "expired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_pool_should_prefer_configured_tier_priority() {
|
||||
let mut pool = AccountPool::with_options(AccountPoolOptions {
|
||||
|
||||
Reference in New Issue
Block a user