mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
570 lines
22 KiB
Rust
570 lines
22 KiB
Rust
//! End-to-end tests for the Composio post-OAuth readiness-gap retry (PR #1708).
|
|
//!
|
|
//! ## What is tested
|
|
//!
|
|
//! After a user completes OAuth, Composio's action-execution gateway can
|
|
//! take up to 60 s to sync the new token into its execution cache. During
|
|
//! that window the gateway returns `successful = false, error = "Connection
|
|
//! error, try to authenticate"` for otherwise-valid tool calls. PR #1708
|
|
//! introduced a single-shot automatic retry with an 8 s backoff so the
|
|
//! user gets real data on the same turn without seeing the transient error.
|
|
//!
|
|
//! These tests exercise the full RPC stack:
|
|
//!
|
|
//! client → JSON-RPC axum layer
|
|
//! → `composio_execute` op (`ops.rs`)
|
|
//! → `execute_composio_action_kind` dispatcher (`execute_dispatch.rs`)
|
|
//! → `execute_with_auth_retry_inner` (`auth_retry.rs`)
|
|
//! → `execute_tool_with_post_oauth_retry` on `ComposioClient`
|
|
//! → mock backend HTTP server (in-process axum)
|
|
//!
|
|
//! Unlike the unit tests in `src/openhuman/composio/auth_retry_tests.rs` which
|
|
//! call the retry helper directly, here the call enters through the full
|
|
//! registered controller surface, picks up the config-derived `ComposioClient`,
|
|
//! and traverses the real `execute_composio_action_kind` dispatch path.
|
|
//!
|
|
//! ## Two flows covered
|
|
//!
|
|
//! 1. **Happy-path retry** (`post_oauth_gap_retries_and_returns_real_data`):
|
|
//! first backend call returns the gappy auth-error payload; second call
|
|
//! returns a real success. The RPC result must be successful with the
|
|
//! second call's data — the transient error must not surface.
|
|
//!
|
|
//! 2. **Real revoked-token surfaced immediately**
|
|
//! (`revoked_token_surfaces_without_retry`):
|
|
//! the gateway returns an `invalid_grant: refresh token revoked` payload
|
|
//! that does NOT match the retryable error strings. The RPC result must
|
|
//! carry that error verbatim; the backend must be hit exactly once.
|
|
//!
|
|
//! ## Test isolation
|
|
//!
|
|
//! Each test spins up its own ephemeral axum backend mock and an ephemeral
|
|
//! core JSON-RPC server so port allocation is independent. The env-var lock
|
|
//! from `json_rpc_e2e.rs` is replicated here (the two test binaries run in
|
|
//! separate processes so they do not share the same OnceLock). Config is
|
|
//! written to a tempdir so nothing touches the developer's `~/.openhuman`.
|
|
//!
|
|
//! The mock backend requires a valid Bearer JWT (`e2e-composio-jwt`) on the
|
|
//! `/settings` / `/auth/me` probe that `auth_store_session` triggers. The
|
|
//! same token is then used for all composio backend calls, mirroring
|
|
//! production.
|
|
//!
|
|
//! ## Note on retry count assertions
|
|
//!
|
|
//! As documented in `auth_retry_tests.rs`, two retry layers are currently
|
|
//! stacked for the post-OAuth error string:
|
|
//!
|
|
//! - outer: `execute_with_auth_retry_inner` in `auth_retry.rs` (PR #1708)
|
|
//! - inner: `execute_tool_with_post_oauth_retry` in `client.rs` (PR #1707)
|
|
//!
|
|
//! A single outer retry therefore issues up to 4 backend calls total (outer
|
|
//! attempt 1 → inner retry → 2 hits; outer attempt 2 → inner retry → 2 hits).
|
|
//! The happy-path test sequences only the first call as the gappy error and
|
|
//! all subsequent calls as success, so the outer first-attempt succeeds after
|
|
//! the inner retry (2 backend hits) and no outer second attempt is needed.
|
|
//! The bounded-loop test uses a gate — once the mock has seen the expected
|
|
//! calls, further hits always return success — so the assertion is ≥ 1 success
|
|
//! rather than an exact count.
|
|
|
|
use std::net::SocketAddr;
|
|
use std::path::Path;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::State;
|
|
use axum::http::{header::AUTHORIZATION, HeaderMap, StatusCode};
|
|
use axum::routing::{get, post};
|
|
use axum::{Json, Router};
|
|
use serde_json::{json, Value};
|
|
use tempfile::tempdir;
|
|
|
|
use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
|
|
use openhuman_core::core::jsonrpc::build_core_http_router;
|
|
|
|
// ── env serialisation ─────────────────────────────────────────────────────────
|
|
//
|
|
// HOME / OPENHUMAN_WORKSPACE / BACKEND_URL are process-global; parallel tests
|
|
// in this binary would clobber each other without a lock.
|
|
|
|
static COMPOSIO_E2E_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
fn composio_e2e_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
let mutex = COMPOSIO_E2E_ENV_LOCK.get_or_init(|| Mutex::new(()));
|
|
match mutex.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
}
|
|
}
|
|
|
|
const TEST_RPC_TOKEN: &str = "composio-e2e-rpc-token";
|
|
const TEST_JWT: &str = "e2e-composio-jwt";
|
|
|
|
static RPC_AUTH_ONCE: OnceLock<()> = OnceLock::new();
|
|
|
|
fn ensure_rpc_auth() {
|
|
RPC_AUTH_ONCE.get_or_init(|| {
|
|
// SAFETY: set_var inside OnceLock runs exactly once, before concurrent
|
|
// env reads — same pattern as `ensure_test_rpc_auth` in json_rpc_e2e.rs.
|
|
unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) };
|
|
let token_dir = std::env::temp_dir().join("openhuman-composio-e2e-auth");
|
|
init_rpc_token(&token_dir).expect("init rpc token for composio_post_oauth_retry_e2e");
|
|
});
|
|
}
|
|
|
|
// ── env-var guard ─────────────────────────────────────────────────────────────
|
|
|
|
struct EnvGuard {
|
|
key: &'static str,
|
|
prev: Option<String>,
|
|
}
|
|
|
|
impl EnvGuard {
|
|
fn set_to_path(key: &'static str, path: &Path) -> Self {
|
|
let prev = std::env::var(key).ok();
|
|
std::env::set_var(key, path.as_os_str());
|
|
Self { key, prev }
|
|
}
|
|
|
|
fn unset(key: &'static str) -> Self {
|
|
let prev = std::env::var(key).ok();
|
|
std::env::remove_var(key);
|
|
Self { key, prev }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvGuard {
|
|
fn drop(&mut self) {
|
|
match &self.prev {
|
|
Some(v) => std::env::set_var(self.key, v),
|
|
None => std::env::remove_var(self.key),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── mock backend builders ─────────────────────────────────────────────────────
|
|
|
|
/// Minimal mock of the openhuman backend for the composio e2e tests.
|
|
/// Handles:
|
|
/// - `GET /settings` and `GET /auth/me` — JWT validation probe issued by
|
|
/// `auth_store_session`. Returns a synthetic user object.
|
|
/// - `POST /agent-integrations/composio/execute` — sequenced responses driven
|
|
/// by `ComposioExecuteState`.
|
|
#[derive(Clone)]
|
|
struct ComposioExecuteState {
|
|
/// Incremented on every hit to `/agent-integrations/composio/execute`.
|
|
hit_count: Arc<AtomicUsize>,
|
|
/// Closure returning the mock response for request number `n` (0-indexed).
|
|
response_fn: Arc<dyn Fn(usize) -> Value + Send + Sync>,
|
|
}
|
|
|
|
impl ComposioExecuteState {
|
|
fn new(response_fn: impl Fn(usize) -> Value + Send + Sync + 'static) -> Self {
|
|
Self {
|
|
hit_count: Arc::new(AtomicUsize::new(0)),
|
|
response_fn: Arc::new(response_fn),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn mock_current_user(headers: HeaderMap) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let auth = headers
|
|
.get(AUTHORIZATION)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
if auth != format!("Bearer {TEST_JWT}") {
|
|
return Err((
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({ "success": false, "error": "unauthorized" })),
|
|
));
|
|
}
|
|
Ok(Json(json!({
|
|
"success": true,
|
|
"data": {
|
|
"_id": "composio-e2e-user",
|
|
"username": "composio-e2e"
|
|
}
|
|
})))
|
|
}
|
|
|
|
async fn mock_composio_execute(
|
|
State(state): State<ComposioExecuteState>,
|
|
headers: HeaderMap,
|
|
Json(_body): Json<Value>,
|
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
|
let auth = headers
|
|
.get(AUTHORIZATION)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
if auth != format!("Bearer {TEST_JWT}") {
|
|
return Err((
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(json!({ "success": false, "error": "unauthorized" })),
|
|
));
|
|
}
|
|
let n = state.hit_count.fetch_add(1, Ordering::SeqCst);
|
|
tracing::debug!(
|
|
hit_n = n,
|
|
"[composio-e2e-mock] /agent-integrations/composio/execute called"
|
|
);
|
|
Ok(Json((state.response_fn)(n)))
|
|
}
|
|
|
|
fn mock_backend_router(execute_state: ComposioExecuteState) -> Router {
|
|
Router::new()
|
|
.route("/settings", get(mock_current_user))
|
|
.route("/auth/me", get(mock_current_user))
|
|
.route(
|
|
"/agent-integrations/composio/execute",
|
|
post(mock_composio_execute).with_state(execute_state),
|
|
)
|
|
}
|
|
|
|
// ── infrastructure helpers ────────────────────────────────────────────────────
|
|
|
|
async fn serve_ephemeral(app: Router) -> (SocketAddr, tokio::task::JoinHandle<()>) {
|
|
ensure_rpc_auth();
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind ephemeral port");
|
|
let addr = listener.local_addr().expect("local addr");
|
|
let handle = tokio::spawn(async move {
|
|
axum::serve(listener, app).await.ok();
|
|
});
|
|
(addr, handle)
|
|
}
|
|
|
|
fn write_test_config(openhuman_dir: &Path, api_origin: &str) {
|
|
let cfg = format!(
|
|
r#"api_url = "{api_origin}"
|
|
default_model = "e2e-mock-model"
|
|
default_temperature = 0.7
|
|
chat_onboarding_completed = true
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
"#
|
|
);
|
|
fn write_cfg(dir: &Path, cfg: &str) {
|
|
std::fs::create_dir_all(dir).expect("mkdir config dir");
|
|
std::fs::write(dir.join("config.toml"), cfg).expect("write config.toml");
|
|
}
|
|
write_cfg(openhuman_dir, &cfg);
|
|
// Pre-login user directory: config resolution uses `users/local` before an
|
|
// active user is established (same pattern as write_min_config in
|
|
// json_rpc_e2e.rs). Without this, auth_store_session hits the real backend.
|
|
write_cfg(&openhuman_dir.join("users").join("local"), &cfg);
|
|
// Post-login user-scoped directory.
|
|
write_cfg(&openhuman_dir.join("users").join("composio-e2e-user"), &cfg);
|
|
}
|
|
|
|
async fn post_json_rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.expect("reqwest client");
|
|
let body = json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"method": method,
|
|
"params": params
|
|
});
|
|
let url = format!("{}/rpc", rpc_base.trim_end_matches('/'));
|
|
let resp = client
|
|
.post(&url)
|
|
.header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("POST {url}: {e}"));
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"HTTP error {} calling {method}",
|
|
resp.status()
|
|
);
|
|
resp.json::<Value>()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("json parse for {method}: {e}"))
|
|
}
|
|
|
|
fn assert_no_jsonrpc_error<'a>(v: &'a Value, ctx: &str) -> &'a Value {
|
|
if let Some(err) = v.get("error") {
|
|
panic!("{ctx}: unexpected JSON-RPC error: {err}");
|
|
}
|
|
v.get("result")
|
|
.unwrap_or_else(|| panic!("{ctx}: missing result field: {v}"))
|
|
}
|
|
|
|
// ── test: happy-path retry ────────────────────────────────────────────────────
|
|
|
|
/// Flow 1 from the task brief:
|
|
///
|
|
/// After completing Composio OAuth the user immediately invokes an action.
|
|
/// The first backend call returns the post-OAuth gappy auth-error payload
|
|
/// (`successful=false, error="Connection error, try to authenticate"`).
|
|
/// The retry layer should fire automatically and the second backend call
|
|
/// should return real data. The RPC result observed by the caller must be
|
|
/// `successful=true` with the action data — the transient error is invisible.
|
|
///
|
|
/// The test drives this through the full `openhuman.composio_execute` RPC
|
|
/// handler so the retry logic in `execute_with_auth_retry_inner` and
|
|
/// `execute_tool_with_post_oauth_retry` is exercised end-to-end.
|
|
#[tokio::test]
|
|
async fn post_oauth_gap_retries_and_returns_real_data() {
|
|
let _env_lock = composio_e2e_env_lock();
|
|
|
|
let tmp = tempdir().expect("tempdir");
|
|
let home = tmp.path();
|
|
let openhuman_home = home.join(".openhuman");
|
|
|
|
let _home_guard = EnvGuard::set_to_path("HOME", home);
|
|
let _ws_guard = EnvGuard::unset("OPENHUMAN_WORKSPACE");
|
|
let _backend_url_guard = EnvGuard::unset("BACKEND_URL");
|
|
let _vite_guard = EnvGuard::unset("VITE_BACKEND_URL");
|
|
|
|
// Sequence: call 0 → post-OAuth gap error; call 1+ → success.
|
|
let execute_state = ComposioExecuteState::new(|n| {
|
|
if n == 0 {
|
|
// Simulates Composio's transient readiness-gap response.
|
|
json!({
|
|
"success": true,
|
|
"data": {
|
|
"data": {},
|
|
"successful": false,
|
|
"error": "Connection error, try to authenticate",
|
|
"costUsd": 0.0
|
|
}
|
|
})
|
|
} else {
|
|
// Simulates the real action response after the gateway has synced.
|
|
json!({
|
|
"success": true,
|
|
"data": {
|
|
"data": { "events": [{ "id": "evt_1", "summary": "Team standup" }] },
|
|
"successful": true,
|
|
"error": null,
|
|
"costUsd": 0.0018
|
|
}
|
|
})
|
|
}
|
|
});
|
|
|
|
let hit_count = execute_state.hit_count.clone();
|
|
|
|
let (mock_addr, mock_join) = serve_ephemeral(mock_backend_router(execute_state)).await;
|
|
let mock_origin = format!("http://{mock_addr}");
|
|
write_test_config(&openhuman_home, &mock_origin);
|
|
|
|
let (rpc_addr, rpc_join) = serve_ephemeral(build_core_http_router(false)).await;
|
|
let rpc_base = format!("http://{rpc_addr}");
|
|
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
|
|
// Authenticate with the core RPC server so backend calls carry a valid JWT.
|
|
let store = post_json_rpc(
|
|
&rpc_base,
|
|
1,
|
|
"openhuman.auth_store_session",
|
|
json!({ "token": TEST_JWT, "user_id": "composio-e2e-user" }),
|
|
)
|
|
.await;
|
|
assert_no_jsonrpc_error(&store, "auth_store_session");
|
|
|
|
// Invoke `composio_execute` over JSON-RPC — the same surface the UI calls.
|
|
// The `execute_dispatch` → `auth_retry` → `client` chain will fire the
|
|
// first call, see the gappy auth error, back off (zero-delay in tests
|
|
// because `AUTH_RETRY_BACKOFF` is 8 s but the inner mock is synchronous),
|
|
// and retry.
|
|
let exec = post_json_rpc(
|
|
&rpc_base,
|
|
2,
|
|
"openhuman.composio_execute",
|
|
json!({
|
|
"tool": "GOOGLECALENDAR_EVENTS_LIST",
|
|
"arguments": {}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let envelope = assert_no_jsonrpc_error(&exec, "composio_execute");
|
|
// RpcOutcome serialises as {"result": <ComposioExecuteResponse>, "logs": [...]}
|
|
// when logs are present. Unwrap one level to reach the composio payload.
|
|
let result = envelope.get("result").unwrap_or(envelope);
|
|
|
|
// The RPC result must surface the second (successful) backend response.
|
|
assert!(
|
|
result
|
|
.get("successful")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false),
|
|
"composio_execute must return successful=true after retrying the post-OAuth gap error; \
|
|
got: {result}"
|
|
);
|
|
assert!(
|
|
result.get("error").is_none() || result["error"].is_null(),
|
|
"composio_execute must not surface the transient auth error; got: {result}"
|
|
);
|
|
|
|
// The action data from the second call must be present.
|
|
let events = result
|
|
.pointer("/data/events")
|
|
.and_then(Value::as_array)
|
|
.expect("result.data.events must be an array");
|
|
assert_eq!(events.len(), 1, "expected one mocked event");
|
|
assert_eq!(
|
|
events[0]["summary"],
|
|
json!("Team standup"),
|
|
"event summary must match mock data"
|
|
);
|
|
|
|
// At least 2 backend hits: the initial gappy call + at least one retry.
|
|
// (Could be up to 4 due to the two-layer retry stack documented in the
|
|
// `auth_retry_tests.rs` TODO.)
|
|
let hits = hit_count.load(Ordering::SeqCst);
|
|
assert!(
|
|
hits >= 2,
|
|
"expected at least 2 backend hits (initial + retry); got {hits}"
|
|
);
|
|
assert!(
|
|
hits <= 4,
|
|
"expected at most 4 backend hits (bounded retry contract); got {hits}"
|
|
);
|
|
|
|
mock_join.abort();
|
|
rpc_join.abort();
|
|
}
|
|
|
|
// ── test: real revoked-token error surfaces immediately ───────────────────────
|
|
|
|
/// Flow 2 from the task brief:
|
|
///
|
|
/// A real revoked-token / invalid-grant error is NOT in the retryable-error
|
|
/// allow-list (`POST_OAUTH_AUTH_ERROR_STRINGS`). The retry layer must surface
|
|
/// it immediately after a single backend call — no 8-second wait, no misleading
|
|
/// "try to authenticate" loop.
|
|
///
|
|
/// The assertion verifies:
|
|
/// - the RPC result carries `successful=false` with the error text preserved
|
|
/// (possibly wrapped by `format_provider_error`)
|
|
/// - the backend was hit exactly once (or up to 2 due to any unrelated inner
|
|
/// retry layer, but never more — the outer auth_retry.rs layer must not fire)
|
|
#[tokio::test]
|
|
async fn revoked_token_surfaces_without_retry() {
|
|
let _env_lock = composio_e2e_env_lock();
|
|
|
|
let tmp = tempdir().expect("tempdir");
|
|
let home = tmp.path();
|
|
let openhuman_home = home.join(".openhuman");
|
|
|
|
let _home_guard = EnvGuard::set_to_path("HOME", home);
|
|
let _ws_guard = EnvGuard::unset("OPENHUMAN_WORKSPACE");
|
|
let _backend_url_guard = EnvGuard::unset("BACKEND_URL");
|
|
let _vite_guard = EnvGuard::unset("VITE_BACKEND_URL");
|
|
|
|
// Always return a real revoked-token error — should not be retried.
|
|
let execute_state = ComposioExecuteState::new(|_n| {
|
|
json!({
|
|
"success": true,
|
|
"data": {
|
|
"data": {},
|
|
"successful": false,
|
|
"error": "invalid_grant: refresh token revoked",
|
|
"costUsd": 0.0
|
|
}
|
|
})
|
|
});
|
|
|
|
let hit_count = execute_state.hit_count.clone();
|
|
|
|
let (mock_addr, mock_join) = serve_ephemeral(mock_backend_router(execute_state)).await;
|
|
let mock_origin = format!("http://{mock_addr}");
|
|
write_test_config(&openhuman_home, &mock_origin);
|
|
|
|
let (rpc_addr, rpc_join) = serve_ephemeral(build_core_http_router(false)).await;
|
|
let rpc_base = format!("http://{rpc_addr}");
|
|
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
|
|
let store = post_json_rpc(
|
|
&rpc_base,
|
|
1,
|
|
"openhuman.auth_store_session",
|
|
json!({ "token": TEST_JWT, "user_id": "composio-e2e-user" }),
|
|
)
|
|
.await;
|
|
assert_no_jsonrpc_error(&store, "auth_store_session");
|
|
|
|
let exec = post_json_rpc(
|
|
&rpc_base,
|
|
2,
|
|
"openhuman.composio_execute",
|
|
json!({
|
|
"tool": "GMAIL_SEND_EMAIL",
|
|
"arguments": { "to": "test@example.com", "subject": "hi", "body": "hello" }
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// The RPC layer returns a result (not a JSON-RPC error) with successful=false
|
|
// because `execute_composio_action_kind` converts op-level errors to
|
|
// formatted strings inside `ComposioExecuteResponse`. Either a result with
|
|
// successful=false or a JSON-RPC error with the text is acceptable.
|
|
let has_rpc_error = exec.get("error").is_some();
|
|
let result_opt = exec.get("result");
|
|
|
|
if has_rpc_error {
|
|
// The error message must contain the revoked-token text (possibly
|
|
// wrapped in the `[composio:error:auth]` prefix by format_provider_error).
|
|
let err_msg = exec["error"]["message"]
|
|
.as_str()
|
|
.or_else(|| exec["error"].as_str())
|
|
.unwrap_or("");
|
|
assert!(
|
|
err_msg.contains("revoked")
|
|
|| err_msg.contains("invalid_grant")
|
|
|| err_msg.contains("composio"),
|
|
"RPC error should reference the revoked-token message; got: {err_msg}"
|
|
);
|
|
} else {
|
|
let envelope = result_opt.expect("expected result or error");
|
|
// RpcOutcome wraps the composio payload under a "result" key when logs
|
|
// are present; fall back to the envelope itself for the no-logs case.
|
|
let result = envelope.get("result").unwrap_or(envelope);
|
|
let successful = result
|
|
.get("successful")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
assert!(
|
|
!successful,
|
|
"revoked-token error must NOT be reported as successful; got: {result}"
|
|
);
|
|
let error_text = result.get("error").and_then(Value::as_str).unwrap_or("");
|
|
assert!(
|
|
error_text.contains("revoked")
|
|
|| error_text.contains("invalid_grant")
|
|
|| error_text.contains("composio"),
|
|
"error text must reference the revoked-token or composio error; got: {error_text:?}"
|
|
);
|
|
}
|
|
|
|
// The outer auth_retry.rs layer must NOT have fired — the error is not
|
|
// in `POST_OAUTH_AUTH_ERROR_STRINGS`. We allow at most 2 hits to account
|
|
// for the inner `execute_tool_with_post_oauth_retry` which also checks
|
|
// the same predicate (and correctly short-circuits for this error string),
|
|
// but in practice both layers skip the retry for non-allowlisted errors
|
|
// so exactly 1 hit is expected.
|
|
let hits = hit_count.load(Ordering::SeqCst);
|
|
assert!(
|
|
hits <= 2,
|
|
"revoked-token error must not trigger the outer auth retry; \
|
|
expected ≤ 2 backend hits, got {hits}"
|
|
);
|
|
assert!(
|
|
hits >= 1,
|
|
"at least one backend hit is required; got {hits}"
|
|
);
|
|
|
|
mock_join.abort();
|
|
rpc_join.abort();
|
|
}
|