<?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: Emre Kadir Dağdelen</title>
    <description>The latest articles on DEV Community by Emre Kadir Dağdelen (@dagdelean).</description>
    <link>https://dev.to/dagdelean</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%2F4026094%2F52f9a3c3-20e6-4328-8c24-2a097300bf82.jpg</url>
      <title>DEV Community: Emre Kadir Dağdelen</title>
      <link>https://dev.to/dagdelean</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dagdelean"/>
    <language>en</language>
    <item>
      <title>Make your LLM API calls resilient in Python</title>
      <dc:creator>Emre Kadir Dağdelen</dc:creator>
      <pubDate>Thu, 23 Jul 2026 07:45:05 +0000</pubDate>
      <link>https://dev.to/dagdelean/make-your-llm-api-calls-resilient-in-python-56pn</link>
      <guid>https://dev.to/dagdelean/make-your-llm-api-calls-resilient-in-python-56pn</guid>
      <description>&lt;p&gt;If your app calls an LLM API (OpenAI, Anthropic, anything), you have already&lt;br&gt;
met its failure modes: 429 rate limits, occasional 500s, requests that hang,&lt;br&gt;
and the tail latency where one call in fifty takes ten seconds. In a demo you&lt;br&gt;
ignore this. In production it is most of your incidents.&lt;/p&gt;

&lt;p&gt;Here is a compact way to handle all of it, using nopanic (pip install&lt;br&gt;
nopanic), a zero-dependency resilience toolkit. Everything below works the&lt;br&gt;
same on sync and async functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem, honestly
&lt;/h2&gt;

