<?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: Gabriel Dressler</title>
    <description>The latest articles on DEV Community by Gabriel Dressler (@dresslert).</description>
    <link>https://dev.to/dresslert</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%2F4138244%2F33dfa013-466d-466a-b6ff-6f057aeea838.jpg</url>
      <title>DEV Community: Gabriel Dressler</title>
      <link>https://dev.to/dresslert</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dresslert"/>
    <language>en</language>
    <item>
      <title>Idempotent Confirmations, Safe Retries: Two Fixes for Payment Infrastructure</title>
      <dc:creator>Gabriel Dressler</dc:creator>
      <pubDate>Wed, 23 Sep 2026 14:39:36 +0000</pubDate>
      <link>https://dev.to/dresslert/idempotent-confirmations-safe-retries-two-fixes-for-payment-infrastructure-101h</link>
      <guid>https://dev.to/dresslert/idempotent-confirmations-safe-retries-two-fixes-for-payment-infrastructure-101h</guid>
      <description>&lt;p&gt;Say you're building a payment confirmation endpoint. A client calls &lt;code&gt;POST /payments/{id}/confirm&lt;/code&gt;, your API Gateway routes it to a backend service, and that service either confirms the payment or — if it's already confirmed, which happens constantly with retried client requests — returns nothing new: an HTTP 204 No Content. Meanwhile, confirming with the actual payment processor is slow and occasionally fails transiently, so it runs as a background task with retries.&lt;/p&gt;

&lt;p&gt;That one endpoint hits two separate, well-documented gotchas that aren't obvious until they bite you. Here's both, with the actual fix.&lt;/p&gt;

&lt;h4&gt;
  
  
  Gotcha #1: your gateway can turn a valid 204 into a broken 500
&lt;/h4&gt;

&lt;p&gt;If you're running KrakenD in front of that backend, here's what happens by default: the backend correctly returns &lt;code&gt;204 No Content&lt;/code&gt;, and KrakenD's response-composition layer — which normally merges and re-encodes backend responses before sending them to the client — treats the empty body as invalid input and returns a &lt;code&gt;500&lt;/code&gt; instead. This isn't a rare edge case; it's filed as a &lt;a href="https://github.com/krakend/krakend-ce/issues/744" rel="noopener noreferrer"&gt;known issue in KrakenD's own repository&lt;/a&gt;, and the reason is structural, not a bug: KrakenD's default encoding assumes there's a body to process, merge, or re-encode.&lt;/p&gt;

&lt;p&gt;The fix is a specific config flag, &lt;code&gt;no-op&lt;/code&gt; output encoding, documented in &lt;a href="https://www.krakend.io/docs/endpoints/no-op/" rel="noopener noreferrer"&gt;KrakenD's own docs&lt;/a&gt;:&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;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/payments/{id}/confirm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"output_encoding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"no-op"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url_pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/internal/payments/{id}/confirm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"http://payment-service:8080"&lt;/span&gt;&lt;span class="p"&gt;]&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;span class="p"&gt;]&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;&lt;code&gt;no-op&lt;/code&gt; tells KrakenD to stop processing the response entirely for that endpoint — body, headers, and status code are passed straight through from the backend, whatever they are. The trade-off is real: you lose response composition (merging multiple backend calls into one payload) on any endpoint using &lt;code&gt;no-op&lt;/code&gt;, so it's the right call for a single-backend passthrough like a confirmation endpoint, and the wrong call for an aggregation endpoint that legitimately needs to combine several backend responses into one.&lt;/p&gt;

&lt;h4&gt;
  
  
  Gotcha #2: Dramatiq's retry defaults are not what you'd guess
&lt;/h4&gt;

&lt;p&gt;The background task confirming with the processor needs retries — but not blind ones. A network timeout should retry. A processor response of "card declined" absolutely should not — retrying that isn't resilience, it's a bug that could double-charge or spam a processor with requests that will never succeed.&lt;/p&gt;

&lt;p&gt;Dramatiq's &lt;a href="https://dramatiq.io/reference.html" rel="noopener noreferrer"&gt;&lt;code&gt;@dramatiq.actor&lt;/code&gt;&lt;/a&gt; decorator supports &lt;code&gt;max_retries&lt;/code&gt;, &lt;code&gt;min_backoff&lt;/code&gt;, &lt;code&gt;max_backoff&lt;/code&gt;, and a &lt;code&gt;retry_when&lt;/code&gt; predicate for exactly this. What catches people off guard: the built-in defaults are &lt;code&gt;min_backoff=15000&lt;/code&gt; (15 seconds) and &lt;code&gt;max_backoff=604800000&lt;/code&gt; (7 days) — sensible for a low-priority background job, almost certainly wrong for a payment confirmation a user is waiting on. Left at the defaults, a retryable failure on attempt one won't try again for 15+ seconds, and a task stuck retrying could keep backing off for a week.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dramatiq&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;should_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retries_so_far&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Only retry transient failures. A declined payment or invalid
&lt;/span&gt;    &lt;span class="c1"&gt;# request is not transient — retrying it is a correctness bug,
&lt;/span&gt;    &lt;span class="c1"&gt;# not resilience.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TimeoutError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ConnectionError&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;retries_so_far&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;

&lt;span class="nd"&gt;@dramatiq.actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;min_backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# 1s — the 15s default is too slow for a user-facing confirmation
&lt;/span&gt;    &lt;span class="n"&gt;max_backoff&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# cap at 1 minute instead of the 7-day default
&lt;/span&gt;    &lt;span class="n"&gt;retry_when&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;should_retry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;confirm_payment_with_processor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment_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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;retry_when&lt;/code&gt; predicate is the part that actually matters for correctness here, not just the backoff tuning: it's what stops Dramatiq from retrying a legitimately declined payment as if it were a flaky network call.&lt;/p&gt;

&lt;h4&gt;
  
  
  Why both matter together
&lt;/h4&gt;

&lt;p&gt;Neither of these is a deep architectural decision — they're both small, specific config choices. But they're the kind of thing that's invisible in a demo and very visible in production: the gateway returning 500 for a perfectly valid confirmation, or a background worker silently retrying a declined charge five times over five days because nobody overrode the defaults. Payment systems don't get to treat these as edge cases — they're the normal path.&lt;/p&gt;

&lt;h4&gt;
  
  
  Sources
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/krakend/krakend-ce/issues/744" rel="noopener noreferrer"&gt;KrakenD 204 No Content issue — krakend-ce#744&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.krakend.io/docs/endpoints/no-op/" rel="noopener noreferrer"&gt;KrakenD &lt;code&gt;no-op&lt;/code&gt; output encoding — official docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dramatiq.io/reference.html" rel="noopener noreferrer"&gt;Dramatiq API reference — retries, backoff, &lt;code&gt;retry_when&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>infrastructure</category>
    </item>
  </channel>
</rss>
