<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: agustin18</title>
    <description>The latest articles on DEV Community by agustin18 (@agustin18).</description>
    <link>https://dev.to/agustin18</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4194347%2Fa4f665cc-5095-4ab3-acf4-3b5663aef315.jpg</url>
      <title>DEV Community: agustin18</title>
      <link>https://dev.to/agustin18</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/agustin18"/>
    <language>en</language>
    <item>
      <title>The 429 looked right. The limiter was wrong</title>
      <dc:creator>agustin18</dc:creator>
      <pubDate>Tue, 27 Oct 2026 18:30:43 +0000</pubDate>
      <link>https://dev.to/agustin18/the-429-looked-right-the-limiter-was-wrong-ghf</link>
      <guid>https://dev.to/agustin18/the-429-looked-right-the-limiter-was-wrong-ghf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A debugging story about layered limits in a FastAPI SSE demo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I built a small browser demo for &lt;a href="https://github.com/agustin18/fastapi-stream-lease" rel="noopener noreferrer"&gt;fastapi-stream-lease&lt;/a&gt; to show how a Redis lease stops one user from holding several long-lived streams at the same time. Two tabs, one session cookie, a synthetic 20-second SSE stream. The first tab streams. The second tab gets &lt;code&gt;429&lt;/code&gt;. Everything looked correct.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;429&lt;/code&gt; was real, but it came from the wrong limiter. The second tab was rejected by a demo-specific per-IP guard before it reached the per-session Redis lease I wanted to show. The tests passed. The UI looked fine. The only thing wrong was the story the demo was telling.&lt;/p&gt;

&lt;p&gt;This is how I found it, what I changed, and why I now test the layer that rejects a request, not just the status code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the demo does
&lt;/h2&gt;

&lt;p&gt;The demo is local-only. It runs with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt; demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It starts Redis and a small FastAPI app at &lt;code&gt;http://localhost:8080&lt;/code&gt;. The app has two panels. Both share one signed HttpOnly session cookie. Each panel opens a synthetic SSE stream that yields tokens for 20 seconds.&lt;/p&gt;

&lt;p&gt;The library provides per-session and global stream leases. The demo adds two extra layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an in-memory request rate limiter&lt;/li&gt;
&lt;li&gt;a Redis-backed per-IP guard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The request path is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rate limit -&amp;gt; per-IP guard -&amp;gt; per-session lease -&amp;gt; SSE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The per-session lease is the library. The other two are demo guardrails. That distinction matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first limiter wins
&lt;/h2&gt;

&lt;p&gt;Layered limits are useful, but they are also easy to misread. If the per-IP guard rejects first, the per-session lease never runs. The client still gets &lt;code&gt;429&lt;/code&gt;. A test that checks only &lt;code&gt;status_code == 429&lt;/code&gt; passes. The demo looks correct. But the feature you wanted to demonstrate was never exercised.&lt;/p&gt;

&lt;p&gt;This is a small detail, but it changes completely the story. I was more focused in the &lt;code&gt;429&lt;/code&gt; than in the layer that produced it.&lt;/p&gt;

&lt;p&gt;The UI did not help. It said the second tab was rejected, which was true. It did not say which layer rejected it. I looked at the status code, saw &lt;code&gt;429&lt;/code&gt;, and moved on. That was the mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found it
&lt;/h2&gt;

&lt;p&gt;The response body had a code. The second tab returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"demo_ip_limit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Too many active demo streams from this network."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expected one was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stream_connection_limit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_limit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User stream limit reached"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the clue. The default &lt;code&gt;DEMO_MAX_PER_IP&lt;/code&gt; was &lt;code&gt;1&lt;/code&gt;, so the second tab from the same browser hit the IP guard before the session lease. The test config used &lt;code&gt;max_per_ip=2&lt;/code&gt;, so the test took a different path and passed.&lt;/p&gt;

&lt;p&gt;That is the part I missed: the test was not using the same config as the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test gap
&lt;/h2&gt;

&lt;p&gt;The test looked reasonable. It created a demo app, opened a stream, waited for the lease, and opened a second stream. It asserted &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;reason == "user_limit"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem was in the config helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;make_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_per_ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app default was &lt;code&gt;1&lt;/code&gt;. The test created a world where the bug could not happen.&lt;/p&gt;

