aws-samples / aws-samples/sample-multi-agent-orchestration-chat-on-agentcore

Throttling risk: trigger fan-out and shared schedules can exceed Cognito Identity Pool GetOpenIdTokenForDeveloperIdentity quota (50 RPS)

Open
#37 0 comments 0 reactions 0 assignees View on GitHub
enhancement
Dominant language
TypeScript
Stars
128
Forks
12
Avg merge
3d 1h
Merged PRs (30d)
4

Description

## Summary

**Priority: Low.** At larger user counts, the event-trigger code paths can issue a burst of `GetOpenIdTokenForDeveloperIdentity` calls that exceed the Cognito Identity Pool default quota (**50 RPS**), causing throttling and dropped trigger executions. This issue proposes two small, self-contained mitigations.

This is a scalability hardening item only. It does not affect correctness at small scale and is not a security issue.

## Background

Triggers obtain per-user credentials via Cognito Identity Pool **Developer Authenticated Identities**:

- The Trigger Lambda calls `GetOpenIdTokenForDeveloperIdentity` (default quota **50 RPS**, adjustable).
- The downstream agent runtime then calls `GetCredentialsForIdentity` (default quota 200 RPS).

The tightest limit is `GetOpenIdTokenForDeveloperIdentity` at **50 RPS**. Two trigger patterns can concentrate calls and exceed it:

### 1. Custom-event fan-out (most concentrated)

`packages/trigger/src/handlers/custom-event-handler.ts` queries all triggers subscribed to an `eventSourceId` (GSI2) and invokes them **fully in parallel** with `Promise.allSettled`:

```ts
const results = await Promise.allSettled(
triggers.map((trigger) =>
invokeTrigger(trigger, event, authService, agentInvoker, executionRecorder)
)
);
```

Each `invokeTrigger` performs one `GetOpenIdTokenForDeveloperIdentity` call. A single event with N subscribers fires N near-simultaneous calls inside one Lambda invocation. This cannot be mitigated by time-spreading because all subscribers fire on the same event.

### 2. Shared schedule times

`packages/backend/src/services/scheduler-service.ts` creates each EventBridge Schedule with `FlexibleTimeWindow: { Mode: 'OFF' }`:

```ts
FlexibleTimeWindow: {
Mode: 'OFF',
},
```

With `Mode: 'OFF'`, all schedules set to the same cron (e.g. `cron(0 9 * * ? *)`, or users naturally clustering on `HH:00`) dispatch at the same instant, producing a synchronized burst.

## Peak RPS estimates (assume 5,000 users, 1 trigger/user, quota = 50 RPS)

| Scenario | Assumption | Peak RPS | vs 50 RPS |
|---|---|---:|---:|
| All same cron `9:00` (1s dispatch window) | synchronized | ~5,000 | x100 |
| All same cron `9:00` (5s dispatch window) | synchronized | ~1,000 | x20 |
| Event fan-out, 500 subscribers, <1s | single Lambda parallel | ~500 | x10 |
| Hourly `:00` clustering (167 users at same second, 3s) | natural clustering | ~56 | x1.1 |
| 24h uniform spread (per-second jitter) | spread | ~0.06 | ok |

Even modest natural clustering (~167 users at the same second) already exceeds the quota, and event fan-out exceeds it with as few as ~50 subscribers.

## Proposed fixes

### Fix 1 — Bound fan-out concurrency in `custom-event-handler.ts`

Replace the unbounded `Promise.allSettled(map(...))` with a concurrency-limited runner so that at most `MAX_CONCURRENCY` (e.g. 20) credential exchanges are in flight at once. Add a small dependency such as [`p-limit`](https://www.npmjs.com/package/p-limit) (or a tiny inline limiter to avoid the dependency).

```ts
import pLimit from 'p-limit';

// Keep below the GetOpenIdTokenForDeveloperIdentity quota (default 50 RPS),
// leaving headroom for concurrent Lambda invocations.
const FANOUT_CONCURRENCY = Number(process.env.TRIGGER_FANOUT_CONCURRENCY ?? 20);

const limit = pLimit(FANOUT_CONCURRENCY);
const results = await Promise.allSettled(
triggers.map((trigger) =>
limit(() =>
invokeTrigger(trigger, event, authService, agentInvoker, executionRecorder)
)
)
);
```

Inline alternative (no new dependency):

```ts
async function mapWithConcurrency(
items: T[],
concurrency: number,
fn: (item: T) => Promise
): Promise[]> {
const results: PromiseSettledResult[] = new Array(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (true) {
const i = next++;
if (i >= items.length) return;
try {
results[i] = { status: 'fulfilled', value: await fn(items[i]) };
} catch (reason) {
results[i] = { status: 'rejected', reason };
}
}
});
await Promise.all(workers);
return results;
}

// usage
const results = await mapWithConcurrency(triggers, FANOUT_CONCURRENCY, (trigger) =>
invokeTrigger(trigger, event, authService, agentInvoker, executionRecorder)
);
```

### Fix 2 — Enable a flexible time window for schedules in `scheduler-service.ts`

Use EventBridge Scheduler's built-in `FlexibleTimeWindow` to spread synchronized cron schedules across a window automatically (no custom jitter logic needed):

```ts
const FLEX_WINDOW_MINUTES = Number(process.env.SCHEDULE_FLEX_WINDOW_MINUTES ?? 5);

// in CreateScheduleCommand (and the UpdateSchedule path around line 357)
FlexibleTimeWindow: FLEX_WINDOW_MINUTES > 0
? { Mode: 'FLEXIBLE', MaximumWindowInMinutes: FLEX_WINDOW_MINUTES }
: { Mode: 'OFF' },
```

A 5-minute window spreads otherwise-synchronized schedules across 300 seconds. Example: 5,000 users all on the same cron drop from ~5,000 RPS (1s) to ~17 RPS average over the window. This should be documented as a best-effort timing (executions may run up to N minutes after the nominal time).

### Fix 3 (optional) — Quota increase + retry

- Request a Service Quotas increase for `GetOpenIdTokenForDeveloperIdentity` if high trigger volume is expected.
- Add exponential backoff / retry for `ThrottlingException` / `TooManyRequestsException` on the Identity calls in the trigger auth path as defense-in-depth.

## Acceptance criteria

- [ ] Custom-event fan-out limits concurrent credential exchanges to a configurable bound (default ~20).
- [ ] Schedule creation/update supports a configurable `FlexibleTimeWindow` (default FLEXIBLE, e.g. 5 min) to de-synchronize shared cron times.
- [ ] Behavior is configurable via env vars and defaults are safe for the documented "few hundred users" PoC scale.
- [ ] Unit tests cover the concurrency limiter and the schedule flexible-window option.

## Notes

- The steady-state RPS is negligible thanks to in-memory credential caching; this issue only concerns **burst** patterns.
- No account-specific or confidential information is involved; this describes application behavior only.

Contributor guide

Open the contributing guide

Research direction

Start by reading packages/trigger/src/handlers/custom-event-handler.ts and packages/backend/src/services/scheduler-service.ts, including the schedule update path around line 357. Run the existing test suite and locate the relevant unit-test patterns before implementing configurable fan-out concurrency and FlexibleTimeWindow settings. Done means both options have safe defaults, honor their environment variables, and have unit tests covering concurrency limiting and schedule-window configuration.

Written by the indexing model from the issue text.

Assessment

Tech stack
aws, typescript
Domain
backend, cloud
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.