&lt;p&gt;A naive call looks like this:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async def ask(prompt):
    return await client.chat.completions.create(...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Every failure mode above takes down the request. Worse, the naive fix&lt;br&gt;
(wrap it in a retry loop) can make an outage worse: if the provider is&lt;br&gt;
struggling and everyone retries immediately, the retries pile on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry, but politely
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from nopanic import retry, backoff

@retry(attempts=4, on=RateLimitError,
       backoff=backoff.full_jitter(base=0.5, cap=30.0))
async def ask(prompt): ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Full jitter spreads the retries out randomly so they do not arrive in a&lt;br&gt;
synchronized wave. If the error carries a Retry-After hint, nopanic honors&lt;br&gt;
it (capped, so a hostile value cannot park your client).&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop hammering a provider that is down
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from nopanic import circuit_breaker

llm = circuit_breaker(failure_threshold=0.5, reset_timeout=20.0)

@retry(...)
@llm
async def ask(prompt): ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Once half the calls in the window fail, the breaker opens and fails fast for&lt;br&gt;
20 seconds instead of sending doomed requests. Then it lets one probe through&lt;br&gt;
to check if the provider recovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bound every attempt, and degrade instead of crashing
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from nopanic import compose, fallback, timeout, CircuitOpen

resilient = compose(
    fallback(lambda e: "Sorry, please try again shortly.",
             on=(RateLimitError, CircuitOpen, TimeoutError)),
    retry(attempts=3, on=RateLimitError, backoff=backoff.full_jitter(0.5)),
    llm,
    timeout(30.0),
)

@resilient
async def ask(prompt): ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Read it from the inside out: each attempt gets 30 seconds, outcomes feed the&lt;br&gt;
breaker, rate limits retry with jitter, and anything still unhandled becomes&lt;br&gt;
a graceful message instead of a stack trace reaching your user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the tail, not just the median
&lt;/h2&gt;

&lt;p&gt;For idempotent reads, if a call is slow, race a second one:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from nopanic import hedge

@hedge(delay=0.8)
async def embed(text): ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;If the first attempt has not answered in 800 ms, a duplicate is sent and&lt;br&gt;
whichever returns first wins. This trades a little extra load for a much&lt;br&gt;
better p99.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;None of these patterns are new. What is annoying is wiring them together from&lt;br&gt;
three different libraries. Putting the whole stack in one place, with one API&lt;br&gt;
that reads top to bottom, is the entire idea. Full docs and source:&lt;br&gt;
github.com/dagdelenemre/nopanic&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A circuit breaker bug that turned 3.2M calls into 40 minutes</title>
      <dc:creator>Emre Kadir Dağdelen</dc:creator>
      <pubDate>Wed, 22 Jul 2026 12:18:42 +0000</pubDate>
      <link>https://dev.to/dagdelean/a-circuit-breaker-bug-that-turned-32m-calls-into-40-minutes-4ba</link>
      <guid>https://dev.to/dagdelean/a-circuit-breaker-bug-that-turned-32m-calls-into-40-minutes-4ba</guid>
      <description>&lt;p&gt;I maintain nopanic, a small resilience toolkit for Python (retries, circuit&lt;br&gt;
breakers, timeouts, rate limits). Everything passed its tests, so I did what&lt;br&gt;
I should have done earlier: I put it under real load. The result taught me a&lt;br&gt;
lesson worth writing down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A circuit breaker watches the failure rate of a dependency. If too many&lt;br&gt;
calls fail inside a time window, it "opens" and fails fast instead of&lt;br&gt;
hammering something that is already down. To know the failure rate, it has&lt;br&gt;
to remember recent outcomes.&lt;/p&gt;

&lt;p&gt;My first implementation remembered them the obvious way: a list of&lt;br&gt;
(timestamp, ok/fail) records inside a sliding time window. On every failure&lt;br&gt;
it counted how many entries in that list were failures, divided by the&lt;br&gt;
total, and compared to the threshold.&lt;/p&gt;

&lt;p&gt;Correct. All tests green. Shipped.&lt;/p&gt;

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

&lt;p&gt;I wrote a stress test: 32 threads, 100,000 calls each, one shared breaker,&lt;br&gt;
about a third of the calls failing (below the trip threshold, so the breaker&lt;br&gt;
stays closed and keeps recording). 3.2 million calls total.&lt;/p&gt;

&lt;p&gt;It took 2,413 seconds. Forty minutes. Single threaded, the breaker does&lt;br&gt;
roughly 850,000 calls per second, so those calls should have finished in&lt;br&gt;
about four seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnosis
&lt;/h2&gt;

&lt;p&gt;Two problems, one root cause.&lt;/p&gt;

&lt;p&gt;First, memory. The window kept one record per call. At high throughput that&lt;br&gt;
list holds every call inside the window. I measured it: one million calls in&lt;br&gt;
the window was about 88 MB, for a single breaker. It grows with traffic,&lt;br&gt;
without bound.&lt;/p&gt;

&lt;p&gt;Second, and worse, time. On every failure the code recomputed the failure&lt;br&gt;
count by scanning the entire list. As the list grew, each scan grew with it.&lt;br&gt;
N failures, each doing O(N) work, is O(N squared). Under sustained partial&lt;br&gt;
failure the whole thing collapses into a quadratic crawl, and because the&lt;br&gt;
scan happens inside the breaker's lock, all the threads pile up behind it.&lt;/p&gt;

&lt;p&gt;That is the forty minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The realization
&lt;/h2&gt;

&lt;p&gt;The fix was not a faster scan. It was noticing that a circuit breaker's&lt;br&gt;
memory is not a log. It only needs to answer one question: what is the&lt;br&gt;
failure rate right now. It never needs the individual records. I was storing&lt;br&gt;
a detailed history to compute a single ratio.&lt;/p&gt;

&lt;p&gt;Detailed per-request history is a real need, but it belongs in an&lt;br&gt;
observability stream that the user forwards to their own logging, not in the&lt;br&gt;
breaker's hot path. Keeping millions of records in RAM to compute one&lt;br&gt;
percentage was the actual bug.&lt;/p&gt;

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

&lt;p&gt;Replace the per-call list with a fixed set of time buckets, each holding two&lt;br&gt;
integers: how many calls, how many failures. Keep running totals updated as&lt;br&gt;
buckets rotate out of the window. This is how Hystrix and resilience4j do&lt;br&gt;
it, and now I understand why.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recording an outcome: O(1).&lt;/li&gt;
&lt;li&gt;Reading the failure rate: O(1), from the running totals.&lt;/li&gt;
&lt;li&gt;Memory: constant, no matter the traffic. Ten buckets of two integers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same 3.2 million call load now finishes in about 2.4 seconds instead of&lt;br&gt;
40 minutes. One million in-window calls use 0.001 MB instead of 88. The&lt;br&gt;
per-call cost on the success path even dropped slightly.&lt;/p&gt;

&lt;p&gt;The trade-off: outcomes now expire in bucket-sized steps instead of at an&lt;br&gt;
exact per-call age. For deciding whether to trip a breaker, that precision&lt;br&gt;
was never worth its price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two lessons
&lt;/h2&gt;

&lt;p&gt;First, tests that check correctness do not check behavior under load. My&lt;br&gt;
suite was green the entire time the library was quadratic. A partially&lt;br&gt;
failing dependency at high traffic, the exact situation a circuit breaker&lt;br&gt;
exists for, was the situation that broke it.&lt;/p&gt;

&lt;p&gt;Second, a bonus one I tripped over while fixing this: I was testing with an&lt;br&gt;
editable install, and a stale copy of the package in site-packages was&lt;br&gt;
shadowing my source. My tests were passing against old code. Now the CI also&lt;br&gt;
builds the wheel, installs it into a clean environment, and runs the suite&lt;br&gt;
against the installed artifact. Never fully trust &lt;code&gt;pip install -e&lt;/code&gt; alone.&lt;/p&gt;

&lt;p&gt;The library is nopanic (pip install nopanic) if you want to see the code&lt;/p&gt;

</description>
      <category>python</category>
      <category>performance</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building the Polly that Python never had</title>
      <dc:creator>Emre Kadir Dağdelen</dc:creator>
      <pubDate>Mon, 13 Jul 2026 10:53:15 +0000</pubDate>
      <link>https://dev.to/dagdelean/building-the-polly-that-python-never-had-1olm</link>
      <guid>https://dev.to/dagdelean/building-the-polly-that-python-never-had-1olm</guid>
      <description>&lt;h1&gt;
  
  
  Building the Polly that Python never had
&lt;/h1&gt;

&lt;p&gt;Every app that calls external APIs faces the same four problems: transient&lt;br&gt;
failures, dead dependencies, rate limits, and slow responses. Java solved&lt;br&gt;
this with resilience4j. .NET solved it with Polly. Python never had a&lt;br&gt;
unified answer: tenacity does retries, pybreaker does circuit breaking&lt;br&gt;
(sync only, aging), and everything else you wire by hand.&lt;/p&gt;

&lt;p&gt;These patterns only pay off when they compose: a timeout per attempt,&lt;br&gt;
retries that respect an open circuit, a fallback that absorbs the rest.&lt;br&gt;
Wiring that from three libraries with three philosophies is the code&lt;br&gt;
nobody wants to own. So I built nopanic.&lt;/p&gt;

&lt;h2&gt;
  
  
  One decorator API, sync and async
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from nopanic import compose, fallback, retry, circuit_breaker, timeout, backoff, CircuitOpen

llm = circuit_breaker(failure_threshold=0.5, reset_timeout=20.0, name="llm")

resilient = compose(
    fallback(lambda e: "temporarily unavailable", on=(ConnectionError, CircuitOpen, TimeoutError)),
    retry(attempts=3, on=(ConnectionError, TimeoutError), backoff=backoff.full_jitter(0.2)),
    llm,
    timeout(30.0),
)

@resilient
async def ask(prompt: str) -&amp;gt; str: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Reading inside out: each attempt gets 30 seconds, outcomes feed the&lt;br&gt;
breaker, transient failures retry with jitter, and whatever is left&lt;br&gt;
becomes a degraded answer instead of a traceback. The same decorators&lt;br&gt;
work on plain sync functions. Zero runtime dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design choices that mattered
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No wrapper exceptions.&lt;/strong&gt; Exhausted retries re-raise the original error.&lt;br&gt;
Your &lt;code&gt;except ConnectionError:&lt;/code&gt; keeps working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Injectable time.&lt;/strong&gt; Breakers, rate limiters and caches take a &lt;code&gt;clock=&lt;/code&gt;&lt;br&gt;
parameter, so the test suite (141 tests) runs in under two seconds with&lt;br&gt;
zero sleeps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability as a stream.&lt;/strong&gt; &lt;code&gt;events.subscribe()&lt;/code&gt; sees everything every&lt;br&gt;
policy does. With no listeners the cost is one attribute read (~0.1us).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measured overhead.&lt;/strong&gt; Every policy costs 0.15 to 1.3 microseconds per&lt;br&gt;
call on the success path. A fast HTTP round trip is 5,000+ us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation: retrofitting a 232k-star repo
&lt;/h2&gt;

&lt;p&gt;To test the API against reality, I took network scripts from&lt;br&gt;
geekcomputers/Python and added resilience: three decorators per call&lt;br&gt;
site, no restructuring. The exercise even improved the design: my first&lt;br&gt;
retry policy naively retried a 403, which led to the documented&lt;br&gt;
"retry 429/5xx, never other 4xx" recipe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that only exists on Windows
&lt;/h2&gt;

&lt;p&gt;The best story came from CI. Tests passed everywhere except Windows on&lt;br&gt;
Python 3.11/3.12. After reproducing locally and instrumenting, the data&lt;br&gt;
showed &lt;code&gt;time.sleep(0.05)&lt;/code&gt; waking after 47ms by the monotonic clock, and&lt;br&gt;
sub-quantum sleeps returning instantly. My retry honored a server's&lt;br&gt;
Retry-After by sleeping exactly that long, then racing the deadline and&lt;br&gt;
losing, burning attempts against a still-closed breaker window.&lt;/p&gt;

&lt;p&gt;The fix belongs in the library, not the test: "retry after X" means "not&lt;br&gt;
before X", so honored hints now sleep X * 1.05 + 50ms, still capped so a&lt;br&gt;
hostile server cannot park your client. If your code races deadlines&lt;br&gt;
exactly, Windows will eventually teach you the same lesson.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adaptive rate limiting: stop hardcoding guesses
&lt;/h2&gt;

&lt;p&gt;The newest addition applies TCP's AIMD congestion control to API quotas:&lt;br&gt;
a 429 cuts your rate multiplicatively, successes recover it additively,&lt;br&gt;
and Retry-After blocks the bucket for exactly that long. You converge on&lt;br&gt;
the rate the server actually sustains.&lt;/p&gt;

&lt;p&gt;pip install nopanic — GitHub: github.com/dagdelenemre/nopanic&lt;br&gt;
Feedback and issues welcome, especially from Polly and resilience4j users.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
