Skip to content

Chapter 15: Throttling - Using SemaphoreSlim to Respect API Rate Limits

Theoretical Foundations

At the heart of any robust AI application lies the delicate dance between computational speed and external constraints. When we transition from simple, sequential API calls to building complex, asynchronous AI pipelines, we often encounter a fundamental bottleneck: the rate limits imposed by AI providers. These limits are not mere suggestions; they are hard walls that, when hit, result in HTTP 429 (Too Many Requests) errors, exponential backoff penalties, or even temporary account suspensions. This section lays the theoretical groundwork for managing these constraints, introducing the SemaphoreSlim class as our primary tool for enforcing concurrency limits and ensuring our application behaves as a respectful, efficient citizen in the shared ecosystem of AI services.

The Problem: The Unregulated Flood

Imagine a high-speed train station during rush hour. The platform has a strict capacity limit—say, 100 people at a time—for safety reasons. If a thousand passengers all try to rush the platform simultaneously, chaos ensues: people get hurt, the station is forced to shut down, and the entire system grinds to a halt. In the world of AI, the API endpoint is the platform, and your application's concurrent requests are the passengers. Without a gatekeeper, you risk overwhelming the service, leading to errors and degraded performance for everyone.

This is not a hypothetical scenario. In Book 3, Chapter 10: "Task-Based Asynchronous Pattern (TAP) and async/await", we learned how to fire off multiple tasks concurrently using Task.WhenAll to maximize throughput. While powerful, this technique, when applied naively to external API calls, can be disastrous. If we have 1000 documents to summarize and we simply create 1000 tasks and await them all, we are effectively trying to send 1000 requests at the exact same millisecond. The provider's rate limiter will see this as a coordinated attack and will almost certainly block us.

The core challenge is not just about making requests asynchronously; it's about orchestrating them to respect external boundaries while still achieving high throughput. We need a mechanism to control the degree of parallelism—the number of requests "on the platform" at any given moment.

The Solution: The Digital Turnstile with SemaphoreSlim

A semaphore (from the Greek sema "signal" and phore "bearer") is a synchronization primitive that limits the number of threads or tasks that can access a shared resource concurrently. In C#, SemaphoreSlim is a lightweight version of this concept, perfectly suited for managing access to non-shared resources like network API calls.

Think of SemaphoreSlim as a digital turnstile at the entrance to the AI platform. It has a fixed number of "permits" or "slots." A task wanting to make an API call must first acquire a permit from the turnstile. If a permit is available, the turnstile clicks, the task proceeds, and the number of available permits decreases by one. If no permits are available (all slots are occupied), the task must wait in a queue until another task finishes its work and releases its permit back to the turnstile.

This is fundamentally different from a lock (or Monitor). A lock is a mutual exclusion primitive—it ensures that only one thread can execute a critical section of code at a time. It's a binary, one-or-zero concept. A semaphore, on the other hand, is a counting mechanism. It allows up to N concurrent operations. For API rate limiting, N corresponds to the provider's concurrency limit (e.g., "5 requests per second").

Key Properties of SemaphoreSlim:

  1. Initial Count: The number of permits available when the semaphore is created. This is our concurrency limit.
  2. Maximum Count: The maximum number of permits the semaphore can ever hold. For rate limiting, this is typically the same as the initial count.
  3. WaitAsync(): The method a task calls to request a permit. This is an asynchronous operation; if no permit is available, the task is placed in a waiting queue without blocking the calling thread, allowing the application to remain responsive.
  4. Release(): The method called when a task has finished its critical work, returning the permit to the semaphore so another waiting task can acquire it.

The Architectural Pattern: The DelegatingHandler