&lt;p&gt;I removed the override so the test uses the real default. Then I added a second test that starts two sessions, fills the per-IP guard, and asserts the third one gets &lt;code&gt;demo_ip_limit&lt;/code&gt;. The first test now asserts the layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream_connection_limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the test that would have caught the original problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;I changed the demo default to &lt;code&gt;DEMO_MAX_PER_IP=2&lt;/code&gt;, so two tabs from the same network can reach the per-session lease. The per-IP guard still exists. A third session from the same network hits it.&lt;/p&gt;

&lt;p&gt;I also added &lt;code&gt;max_per_ip&lt;/code&gt; and &lt;code&gt;rate_limit_per_minute&lt;/code&gt; to &lt;code&gt;/demo/stats&lt;/code&gt;, and the UI now shows the request path and the layer that rejected the stream. A blocked panel says &lt;code&gt;per-session lease&lt;/code&gt; or &lt;code&gt;per-IP guard&lt;/code&gt;, not just &lt;code&gt;429&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The library did not change. This was a demo bug.&lt;/p&gt;

&lt;p&gt;The library call is still the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;token_stream&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes any async token generator, acquires the lease before the stream starts, renews it while the response is open, and releases it on completion, error, or client disconnect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in v0.6.4
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The per-IP guard default is now &lt;code&gt;2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/demo/stats&lt;/code&gt; includes &lt;code&gt;max_per_ip&lt;/code&gt; and &lt;code&gt;rate_limit_per_minute&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The UI shows per-session and per-network limits, a guided "Try this" flow, and the request path.&lt;/li&gt;
&lt;li&gt;The demo suite has 46 tests and 100% branch coverage for &lt;code&gt;demo/app.py&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The library suite has 467 tests.&lt;/li&gt;
&lt;li&gt;No runtime library changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are already on &lt;code&gt;0.6.3&lt;/code&gt;, this is a demo-only update.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Return a machine-readable reason, not only &lt;code&gt;429&lt;/code&gt;. A status code tells you that something rejected the request. It does not tell you what.&lt;/li&gt;
&lt;li&gt;Test with the same config users get. A test-only override can hide the default path.&lt;/li&gt;
&lt;li&gt;Test each layer separately. If you have a rate limit, a per-IP guard, and a per-session lease, each one needs its own assertion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For streaming endpoints, I would also test the disconnect path. Close the client in the middle of the stream and check that the lease is released. That is where orphaned generations and socket leaks hide.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A 429 is not a test. The layer is.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/agustin18/fastapi-stream-lease.git
&lt;span class="nb"&gt;cd &lt;/span&gt;fastapi-stream-lease
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt; demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8080&lt;/code&gt;. Start Tab A, then Tab B. Tab B gets &lt;code&gt;429&lt;/code&gt; from the per-session lease. A third session from the same network hits the per-IP guard. No hosted service or API keys are required.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/agustin18/fastapi-stream-lease" rel="noopener noreferrer"&gt;Repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/agustin18/fastapi-stream-lease/releases/tag/v0.6.4" rel="noopener noreferrer"&gt;Release v0.6.4&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/agustin18/fastapi-stream-lease/tree/main/demo" rel="noopener noreferrer"&gt;Demo docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://agustin18.github.io/fastapi-stream-lease/faq/" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run streaming endpoints in production, what layer do you test first?&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>redis</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Why standard rate limiters fail for LLM &amp; SSE streams in FastAPI</title>
      <dc:creator>agustin18</dc:creator>
      <pubDate>Mon, 19 Oct 2026 14:49:48 +0000</pubDate>
      <link>https://dev.to/agustin18/why-standard-rate-limiters-fail-for-llm-sse-streams-in-fastapi-3jce</link>
      <guid>https://dev.to/agustin18/why-standard-rate-limiters-fail-for-llm-sse-streams-in-fastapi-3jce</guid>
      <description>&lt;p&gt;If you are building an AI chat assistant, real-time dashboard, or financial ticker in Python using FastAPI, chances are you are using Server-Sent Events (&lt;code&gt;StreamingResponse&lt;/code&gt;) or WebSockets.&lt;/p&gt;

