Repository navigation
Description
Problem
Microsoft Agent Framework's Python SDK provides automatic function invocation, concurrent tool execution, and FunctionMiddleware for intercepting tool calls.
However, the framework does not currently provide an official, reusable mechanism for enforcing independent request-rate limits on locally executed tools before execution.
This creates a practical production reliability limitation.
Enterprise AI agents routinely invoke external services with different request quotas, including:
- REST APIs
- CRM and enterprise applications
- Payment and financial systems
- Database services
- Search and retrieval APIs
- MCP-backed tools
- Cloud infrastructure services
When an LLM requests multiple tools, the framework may execute those calls concurrently. A batch of otherwise valid requests can exceed the downstream service's requests-per-second or requests-per-minute quota.
This can cause:
- HTTP 429 responses and unnecessary failures.
- Increased end-to-end execution latency.
- Excessive traffic to already rate-limited services.
- Unpredictable agent behavior under concurrent workloads.
- Duplicated rate-limit handling across applications.
- Higher operational costs and wasted model interactions.
- Unnecessary reduction in throughput when developers disable concurrency globally.
Existing allow_concurrent_invocation controls execution concurrency, not request frequency over time.
The existing model-client retry sample demonstrates recovering from rate-limit errors after they occur. It does not prevent locally executed tools from exceeding independent request quotas.
Core limitation: There is no official per-tool admission-control policy that determines whether an invocation may start based on the individual tool's configured request-rate quota.
The desired capability should be opt-in, preserve existing behavior by default, and work with existing function invocation middleware.
Use Case and Evidence
Real-world scenario: Enterprise multi-tool customer service agent
Consider a customer-service agent connected to three services:
| Tool | Illustrative service quota |
|---|---|
lookup_customer |
2 requests/second |
get_payment_status |
1 request/second |
search_documents |
10 requests/second |
These quotas are illustrative application configuration values, not published limits of any particular provider.
A customer asks the agent to retrieve several customer records, check recent payments, and search supporting documentation.
The LLM generates the following operations:
- 5 calls to
lookup_customer - 2 calls to
get_payment_status - 3 calls to
search_documents
The model may request these operations within the same response.
The current function invocation scheduler can execute independent calls concurrently.
Consequently, several calls to the same external service may begin together, exceeding that service's rate limit.
Why current controls are insufficient
1. Global sequential execution
Setting:
allow_concurrent_invocation=False
can execute function calls sequentially.
However, sequential execution does not guarantee adherence to requests-per-second limits. Several short calls can still execute within the same second.
It also sacrifices useful concurrency between unrelated services.
2. Function invocation limits
max_function_calls limits the number of function executions, not their rate over time.
A configuration permitting 100 calls does not prevent 100 calls from being concentrated within a short interval.
3. Model-client retry middleware
The repository's existing:
python/samples/02-agents/auto_retry.py
demonstrates handling model-provider rate-limit errors.
It does not implement proactive rate limiting for locally executed tools.
4. Existing function middleware
Developers can build their own rate limiter using FunctionMiddleware, but the framework does not provide a focused official implementation or reference demonstrating:
- Atomic capacity reservations
- Independent per-tool quotas
- Safe concurrent invocation
- Burst management
- Cancellation handling
- Limit-exceeded responses
- Policy state lifetime
- Deterministic validation
These concerns must be addressed separately by application developers.
Reproducible demonstration
A minimal local test could define two asynchronous tools:
slow_customer_lookupfast_document_search
Configure the first tool with a limit of two requests per second and a burst capacity of two.
Then:
- Initiate five concurrent customer-lookup invocations.
- Verify that only the permitted number are immediately admitted.
- Confirm that additional requests are rejected before tool execution in fail-fast mode.
- Verify that the independent document-search tool continues normally.
- Advance a controlled monotonic clock.
- Verify that admission capacity refills according to the configured policy.
- Verify that simultaneous calls cannot bypass the limit through race conditions.
This demonstrates the capability without requiring external cloud accounts or API credentials.
Relevant implementation evidence
The following repository locations provide the existing integration points:
python/packages/core/agent_framework/_middleware.py
Contains FunctionMiddleware and FunctionInvocationContext.
python/packages/core/agent_framework/_tools.py
Contains function invocation scheduling and execution handling.
python/samples/02-agents/auto_retry.py
Provides model-provider rate-limit retry examples.
The requested capability is distinct from retry, batch serialization, and total function-call limits.
Desired Outcome
Provide an opt-in, concurrency-safe per-tool request-rate admission-control capability for locally executed Python tools.
A successful outcome should allow application developers to configure different request-rate quotas for individual tools while preserving concurrency between independent services.
Required behaviors
1. Independent per-tool quotas
Different tools should support independent rate policies.
For example:
- Customer lookup: 2 requests/second
- Payment status: 1 request/second
- Document search: 10 requests/second
Tools without configured policies must continue behaving normally.
2. Pre-execution admission control
Before invoking a protected tool, the policy must determine whether capacity is available.
If admission is denied, the underlying tool must not execute.
3. Concurrency-safe quota enforcement
Multiple simultaneous invocations must not exceed the available admission capacity because of race conditions.
4. Configurable burst capacity
Applications should be able to define permitted request bursts.
Burst and refill semantics should be explicit, predictable, and testable.
5. Independent execution
A rate-limited tool must not unnecessarily block unrelated tools with available capacity.
6. Clear limit-exceeded behavior
Applications should receive an identifiable outcome when a tool call is rejected by the admission policy.
The initial feature may use fail-fast rejection.
Bounded waiting could be supported later if maintainers prefer a staged implementation.
7. Correct cancellation handling
Caller cancellation must propagate correctly.
If bounded waiting is supported, cancelled waiters must be removed safely without corrupting accounting.
8. No implicit retries
The admission policy must not automatically replay rejected operations.
This is particularly important for non-idempotent operations that may produce external side effects.
9. Observability
Applications should be able to identify:
- Admitted invocations
- Rejected invocations
- Tool identity
- Applicable quota
- Waiting duration, when relevant
Sensitive tool arguments and authentication information should not be logged unnecessarily.
10. Backward compatibility
Existing tool invocation behavior must remain unchanged unless the policy is explicitly enabled.
Alternatives Considered
Alternative 1: Existing model-client rate-limit handling
Issue #1362 and merged PR #4223 address rate-limit retry behavior for model inference requests.
This helps recover from provider throttling but does not proactively restrict the rate of locally executed tools.
Alternative 2: Disable concurrent invocation
The existing allow_concurrent_invocation=False option serializes supported function execution.
However:
- Sequential calls may still exceed time-based quotas.
- Independent tools lose parallelism.
- No per-tool rate policy is expressed.
- Burst behavior remains undefined.
Alternative 3: Per-tool concurrency groups
Issue #7914 discusses per-tool concurrency controls.
Concurrency and rate limiting solve distinct problems.
Concurrency limits the number of simultaneous executions.
Rate limiting restricts the frequency at which requests are admitted over time.
Both capabilities may be useful together.
Alternative 4: Implement rate limiting inside each tool
Developers can manually implement rate limiting using asyncio synchronization primitives and a token-bucket algorithm.
However, this results in duplicated logic across multiple tools and applications.
Common concerns include:
- Race conditions
- Policy configuration
- State sharing
- Time accounting
- Cancellation
- Consistent error handling
- Observability
A reusable reference implementation would reduce this duplication.
Alternative 5: External API gateways
External gateways can enforce request quotas on supported network traffic.
However, not all tools execute through the same gateway, and local Python tools or locally invoked MCP integrations may require application-level admission control.
Alternative 6: Retry with exponential backoff
Retry policies operate after a failure has occurred.
They do not prevent excessive calls from being initiated.
Furthermore, automatic retries may be unsafe for non-idempotent operations.
The proposed capability focuses on proactive admission control rather than retry execution.
Proposed Direction
Preferred implementation
Introduce an optional Python per-tool admission-control policy using the existing FunctionMiddleware architecture.
The first contribution should preferably be a reusable middleware implementation or official example with tests, without requiring changes to the framework's public API.
Illustrative configuration
rate_limit_policy = ToolRateLimitPolicy(
limits={
"lookup_customer": RateLimit(
requests=2,
per_seconds=1.0,
burst=2,
),
"get_payment_status": RateLimit(
requests=1,
per_seconds=1.0,
burst=1,
),
"search_documents": RateLimit(
requests=10,
per_seconds=1.0,
burst=10,
),
},
on_limit="reject",
)This is an illustrative design, not an existing or finalized public SDK API.
Suggested technical approach
A token-bucket algorithm could provide predictable rate enforcement.
Each configured tool would maintain:
- Maximum token capacity
- Available tokens
- Token refill rate
- Last refill timestamp
- Appropriate asynchronous synchronization state
When a tool invocation begins:
- Identify the tool and its configured policy.
- Calculate available capacity using a monotonic clock.
- Atomically update bucket state and attempt an admission reservation.
- Admit the tool if capacity is available.
- Otherwise reject the invocation before execution.
- Preserve the existing function middleware pipeline behavior.
- Return a clearly identifiable admission rejection without automatically retrying the tool.
Concurrency and correctness requirements
- Quota accounting must remain consistent under concurrent invocations.
- An admission decision must not be based on stale shared state.
- Independent tools must maintain independent quotas.
- The state-sharing scope must be explicitly documented.
- A cancellation must not corrupt bucket state.
- Already admitted operations must not be re-executed.
- Framework cancellation and failure semantics must be preserved.
- Tool identities must be unambiguous when tools originate from different integrations.
- Rate-limit configuration must reject invalid values.
Implementation boundaries
An in-process policy may share state across invocations and agent runs that use the same middleware instance.
It should not claim to enforce global quotas across separate processes or distributed workers.
Distributed rate limiting would require a separate shared-state or coordination mechanism and should remain outside the initial scope.
Proposed acceptance criteria
- Independent rate policies are configurable for different locally invoked tools.
- Calls within available capacity execute normally.
- Excess calls are rejected before the underlying tool executes.
- Concurrent calls cannot bypass quota accounting.
- Configurable burst capacity behaves as documented.
- Refill calculations use monotonic elapsed time.
- Unconfigured tools retain normal behavior.
- One rate-limited tool does not unnecessarily block unrelated tools.
- Cancellation propagates correctly.
- No implicit retries are introduced.
- Admission rejection is distinguishable from tool execution failure.
- Invalid policy configurations produce clear errors.
- The feature is opt-in and backward compatible.
- Deterministic local tests cover concurrency and refill behavior.
- Documentation explains state scope and distributed limitations.
- No new framework-level dependencies are required for the initial implementation.
Suggested deterministic tests
| Scenario | Expected result |
|---|---|
| Invocation within available capacity | Tool executes normally |
| Invocation beyond quota | Tool does not execute |
| Two tools with different limits | Each quota enforced independently |
| Simultaneous invocations | No over-admission race |
| Permitted burst | Calls admitted up to configured capacity |
| Refill interval elapses | Capacity replenished correctly |
| No configured policy | Existing behavior unchanged |
| Cancellation | Correct propagation and accounting |
| Invalid quota | Configuration rejected |
| Healthy tool alongside rejected tool | Healthy tool remains executable |
| Side-effecting tool denied admission | Underlying operation never starts |
| Shared middleware used concurrently | Consistent quota enforcement |
The tests should run without network access, external APIs, or paid cloud services.
Related issues and pull requests
Issue #1362 — Model-client rate limiting:
Issue #7914 — Per-tool concurrency controls:
Merged PR #4223 — Model-client auto-retry sample:
Merged PR #8453 — Sequential function invocation:
These address adjacent concerns but do not directly provide proactive per-tool request-rate admission control.
I am open to maintainer feedback regarding whether the initial contribution should be a sample-only implementation or a more reusable framework component.
Scope and Non-Goals
In Scope
- Python implementation
- Locally executed asynchronous tools
- Configurable per-tool request-rate quotas
- Pre-execution admission control
- Concurrency-safe accounting
- Configurable burst limits
- Fail-fast rejection
- Cancellation correctness
- Opt-in behavior
- Deterministic unit tests
- Documentation and examples
Out of Scope
- Model-provider HTTP 429 retry handling
- Distributed or cluster-wide rate limiting
- Redis-backed shared quota management
- Automatic retry and exponential backoff
- Provider-hosted tools outside the local invocation pipeline
- Modification of upstream API quotas
- Billing and token-cost accounting
- Whole-agent execution budgets
- Forced cancellation of already-running operations
- Guaranteed rollback of external side effects
- Breaking changes to existing APIs
These boundaries are intended to keep the initial contribution focused, testable, and maintainable.
Language
Python
Contribution Intent
I am willing to implement this
Acknowledgements
- I searched existing issues and did not find a duplicate.
- I will wait for explicit maintainer agreement before starting implementation of a non-trivial change.
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
[-][Feature]: Python per-tool request-rate limiting and admission control for production AI agents[/-][+]Python: [Feature]: Python per-tool request-rate limiting and admission control for production AI agents[/+]eavanvalkenburg commentedon Oct 13, 2026
/plan
github-actions commentedon Oct 13, 2026
Summary
This issue asks for a way to cap how often individual tools (e.g.
lookup_customer,get_payment_status) can be called per second, so a batch of concurrent tool calls from the model doesn't overload a downstream service and trigger HTTP 429s. The issue itself proposes a "token bucket" admission-control approach and explicitly says the preferred form is "a reusable middleware implementation or official example with tests, without requiring changes to the framework's public API."Recommendation
A sample, not a core framework change.
I checked the existing
FunctionMiddlewaremechanism in_middleware.py, which already covers essentially everything the issue asks for:call_next()is what actually invokes the function — so middleware can block execution entirely (admission control) rather than just reacting after the fact.context.function.namegives per-tool identity, so a middleware implementation can keep an independent token bucket per tool name.MiddlewareTerminationstops the chain "gracefully" without running the tool and lets you set a distinguishablecontext.resultfor the "rejected" case; raisingMiddlewareFailuregives a fail-closed option instead, if desired.FunctionMiddlewareforces sequential or global execution — independent tools called concurrently each go through their own middleware instance call, so a busy tool doesn't need to block an unrelated one.middleware=[...]to theAgent, so default behavior for everyone else is unchanged.In other words, the building blocks needed (pre-execution hook, per-tool identity, reject-without-executing, no forced global change) already exist in the framework. What's missing is a documented reference implementation of a token-bucket rate limiter built on top of them — which is exactly what the issue's own "Preferred implementation" section suggests, and matches how the repo already handles a similar need:
python/samples/02-agents/auto_retry.pyis a sample for model-call retries, not a new core retry API.I also looked at issue #7914, which covers a related but distinct concept — per-tool concurrency groups (limiting how many calls run at once). That issue was deliberately deferred/kept separate from core because its cross-SDK design questions (MCP tool grouping, cancellation semantics, etc.) aren't settled. Rate limiting (calls per unit time) doesn't have those same open design questions, since it's just bucketed timing logic layered on an existing extension point, so it doesn't need to wait on that separate discussion.
Proposed plan
python/samples/02-agents/middleware/(e.g.tool_rate_limit_middleware.py), following the existing structure of sibling middleware samples such astool_approval_middleware.py. It would:asyncio.Lockso concurrent calls can't over-admit.FunctionMiddlewaresubclass that looks up the bucket forcontext.function.name, and either callscall_next()(admitted) or raisesMiddlewareTerminationwith a clear "rate limit exceeded" result (rejected) — without ever callingcall_next()in the rejected case, so the underlying tool never runs.python/packages/core/tests/near existing middleware tests, or as an inlineif __name__ == "__main__"demonstration if kept sample-only) that exercises: calls within capacity succeed, calls beyond capacity are rejected before execution, two tools with different limits are enforced independently, and capacity refills after advancing time.python/samples/02-agents/middleware/README.md(if present) or the relevant sample index to mention the new sample.You'd know this is resolved when the sample runs standalone, demonstrates admission rejection distinct from a tool error, and the accompanying test shows quota accounting stays correct under concurrent calls.
This is a proposal only — no code has been changed. If after review this feels like it should instead become a small reusable helper (e.g. exported from
agent_framework) rather than a plain sample, that would be a slightly larger, separate discussion about public API surface, since it goes beyond what's needed to unblock the reported scenario.eavanvalkenburg commentedon Oct 27, 2026
Suhas Aggarwal (@suhasagg) This scenario belongs in a sample built on the supported FunctionMiddleware extension point; no core rate-limiting API change is planned. The existing planning result is a useful starting point for a focused example of per-tool, in-process admission control before calling the underlying function.
Please keep the example explicit about quota/state-sharing scope, concurrency-safe accounting, cancellation, and rejection before tool side effects, without implicit retries or distributed-enforcement claims. Rejection semantics also need care: MiddlewareTermination stops the tool loop, so it should not be used as though it merely rejects one call while guaranteeing unrelated calls continue. A focused sample can demonstrate the intended behavior without introducing a framework-owned quota subsystem.
suhasagg commentedon Oct 28, 2026
Eduard van Valkenburg (@eavanvalkenburg) Thank you for your guidance and clarification on the preferred implementation approach.
I've completed the sample-only implementation of per-tool rate limiting using the existing
FunctionMiddlewareextension point, following your recommendations.The implementation includes:
threading.Lock.MiddlewareTermination.Local validation is complete: all 17 tests pass, Ruff lint and formatting checks pass, and the standalone sample runs successfully.
I've also kept the changes entirely within the Python samples directory, without introducing new public framework APIs.
PR:#9239
Would appreciate your review and feedback on whether this implementation aligns with the intended direction for #9239.
Thank you!