While we could wrap every individual LLM call in a SemaphoreSlim block, this approach is verbose, error-prone, and violates the DRY (Don't Repeat Yourself) principle. A more elegant and robust solution is to centralize this throttling logic. In modern C#, this is achieved by creating a custom DelegatingHandler.

A DelegatingHandler sits in the HttpClient pipeline, intercepting every outgoing request and every incoming response. It acts as a universal gatekeeper. By placing our semaphore logic here, we enforce the rate limit across all HTTP requests made by our HttpClient instance, regardless of which service or endpoint we are calling. This is crucial for building modular AI applications where you might swap between different providers (e.g., OpenAI, Azure OpenAI, or a local model) without rewriting the throttling logic for each.

The flow looks like this:

The diagram illustrates a unified throttling mechanism that wraps various AI service providers—such as OpenAI, Azure OpenAI, or local models—into a single, reusable interface, allowing developers to manage request limits consistently without rewriting code for each provider.
Hold "Ctrl" to enable pan & zoom

The diagram illustrates a unified throttling mechanism that wraps various AI service providers—such as OpenAI, Azure OpenAI, or local models—into a single, reusable interface, allowing developers to manage request limits consistently without rewriting code for each provider.

The Synergy with Task.WhenAll: Batching and Throttling

The true power of this pattern emerges when we combine it with Task.WhenAll. As established in Book 3, Task.WhenAll is the engine of high-throughput parallelism. However, without a governor (our semaphore), it's an engine running without speed limits.

The strategy is to create a large batch of tasks—say, 1000 document summarization requests. We then use Task.WhenAll to await their completion. However, each individual task, when it reaches the point of making an HTTP call, will first contend for a permit from our DelegatingHandler's semaphore.

This creates a beautiful, efficient system:

  1. Burst Initiation: All 1000 tasks are created and start their preparatory work (e.g., reading data, formatting prompts) in parallel.
  2. Controlled Execution: When tasks reach the network call, they queue up at the semaphore's turnstile. Only a small, manageable number (e.g., 5) are allowed to proceed simultaneously.
  3. Automatic Pipelining: As one task completes its API call and releases its permit, the next task in the queue acquires it and begins its network operation. This creates a self-regulating pipeline that keeps the network channel full without overflowing it.
  4. Efficient Resource Usage: The application doesn't waste threads by blocking them while waiting for network I/O. The async/await pattern ensures threads are returned to the thread pool, making the entire process highly scalable.

This approach is superior to naive batching (e.g., sending 5 requests, waiting for all to complete, then sending the next 5) because it allows for a continuous flow of work. It's the difference between a factory that ships products in discrete, large batches with idle time in between, and one that uses a just-in-time conveyor belt system for a constant, smooth output.

Edge Cases and Nuances

A thorough theoretical understanding must also consider the boundaries:

  • Provider-Specific Limits: Some providers have complex limits, such as "X requests per minute" and "Y concurrent requests." A simple semaphore handles the concurrency limit. For the per-minute limit, we would need a token bucket algorithm or a similar rate-limiting strategy, which could be implemented as a second, outer layer of control or a more sophisticated DelegatingHandler.
  • Error Handling: What happens if a request fails? The Release() method must be called in a finally block to ensure the permit is always returned to the semaphore, preventing a "leak" that would eventually deadlock the entire application.
  • Disposing: SemaphoreSlim implements IDisposable. It's a best practice to manage its lifecycle, typically by creating it once and disposing of it when the application or the relevant service scope is torn down.
  • Local vs. Shared Semaphores: A single SemaphoreSlim instance can be shared across multiple HttpClient instances or even across the entire application to enforce a global rate limit. Alternatively, you can have a semaphore per service client if you need to enforce different limits for different providers.

By mastering the use of SemaphoreSlim within a DelegatingHandler, we move from simply writing asynchronous code to engineering resilient, scalable, and respectful AI systems. This theoretical foundation is the prerequisite for the practical implementation that will follow, where we will see these concepts come to life in clean, modern C# code.

Basic Code Example

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

// Simulating an external AI provider (e.g., OpenAI, Azure AI)
public static class ExternalAiProvider
{
    private static readonly Random _random = new();
    private static int _requestCount = 0;

    // Simulates an API call that is rate-limited to 2 concurrent requests.
    // If more than 2 requests arrive simultaneously, the provider "throttles" (rejects) them.
    public static async Task<string> GetCompletionAsync(string prompt, CancellationToken ct)
    {
        var currentCount = Interlocked.Increment(ref _requestCount);
        Console.WriteLine($"[API] Request '{prompt}' received. Active requests: {currentCount}");

        // Simulate network latency
        await Task.Delay(TimeSpan.FromMilliseconds(200), ct);

        // Simulate rate limiting logic
        if (currentCount > 2)
        {
            Interlocked.Decrement(ref _requestCount);
            Console.WriteLine($"[API] REJECTED '{prompt}' (Too many concurrent requests: {currentCount})");
            throw new HttpRequestException("Rate limit exceeded. Status: 429 Too Many Requests.");
        }

        // Simulate processing
        await Task.Delay(TimeSpan.FromMilliseconds(300), ct);

        Interlocked.Decrement(ref _requestCount);
        Console.WriteLine($"[API] COMPLETED '{prompt}'");
        return $"Response to: {prompt}";
    }
}

public class ThrottledAiClient
{
    // SemaphoreSlim(2, 2) creates a semaphore with an initial count of 2 and a maximum count of 2.
    // This enforces that only 2 threads can enter the protected section concurrently.
    private readonly SemaphoreSlim _throttler = new(2, 2);

    public async Task<string> ProcessRequestAsync(string prompt, CancellationToken ct)
    {
        // We wrap the semaphore wait/release in a using statement to ensure 
        // the semaphore is always released, even if an exception occurs.
        await _throttler.WaitAsync(ct);

        try
        {
            // Only 2 of these calls can be active at any given moment across all instances
            // of this client (if shared) or per instance (if instantiated separately).
            return await ExternalAiProvider.GetCompletionAsync(prompt, ct);
        }
        finally
        {
            // Release the semaphore slot so another waiting request can proceed.
            _throttler.Release();
        }
    }
}

public class Program
{
    public static async Task Main()
    {
        Console.WriteLine("--- Starting Throttled Batch Processing ---");

        var client = new ThrottledAiClient();
        var prompts = Enumerable.Range(1, 5).Select(i => $"Prompt {i}").ToList();
        var tasks = new List<Task<string>>();

        // We use Task.WhenAll to process the batch concurrently.
        // Without SemaphoreSlim, this would spawn 5 simultaneous API calls,
        // likely overwhelming the provider.
        foreach (var prompt in prompts)
        {
            // We do not await immediately. We start the task and store it.
            tasks.Add(client.ProcessRequestAsync(prompt, CancellationToken.None));
        }

        try
        {
            var results = await Task.WhenAll(tasks);
            Console.WriteLine($"\n--- Batch Completed. {results.Length} results received. ---");
            foreach (var result in results)
            {
                Console.WriteLine($"Result: {result}");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"\n--- Batch Failed: {ex.Message} ---");
        }
    }
}

Line-by-Line Explanation

  1. using System.Threading;: Imports the namespace containing SemaphoreSlim and CancellationToken, which are essential for managing concurrency and cancellation in asynchronous operations.

  2. ExternalAiProvider Class: This static class acts as a mock for a real-world AI service (like OpenAI or Azure OpenAI). In a real application, this would be an HttpClient call to an external endpoint.

    • _requestCount: A static integer tracked via Interlocked to simulate the number of currently active requests on the server side.
    • GetCompletionAsync:
      • Interlocked.Increment: Atomically increments the counter to track active requests thread-safely.
      • Rate Limit Simulation: The if (currentCount > 2) block simulates the behavior of a strict API rate limiter. If the "server" detects more than 2 concurrent requests, it throws an exception (mimicking HTTP 429).
      • finally block: Crucially, it decrements the counter when the request finishes (success or failure). This ensures the simulation state remains accurate.
  3. ThrottledAiClient Class: This is the core implementation of the throttling logic.

    • SemaphoreSlim(2, 2): This initializes a semaphore with an initial count of 2 and a maximum count of 2.
      • Concept: Think of a nightclub with a capacity of 2. The semaphore manages the "door."
      • Why 2?: We set this to match the simulated API limit (2 concurrent requests). In a real scenario, you would check your provider's documentation (e.g., "50 requests per second") and calculate the appropriate concurrency limit.
    • ProcessRequestAsync:
      • await _throttler.WaitAsync(ct): This is the "acquire" step. If the semaphore count is 0 (capacity full), the code pauses here until a slot opens up. It does not block the thread; it yields control to the event loop, allowing other work to be done.
      • try...finally block: This is a critical safety pattern.
        • try: Executes the actual API call (ExternalAiProvider.GetCompletionAsync).
        • finally: Always executes, even if the API call throws an exception. It calls _throttler.Release(), incrementing the semaphore count back to the available pool. If you forget this, your application will permanently lose a concurrency slot and eventually freeze (deadlock).
  4. Program.Main:

    • Initialization: Creates the client and a list of 5 prompts.
    • The "Thundering Herd" Prevention:
      • We iterate through the prompts and call client.ProcessRequestAsync. Because ProcessRequestAsync awaits the SemaphoreSlim first, the first 2 tasks will acquire the semaphore and start calling the API immediately.
      • The 3rd, 4th, and 5th tasks will pause at await _throttler.WaitAsync(), effectively queuing themselves in memory.
    • Task.WhenAll(tasks): This aggregates the list of tasks into a single task that completes when all underlying tasks have finished (successfully or faulted). This allows us to process the batch asynchronously without blocking the main thread while waiting for the queue to clear.

Common Pitfalls

  1. Forgetting the finally Block: The most common mistake is failing to release the semaphore in a finally block.

    • Scenario: You acquire the semaphore, make the API call, and the API throws a timeout exception.
    • Result: If you don't catch/release, the semaphore count remains decreased. Eventually, all slots are "leaked," and WaitAsync hangs indefinitely because no slots are ever returned to the pool.
  2. Instantiating SemaphoreSlim Locally: Creating a new SemaphoreSlim(2, 2) inside a loop or a method called frequently.

    • Result: Every loop iteration gets its own separate semaphore instance with a capacity of 2. This defeats the purpose of global throttling. The semaphore must be a shared instance (like a class field) to coordinate access across concurrent tasks.
  3. Setting the Count Higher Than the Limit: Setting SemaphoreSlim(10, 10) when the API limit is 2.

    • Result: The API provider will reject requests (HTTP 429), leading to exceptions in your code and potential wasted costs or temporary IP bans. Always align the semaphore count with the provider's hard limits.

Visualizing the Flow

The following diagram illustrates how the semaphore acts as a gatekeeper for the API calls.

A gatekeeper diagram would show a semaphore counter regulating a queue of API calls, allowing a limited number of requests to pass through to the provider based on its hard limits while holding others back.
Hold "Ctrl" to enable pan & zoom

A gatekeeper diagram would show a semaphore counter regulating a queue of API calls, allowing a limited number of requests to pass through to the provider based on its hard limits while holding others back.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon


Loading knowledge check...



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.