&lt;p&gt;And if you care about uptime and cloud costs, you probably installed a rate-limiting middleware like &lt;code&gt;slowapi&lt;/code&gt; or configured a Redis token bucket.&lt;/p&gt;

&lt;p&gt;Here is the problem: &lt;strong&gt;standard rate limiters are designed for request frequency, not connection duration.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In this post, we'll break down why traditional rate limiters collapse under streaming workloads, and how to implement a distributed lease pattern using Redis to protect your workers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Root Cause: Frequency vs. Duration
&lt;/h2&gt;

&lt;p&gt;Traditional HTTP endpoints are transactional:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Client sends request.&lt;/li&gt;
&lt;li&gt;Server queries DB (50ms).&lt;/li&gt;
&lt;li&gt;Server returns JSON response.&lt;/li&gt;
&lt;li&gt;Connection closes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In this model, limiting by frequency (&lt;strong&gt;10 requests per minute&lt;/strong&gt;) works reliably.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Happens with Streaming (SSE / LLM Tokens)?
&lt;/h3&gt;

&lt;p&gt;An LLM stream (OpenAI, Claude, Ollama) stays open for &lt;strong&gt;30 to 60 seconds&lt;/strong&gt; while tokens are yielded chunk by chunk.&lt;/p&gt;

&lt;p&gt;If you have a rate limit of &lt;strong&gt;10 requests per minute&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A malicious or impatient user opens &lt;strong&gt;10 browser tabs simultaneously&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Each tab requests an SSE stream.&lt;/li&gt;
&lt;li&gt;The rate limiter inspects the counter: &lt;em&gt;"10 requests in 60s? Allowed!"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The result:&lt;/strong&gt; That single user now holds &lt;strong&gt;10 open sockets concurrently for a full minute&lt;/strong&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Standard Rate Limiting (Permissive Failure):
User Request 1 ──► [OK] ──► Holding Socket (60s) ──────┐
User Request 2 ──► [OK] ──► Holding Socket (60s) ──────┤
User Request 3 ──► [OK] ──► Holding Socket (60s) ──────┼──► ASGI Worker Pool Exhausted!
User Request 4 ──► [OK] ──► Holding Socket (60s) ──────┤    (HTTP 502 / Socket Starvation)
User Request 5 ──► [OK] ──► Holding Socket (60s) ──────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your Uvicorn worker pool has 100 concurrent connection limits, just 10 users doing this simultaneously will &lt;strong&gt;starve the worker&lt;/strong&gt;, causing HTTP 502/504 timeouts for all regular API endpoints.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Solution: Connection Leases, Not Request Counts
&lt;/h2&gt;

&lt;p&gt;To protect streaming endpoints, you need to answer a different question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"How many active streams is this user holding RIGHT NOW?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of an incrementing counter with a TTL, you need a &lt;strong&gt;distributed lease mechanism&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before streaming:&lt;/strong&gt; Try to acquire an atomic slot for &lt;code&gt;user_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If &lt;code&gt;active_slots &amp;gt;= limit&lt;/code&gt;:&lt;/strong&gt; Reject immediately with &lt;strong&gt;HTTP 429 Too Many Requests&lt;/strong&gt; &lt;em&gt;before&lt;/em&gt; triggering the LLM generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;While streaming:&lt;/strong&gt; Hold the lease and periodically refresh it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On completion or client disconnect:&lt;/strong&gt; Release the slot immediately.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Implementing Atomic Leases in Redis (Lua CAS)
&lt;/h2&gt;

&lt;p&gt;A naive implementation using multiple Redis commands (&lt;code&gt;GET&lt;/code&gt; then &lt;code&gt;INCR&lt;/code&gt;) introduces race conditions. Two parallel requests could both read &lt;code&gt;count=1&lt;/code&gt; and both succeed, violating your concurrency limit.&lt;/p&gt;

