composio: instagram oauth fails with http 429 in composio integration (#1952) (#2259)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
CodeGhost21
2026-05-22 16:01:12 +05:30
committed by GitHub
co-authored by Cursor
parent 607a6a1db5
commit b2f053f5e1
11 changed files with 552 additions and 17 deletions
@@ -446,6 +446,22 @@ describe('<ComposioConnectModal> — needs-subdomain recovery phase', () => {
});
});
it('surfaces Meta rate-limit guidance for Instagram authorize failures', async () => {
const instagramToolkit = composioToolkitMeta('instagram');
vi.mocked(authorize).mockRejectedValueOnce(
new Error('Authorization failed: Backend returned 429 Too Many Requests')
);
render(<ComposioConnectModal toolkit={instagramToolkit} onClose={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: /Connect Instagram/i }));
await waitFor(() => {
expect(screen.getByText(/Business or Creator account/i)).toBeInTheDocument();
expect(screen.getByText(/HTTP 429/i)).toBeInTheDocument();
expect(screen.queryByText(/api.tinyhumans.ai/i)).not.toBeInTheDocument();
});
});
it('surfaces a sanitized (non-raw) error for unrelated authorization failures', async () => {
vi.mocked(authorize).mockRejectedValueOnce(
new Error(
@@ -33,6 +33,11 @@ import {
listConnections,
setUserScopes,
} from '../../lib/composio/composioApi';
import {
isMetaOAuthToolkit,
isOAuthRateLimitedError,
metaOAuthRateLimitMessage,
} from '../../lib/composio/oauthHandoff';
import {
type ComposioConnection,
type ComposioUserScopePref,
@@ -172,6 +177,8 @@ export default function ComposioConnectModal({
const pollDeadlineRef = useRef<number>(0);
const isPollingRef = useRef<boolean>(false);
const inFlightRef = useRef<boolean>(false);
const connectInFlightRef = useRef<boolean>(false);
const [connectInFlight, setConnectInFlight] = useState(false);
const initialState = deriveComposioState(connection);
const initiallyConnected = initialState === 'connected';
@@ -300,7 +307,7 @@ export default function ComposioConnectModal({
// Fire once immediately, then recurse via setTimeout once the previous
// tick resolves. Avoids overlapping async ticks entirely.
void tick();
}, [onChanged, stopPolling, toolkit.slug]);
}, [onChanged, stopPolling, t, toolkit.slug]);
// If the modal opens while an OAuth handoff is already in flight
// (status = PENDING/INITIATED/…), resume polling instead of asking
@@ -327,8 +334,17 @@ export default function ComposioConnectModal({
}, [requiredFields, fieldValues]);
const handleConnect = useCallback(async () => {
if (connectInFlightRef.current) {
console.debug(
'[composio][authorize] ignored duplicate Connect click toolkit=%s',
toolkit.slug
);
return;
}
if (!validateRequiredFields()) return;
connectInFlightRef.current = true;
setConnectInFlight(true);
setPhase('authorizing');
setError(null);
setFieldErrors({});
@@ -392,9 +408,24 @@ export default function ComposioConnectModal({
}
setPhase('error');
setError(sanitizeAuthError(err));
if (isMetaOAuthToolkit(toolkit.slug) && isOAuthRateLimitedError(err)) {
setError(metaOAuthRateLimitMessage(toolkit.name));
} else {
setError(sanitizeAuthError(err));
}
} finally {
connectInFlightRef.current = false;
setConnectInFlight(false);
}
}, [validateRequiredFields, requiredFields, fieldValues, startPolling, toolkit.slug]);
}, [
validateRequiredFields,
requiredFields,
fieldValues,
startPolling,
toolkit.slug,
toolkit.name,
t,
]);
// Fetch the stored scope pref whenever the modal lands in the
// 'connected' phase. Re-fetching each time we transition (rather
@@ -417,7 +448,7 @@ export default function ComposioConnectModal({
return () => {
cancelled = true;
};
}, [phase, toolkit.slug]);
}, [phase, t, toolkit.slug]);
const handleToggleScope = useCallback(
async (key: keyof ComposioUserScopePref) => {
@@ -465,7 +496,7 @@ export default function ComposioConnectModal({
setSavingScope(null);
}
},
[savingScope, scopes, toolkit.slug]
[savingScope, scopes, t, toolkit.slug]
);
const handleDisconnect = useCallback(async () => {
@@ -482,7 +513,7 @@ export default function ComposioConnectModal({
setPhase('error');
setError(`${t('composio.connect.disconnectFailed')}: ${msg}`);
}
}, [activeConnection, onChanged]);
}, [activeConnection, onChanged, t]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
@@ -577,8 +608,9 @@ export default function ComposioConnectModal({
{error && phase === 'idle' && <p className="text-[11px] text-coral-600">{error}</p>}
<button
type="button"
disabled={connectInFlight}
onClick={() => void handleConnect()}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
{`${t('composio.connect.connect')} ${toolkit.name}`}
</button>
</>
@@ -607,8 +639,9 @@ export default function ComposioConnectModal({
/>
<button
type="button"
disabled={connectInFlight}
onClick={() => void handleConnect()}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
{t('composio.connect.retryConnection')}
</button>
<button
@@ -664,8 +697,9 @@ export default function ComposioConnectModal({
</div>
<button
type="button"
disabled={connectInFlight}
onClick={() => void handleConnect()}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60 disabled:cursor-not-allowed">
Reconnect {toolkit.name}
</button>
</>
@@ -24,6 +24,12 @@ describe('composioToolkitMeta', () => {
expect(calendar.logoUrl).toContain('/googlecalendar');
});
it('documents Instagram Business account requirement and Meta 429 guidance', () => {
const meta = composioToolkitMeta('instagram');
expect(meta.description).toMatch(/Business or Creator/i);
expect(meta.description).toMatch(/429/i);
});
it('falls back cleanly for unknown slugs', () => {
const meta = composioToolkitMeta('my_custom_toolkit');
+11 -1
View File
@@ -322,6 +322,16 @@ export const KNOWN_COMPOSIO_TOOLKITS = Object.freeze(
MANAGED_COMPOSIO_TOOLKITS.map(entry => entry.slug)
);
function descriptionForToolkit(key: string, name: string, category: SkillCategory): string {
if (key === 'instagram') {
return (
'Connect Instagram Business or Creator accounts (personal accounts are not supported). ' +
'If Meta shows “Too Many Requests” (HTTP 429), wait a few minutes before retrying.'
);
}
return defaultDescription(name, category);
}
export function composioToolkitMeta(slug: string): ComposioToolkitMeta {
const key = canonicalizeComposioToolkitSlug(slug);
const name = MANAGED_TOOLKIT_NAME_BY_SLUG.get(key) ?? prettifyUnknownSlug(key);
@@ -329,7 +339,7 @@ export function composioToolkitMeta(slug: string): ComposioToolkitMeta {
return {
slug: key,
name,
description: defaultDescription(name, category),
description: descriptionForToolkit(key, name, category),
category,
icon: <ComposioLogoBadge slug={key} name={name} />,
logoUrl: composioLogoUrl(key),
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import {
isMetaOAuthToolkit,
isOAuthRateLimitedError,
metaOAuthRateLimitMessage,
} from './oauthHandoff';
describe('oauthHandoff', () => {
it('detects Meta OAuth toolkits', () => {
expect(isMetaOAuthToolkit('instagram')).toBe(true);
expect(isMetaOAuthToolkit('Facebook')).toBe(true);
expect(isMetaOAuthToolkit('gmail')).toBe(false);
});
it('detects OAuth rate-limit errors', () => {
expect(isOAuthRateLimitedError(new Error('HTTP 429 Too Many Requests'))).toBe(true);
expect(isOAuthRateLimitedError({ message: 'HTTP 429 Too Many Requests' })).toBe(true);
expect(isOAuthRateLimitedError(new Error('rate_limit exceeded'))).toBe(true);
expect(isOAuthRateLimitedError(new Error('401 Unauthorized'))).toBe(false);
});
it('builds Instagram-specific Meta rate-limit guidance', () => {
const msg = metaOAuthRateLimitMessage('Instagram');
expect(msg).toContain('429');
expect(msg.toLowerCase()).toContain('business');
});
it('builds Facebook-specific Meta rate-limit guidance without Instagram account copy', () => {
const msg = metaOAuthRateLimitMessage('Facebook');
expect(msg).toContain('429');
expect(msg).toContain('Facebook');
expect(msg).toContain('Business Manager');
expect(msg).not.toContain('Instagram Business or Creator');
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* OAuth handoff helpers for Meta-owned Composio toolkits (#1952).
*
* Instagram and Facebook share Meta's OAuth rate limits. The UI uses these
* helpers to detect rate-limit failures and avoid duplicate authorize calls.
*/
/** Toolkits whose OAuth flows are hosted by Meta. */
export const META_OAUTH_TOOLKITS = ['instagram', 'facebook'] as const;
export type MetaOAuthToolkit = (typeof META_OAUTH_TOOLKITS)[number];
export function isMetaOAuthToolkit(slug: string): slug is MetaOAuthToolkit {
const key = slug.trim().toLowerCase();
return (META_OAUTH_TOOLKITS as readonly string[]).includes(key);
}
/** True when an error message looks like an OAuth / Meta rate limit (HTTP 429). */
export function isOAuthRateLimitedError(err: unknown): boolean {
if (!err) return false;
const msg =
err instanceof Error
? err.message
: typeof err === 'object' && err !== null && 'message' in err
? String((err as { message?: unknown }).message ?? '')
: String(err);
const lower = msg.toLowerCase();
return (
lower.includes('429') ||
lower.includes('too many requests') ||
lower.includes('rate limit') ||
lower.includes('rate_limit') ||
lower.includes('ratelimited')
);
}
/** User-facing copy when Meta OAuth is rate-limited. */
export function metaOAuthRateLimitMessage(toolkitName: string): string {
const normalizedName = toolkitName.trim().toLowerCase();
const accountHint =
normalizedName === 'instagram'
? ' Use an Instagram Business or Creator account — personal accounts are not supported.'
: normalizedName === 'facebook'
? ' Confirm the Facebook account has access to the relevant Page or Business Manager.'
: '';
return (
`Meta is temporarily rate-limiting ${toolkitName} sign-in (HTTP 429). ` +
`Wait a few minutes before retrying and avoid clicking Connect repeatedly.${accountHint}`
);
}
+1
View File
@@ -43,6 +43,7 @@ pub mod error_mapping;
pub mod execute_dispatch;
pub mod execute_prepare;
pub mod googlecalendar_args;
pub mod oauth_handoff;
pub mod ops;
pub mod periodic;
pub mod providers;
+190
View File
@@ -0,0 +1,190 @@
//! OAuth handoff helpers — Meta (Instagram / Facebook) rate-limit mitigations (#1952).
//!
//! Meta's OAuth authorize endpoint returns HTTP 429 when too many OAuth sessions
//! are created in a short window. That often happens when a user retries after a
//! failed handoff or clicks Connect multiple times, leaving several `PENDING`
//! Composio rows that each redirect through Meta. Before starting a new handoff
//! for Meta-owned toolkits we clear prior non-active connection rows and apply
//! a small backoff retry when the backend reports a 429-shaped failure.
use std::time::Duration;
use super::client::{direct_authorize, ComposioClient};
use super::types::ComposioAuthorizeResponse;
/// Toolkits whose OAuth flows are hosted by Meta and share the same rate limits.
pub const META_OAUTH_TOOLKITS: &[&str] = &["instagram", "facebook"];
const AUTHORIZE_RATE_LIMIT_MAX_ATTEMPTS: u32 = 3;
const AUTHORIZE_RATE_LIMIT_INITIAL_BACKOFF: Duration = Duration::from_secs(5);
const AUTHORIZE_RATE_LIMIT_MAX_BACKOFF: Duration = Duration::from_secs(60);
/// Return true when `toolkit` uses Meta-hosted OAuth (Instagram / Facebook).
pub fn is_meta_oauth_toolkit(toolkit: &str) -> bool {
let key = toolkit.trim().to_ascii_lowercase();
META_OAUTH_TOOLKITS.contains(&key.as_str())
}
/// Status values that mean an OAuth handoff is still in flight.
pub fn is_inflight_oauth_status(status: &str) -> bool {
matches!(
status.trim().to_ascii_uppercase().as_str(),
"PENDING" | "INITIATED" | "INITIALIZING"
)
}
/// Non-active rows safe to delete before starting a fresh Meta OAuth handoff.
pub fn is_clearable_oauth_status(status: &str) -> bool {
let upper = status.trim().to_ascii_uppercase();
is_inflight_oauth_status(status) || matches!(upper.as_str(), "FAILED" | "ERROR" | "EXPIRED")
}
/// Detect authorize-path failures that look like upstream rate limiting.
pub fn is_authorize_rate_limited(err: &str) -> bool {
let lower = err.to_ascii_lowercase();
lower.contains("429")
|| lower.contains("too many requests")
|| lower.contains("rate limit")
|| lower.contains("rate_limit")
|| lower.contains("ratelimited")
}
/// User-facing hint when Meta OAuth is rate-limited.
pub fn meta_oauth_rate_limit_message(toolkit: &str) -> String {
let name = toolkit.trim();
let account_hint = if name.eq_ignore_ascii_case("instagram") {
" Use an Instagram Business or Creator account — personal accounts are not supported."
} else if name.eq_ignore_ascii_case("facebook") {
" Confirm the Facebook account has access to the relevant Page or Business Manager."
} else {
""
};
format!(
"Meta is temporarily rate-limiting {name} sign-in (HTTP 429). Wait a few \
minutes before retrying and avoid clicking Connect repeatedly.{account_hint}"
)
}
/// If `err` is a Meta-toolkit authorize rate limit, replace it with guidance.
pub fn wrap_authorize_rate_limit_error(toolkit: &str, err: anyhow::Error) -> anyhow::Error {
let rendered = format!("{err:#}");
if is_meta_oauth_toolkit(toolkit) && is_authorize_rate_limited(&rendered) {
anyhow::anyhow!("{}", meta_oauth_rate_limit_message(toolkit))
} else {
err
}
}
/// Remove non-active connection rows for `toolkit` so a fresh OAuth handoff does
/// not accumulate Meta sessions (#1952).
pub async fn clear_non_active_connections(
client: &ComposioClient,
toolkit: &str,
) -> anyhow::Result<u32> {
if !is_meta_oauth_toolkit(toolkit) {
return Ok(0);
}
let toolkit_key = toolkit.trim().to_ascii_lowercase();
let resp = client.list_connections().await?;
let mut cleared = 0u32;
for conn in resp.connections {
if conn.normalized_toolkit() != toolkit_key {
continue;
}
if conn.is_active() || !is_clearable_oauth_status(&conn.status) {
continue;
}
tracing::info!(
toolkit = %toolkit_key,
connection_id = %conn.id,
status = %conn.status,
"[composio][oauth] clearing stale non-active connection before Meta OAuth handoff (#1952)"
);
match client.delete_connection(&conn.id).await {
Ok(_) => cleared += 1,
Err(e) => {
tracing::warn!(
toolkit = %toolkit_key,
connection_id = %conn.id,
error = %e,
"[composio][oauth] failed to clear stale connection (non-fatal)"
);
}
}
}
Ok(cleared)
}
/// Begin a backend-proxied OAuth handoff with Meta cleanup + 429 backoff.
pub async fn authorize_with_meta_guard(
client: &ComposioClient,
toolkit: &str,
extra_params: Option<serde_json::Value>,
) -> anyhow::Result<ComposioAuthorizeResponse> {
let cleared = match clear_non_active_connections(client, toolkit).await {
Ok(cleared) => cleared,
Err(e) => {
tracing::warn!(
toolkit = %toolkit,
error = %e,
"[composio][oauth] pre-handoff cleanup failed; continuing authorize"
);
0
}
};
tracing::debug!(
toolkit = %toolkit,
cleared,
is_meta = is_meta_oauth_toolkit(toolkit),
"[composio][oauth] authorize_with_meta_guard: pre-handoff cleanup"
);
authorize_with_rate_limit_retry(|| client.authorize(toolkit, extra_params.clone())).await
}
/// Direct-mode authorize with the same 429 backoff used for Meta toolkits.
pub async fn direct_authorize_with_meta_guard(
direct: &std::sync::Arc<crate::openhuman::tools::ComposioTool>,
toolkit: &str,
entity_id: &str,
) -> anyhow::Result<ComposioAuthorizeResponse> {
authorize_with_rate_limit_retry(|| direct_authorize(direct, toolkit, entity_id)).await
}
async fn authorize_with_rate_limit_retry<F, Fut>(
mut attempt_authorize: F,
) -> anyhow::Result<ComposioAuthorizeResponse>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = anyhow::Result<ComposioAuthorizeResponse>>,
{
let mut delay = AUTHORIZE_RATE_LIMIT_INITIAL_BACKOFF;
let mut last_err: Option<anyhow::Error> = None;
for attempt in 1..=AUTHORIZE_RATE_LIMIT_MAX_ATTEMPTS {
match attempt_authorize().await {
Ok(resp) => return Ok(resp),
Err(e) => {
let rendered = format!("{e:#}");
if is_authorize_rate_limited(&rendered)
&& attempt < AUTHORIZE_RATE_LIMIT_MAX_ATTEMPTS
{
tracing::warn!(
attempt,
max_attempts = AUTHORIZE_RATE_LIMIT_MAX_ATTEMPTS,
sleep_secs = delay.as_secs(),
"[composio][oauth] authorize rate-limited; backing off (#1952)"
);
tokio::time::sleep(delay).await;
delay = (delay * 2).min(AUTHORIZE_RATE_LIMIT_MAX_BACKOFF);
last_err = Some(e);
continue;
}
return Err(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("authorize failed after retries")))
}
#[cfg(test)]
#[path = "oauth_handoff_tests.rs"]
mod tests;
@@ -0,0 +1,128 @@
//! Tests for Meta OAuth handoff helpers (#1952).
use super::{
authorize_with_meta_guard, is_authorize_rate_limited, is_clearable_oauth_status,
is_inflight_oauth_status, is_meta_oauth_toolkit, meta_oauth_rate_limit_message,
wrap_authorize_rate_limit_error,
};
use axum::{
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde_json::{json, Value};
use std::sync::Arc;
use super::ComposioClient;
async fn start_mock_backend(app: Router) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://127.0.0.1:{}", addr.port())
}
fn build_client_for(base_url: String) -> ComposioClient {
let inner = Arc::new(crate::openhuman::integrations::IntegrationClient::new(
base_url,
"test-token".into(),
));
ComposioClient::new(inner)
}
#[test]
fn meta_oauth_toolkit_detection() {
assert!(is_meta_oauth_toolkit("instagram"));
assert!(is_meta_oauth_toolkit("Facebook"));
assert!(!is_meta_oauth_toolkit("gmail"));
}
#[test]
fn inflight_and_clearable_statuses() {
assert!(is_inflight_oauth_status("pending"));
assert!(is_inflight_oauth_status("INITIATED"));
assert!(!is_inflight_oauth_status("ACTIVE"));
assert!(is_clearable_oauth_status("FAILED"));
assert!(is_clearable_oauth_status("EXPIRED"));
assert!(!is_clearable_oauth_status("ACTIVE"));
}
#[test]
fn authorize_rate_limit_shape_detection() {
assert!(is_authorize_rate_limited(
"Backend returned 429 Too Many Requests"
));
assert!(is_authorize_rate_limited("rate_limit exceeded"));
assert!(!is_authorize_rate_limited("401 Unauthorized"));
}
#[test]
fn wrap_authorize_rate_limit_error_replaces_meta_toolkit_message() {
let err = anyhow::anyhow!("Backend returned 429 Too Many Requests");
let wrapped = wrap_authorize_rate_limit_error("instagram", err);
let msg = format!("{wrapped:#}");
assert!(msg.contains("Business or Creator"));
assert!(msg.contains("429"));
}
#[test]
fn wrap_authorize_rate_limit_error_passthrough_for_non_meta() {
let err = anyhow::anyhow!("Backend returned 429 Too Many Requests");
let wrapped = wrap_authorize_rate_limit_error("gmail", err);
assert!(format!("{wrapped:#}").contains("Backend returned 429"));
}
#[test]
fn meta_oauth_rate_limit_message_mentions_business_account() {
let msg = meta_oauth_rate_limit_message("instagram");
assert!(msg.to_ascii_lowercase().contains("business"));
}
#[test]
fn meta_oauth_rate_limit_message_uses_facebook_specific_guidance() {
let msg = meta_oauth_rate_limit_message("facebook");
assert!(msg.contains("Facebook"));
assert!(msg.contains("Business Manager"));
assert!(!msg.contains("Instagram Business or Creator"));
}
#[tokio::test]
async fn authorize_continues_when_pre_handoff_cleanup_fails() {
let app = Router::new()
.route(
"/agent-integrations/composio/connections",
get(|| async {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"success": false,
"error": "temporary list failure"
})),
)
}),
)
.route(
"/agent-integrations/composio/authorize",
post(|Json(body): Json<Value>| async move {
assert_eq!(body["toolkit"].as_str(), Some("instagram"));
Json(json!({
"success": true,
"data": {
"connectUrl": "https://composio.example/instagram/consent",
"connectionId": "conn-instagram"
}
}))
}),
);
let client = build_client_for(start_mock_backend(app).await);
let resp = authorize_with_meta_guard(&client, "instagram", None)
.await
.expect("authorize should continue when cleanup is unavailable");
assert_eq!(resp.connection_id, "conn-instagram");
assert!(resp.connect_url.contains("instagram"));
}
+17 -7
View File
@@ -300,10 +300,13 @@ pub async fn composio_authorize(
let resp = match kind {
ComposioClientKind::Backend(client) => {
tracing::debug!(toolkit = %toolkit, "[composio] authorize: backend variant");
client.authorize(toolkit, extra_params).await.map_err(|e| {
report_composio_op_error("authorize", &e);
format!("[composio] authorize failed: {e:#}")
})?
super::oauth_handoff::authorize_with_meta_guard(&client, toolkit, extra_params)
.await
.map_err(|e| {
report_composio_op_error("authorize", &e);
let wrapped = super::oauth_handoff::wrap_authorize_rate_limit_error(toolkit, e);
format!("[composio] authorize failed: {wrapped:#}")
})?
}
ComposioClientKind::Direct(direct) => {
tracing::info!(
@@ -327,9 +330,16 @@ pub async fn composio_authorize(
app.composio.dev for your auth config"
);
}
direct_authorize(&direct, toolkit, &config.composio.entity_id)
.await
.map_err(|e| format!("[composio-direct] authorize failed: {e:#}"))?
super::oauth_handoff::direct_authorize_with_meta_guard(
&direct,
toolkit,
&config.composio.entity_id,
)
.await
.map_err(|e| {
let wrapped = super::oauth_handoff::wrap_authorize_rate_limit_error(toolkit, e);
format!("[composio-direct] authorize failed: {wrapped:#}")
})?
}
};
+54
View File
@@ -349,6 +349,60 @@ async fn composio_list_connections_via_mock_counts_active() {
assert!(outcome.logs.iter().any(|l| l.contains("2 active")));
}
#[tokio::test]
async fn composio_authorize_clears_pending_meta_connection_before_handoff() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let deletes = Arc::new(AtomicUsize::new(0));
let deletes_for_delete = Arc::clone(&deletes);
let app = Router::new()
.route(
"/agent-integrations/composio/connections",
get(|| async {
Json(json!({
"success": true,
"data": {"connections": [
{"id":"ig-pending","toolkit":"instagram","status":"PENDING"}
]}
}))
}),
)
.route(
"/agent-integrations/composio/connections/{id}",
axum::routing::delete(move |Path(id): Path<String>| {
let deletes = Arc::clone(&deletes_for_delete);
async move {
if id == "ig-pending" {
deletes.fetch_add(1, Ordering::SeqCst);
}
Json(json!({"success": true, "data": {"deleted": true}}))
}
}),
)
.route(
"/agent-integrations/composio/authorize",
post(|Json(body): Json<Value>| async move {
assert_eq!(body["toolkit"], "instagram");
Json(json!({
"success": true,
"data": {
"connectUrl": "https://meta.example/oauth",
"connectionId": "c-new"
}
}))
}),
);
let base = start_mock_backend(app).await;
let tmp = tempfile::tempdir().unwrap();
let config = config_with_backend(&tmp, base);
let outcome = composio_authorize(&config, "instagram", None)
.await
.unwrap();
assert_eq!(outcome.value.connection_id, "c-new");
assert_eq!(deletes.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn composio_authorize_via_mock_publishes_event_and_returns_url() {
let app = Router::new().route(