&lt;p&gt;To make lease acquisition atomic, we use Redis Sorted Sets (&lt;code&gt;ZSET&lt;/code&gt;) where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key:&lt;/strong&gt; &lt;code&gt;stream_lease:{user_id}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Score:&lt;/strong&gt; Current Unix epoch timestamp&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Member:&lt;/strong&gt; Unique &lt;code&gt;lease_id&lt;/code&gt; (UUID)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the atomic Lua script pattern ($O(\log N)$):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;max_leases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;lease_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;-- 1. Remove expired/stale leases&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZREMRANGEBYSCORE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-inf'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Check current active lease count&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;current_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZCARD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_leases&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="c1"&gt;-- Rejected (Concurrency limit reached)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Acquire lease&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZADD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lease_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'EXPIRE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="c1"&gt;-- Allowed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the client disconnects or the generator completes, a cleanup script removes the &lt;code&gt;lease_id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ZREM'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Handling the Edge Cases in Production
&lt;/h2&gt;

&lt;p&gt;In real-world microservices, three critical failure modes occur:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Zombie Tasks on Abrupt Disconnections
&lt;/h3&gt;

&lt;p&gt;If a user closes their laptop mid-stream, Starlette detects the socket disconnect via &lt;code&gt;request.is_disconnected()&lt;/code&gt;. But if your generator doesn't actively poll this flag or catch the &lt;code&gt;CancelledError&lt;/code&gt;, the background task keeps running, consuming downstream AI tokens and keeping the Redis lease alive until expiration.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Redis Sentinel Failover (&lt;code&gt;ReadOnlyError&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;When Redis switches from primary to replica, pending writes raise &lt;code&gt;ReadOnlyError&lt;/code&gt;. If your lease manager doesn't catch this, your entire API crashes. You need an automated &lt;strong&gt;fail-open&lt;/strong&gt; or &lt;strong&gt;fail-closed&lt;/strong&gt; fallback policy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Enter &lt;code&gt;fastapi-stream-lease&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Instead of writing custom Lua scripts, disconnect wrappers, and metrics hooks in every microservice, I packaged this into a small, dependency-light library:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/agustin18/fastapi-stream-lease" rel="noopener noreferrer"&gt;fastapi-stream-lease on GitHub&lt;/a&gt;&lt;/strong&gt; (MIT, 100% test coverage)&lt;/p&gt;

&lt;h3&gt;
  
  
  1-Line Integration Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi_stream_lease&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StreamLeaseManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LeaseConfig&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;manager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StreamLeaseManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis://localhost:6379&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Register 1-line standard 429 exception handler
&lt;/span&gt;&lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_exception_handlers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chat_stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;token_generator&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Token &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# Acquires atomic Redis lease, wraps StreamingResponse,
&lt;/span&gt;    &lt;span class="c1"&gt;# detects disconnects, and releases slot on finish or error.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;token_generator&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/event-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;LeaseConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What You Get Out of the Box:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1-Line Integration:&lt;/strong&gt; Wrap any async generator in &lt;code&gt;manager.stream()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic Concurrency Bounds:&lt;/strong&gt; Backed by Redis Lua CAS ($O(\log N)$).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful Fallback:&lt;/strong&gt; In-memory circuit breaker handles Redis failovers safely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production Observability:&lt;/strong&gt; Native Prometheus and OpenTelemetry gauges (&lt;code&gt;fastapi_stream_lease_active_streams&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilient Reconnection:&lt;/strong&gt; Disconnect hooks clean up slots immediately.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;When designing systems for LLM streaming and SSE, remember:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiters&lt;/strong&gt; control how fast users knock on your door.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lease managers&lt;/strong&gt; control how many rooms they can occupy at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are running streaming endpoints in production, you need both.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/agustin18/fastapi-stream-lease" rel="noopener noreferrer"&gt;github.com/agustin18/fastapi-stream-lease&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/fastapi-stream-lease/" rel="noopener noreferrer"&gt;pypi.org/project/fastapi-stream-lease&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;(Note: English is not my native language; drafted with some AI assistance for translation and phrasing, but the architecture and code come directly from our production experience).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;How are you currently handling streaming concurrency in your stack? Feedback and architecture critiques are welcome in the comments!&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
