<?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: bitpage</title>
    <description>The latest articles on DEV Community by bitpage (@bitpage).</description>
    <link>https://dev.to/bitpage</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%2F4069501%2F58e87d13-ae03-4f4d-a643-7efb9fdf0a0b.png</url>
      <title>DEV Community: bitpage</title>
      <link>https://dev.to/bitpage</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bitpage"/>
    <language>en</language>
    <item>
      <title>Gmail bounced everything the relay sent: three broken auth mechanisms and a silent OpenDKIM</title>
      <dc:creator>bitpage</dc:creator>
      <pubDate>Sun, 06 Sep 2026 05:11:15 +0000</pubDate>
      <link>https://dev.to/bitpage/gmail-bounced-everything-the-relay-sent-three-broken-auth-mechanisms-and-a-silent-opendkim-1cgl</link>
      <guid>https://dev.to/bitpage/gmail-bounced-everything-the-relay-sent-three-broken-auth-mechanisms-and-a-silent-opendkim-1cgl</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; Three independent mechanisms were broken at once, and fixing any one of them changed nothing visible. The domain had no &lt;code&gt;v=spf1&lt;/code&gt; record (only a dead SenderID one), the PTR resolved forward to a different server, and OpenDKIM was signing nothing because &lt;code&gt;KeyTable&lt;/code&gt; and &lt;code&gt;SigningTable&lt;/code&gt; were attached as &lt;code&gt;refile:&lt;/code&gt; while their contents were in plain two-column format. That last one cost the most time: when OpenDKIM can't find a key it logs absolutely nothing, so the daemon reads &lt;code&gt;active&lt;/code&gt;, the config parses, the key is readable, and mail still leaves unsigned.&lt;/p&gt;

&lt;p&gt;The server is an outgoing relay for transactional mail from a couple of dozen application hosts: password resets, notifications, alerts. Inbound mail for the domain goes elsewhere, to Google Workspace as MX. Postfix on a legacy container with more than two years of uptime, OpenDKIM 2.10.3, no Ansible, edits by hand only. The daily log summary read a four-digit &lt;code&gt;bounced&lt;/code&gt; against a single-digit &lt;code&gt;sent&lt;/code&gt;: thousands of rejections, a handful of deliveries. Password reset mail was among the casualties, so people could not get back into their accounts. Nobody had changed anything on this box in years, which turned out to be the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Root cause (DKIM):&lt;/strong&gt; &lt;code&gt;KeyTable&lt;/code&gt;/&lt;code&gt;SigningTable&lt;/code&gt; mounted as &lt;code&gt;refile:&lt;/code&gt; while the entries were plain &lt;code&gt;example.org mail._domainkey.example.org&lt;/code&gt; lines. Under &lt;code&gt;refile:&lt;/code&gt; the key is a regex matched against the whole &lt;code&gt;From:&lt;/code&gt; address, so &lt;code&gt;example.org&lt;/code&gt; never matches &lt;code&gt;noreply@example.org&lt;/code&gt;. Changing one word per line to &lt;code&gt;file:&lt;/code&gt; fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also broken:&lt;/strong&gt; &lt;code&gt;InternalHosts&lt;/code&gt; listed fewer than ten addresses against a Postfix &lt;code&gt;mynetworks&lt;/code&gt; two to three times longer. Mail from the app hosts was being verified instead of signed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also broken:&lt;/strong&gt; the only SPF-shaped TXT record was &lt;code&gt;spf2.0/mfrom,pra&lt;/code&gt; SenderID. RFC 7208 discards anything that doesn't begin with &lt;code&gt;v=spf1&lt;/code&gt;, so the domain effectively had no SPF record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also broken:&lt;/strong&gt; the PTR pointed at a hostname whose A record led to a different machine. Forward-confirmed reverse DNS failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trap:&lt;/strong&gt; &lt;code&gt;grep -c DKIM-Signature /var/log/mail.log&lt;/code&gt; returns &lt;code&gt;0&lt;/code&gt; whether signing works or not. Postfix does not write message headers to the log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trap:&lt;/strong&gt; testing with &lt;code&gt;sendmail&lt;/code&gt; from the mail server itself passes, because &lt;code&gt;127.0.0.1&lt;/code&gt; was in &lt;code&gt;InternalHosts&lt;/code&gt;. Real traffic from other hosts kept going out bare.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why a server nobody touched stopped being deliverable
&lt;/h2&gt;

&lt;p&gt;The requirements moved while the configuration stood still. Gmail began requiring authentication from bulk senders in February 2024 and has been ramping enforcement since, and a config that predated those rules simply stopped qualifying (&lt;a href="https://support.google.com/mail/answer/81126" rel="noopener noreferrer"&gt;Email sender guidelines&lt;/a&gt;). There was no change window to roll back to, no deploy to blame. The rejection came back verbatim as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;550-5.7.26 Your email has been blocked because the sender is unauthenticated.
550-5.7.26 Gmail requires all senders to authenticate with either SPF or DKIM.
550-5.7.26  Authentication results&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="nt"&gt;550-5.7.26  DKIM = did not pass
550-5.7.26  SPF [example.org] with ip&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; [203.0.113.51] = did not pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both named mechanisms failing at once should have been the tell that this was not one bug. I read it as one bug anyway and spent the next hour paying for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three of the four facts in my own notes were wrong
&lt;/h2&gt;

&lt;p&gt;I started from a ticket written a day earlier that already contained a diagnosis, and rechecking it by hand killed three of its four claims. The notes said: PTR is fine and forward and reverse match; DNS is hosted on Cloudflare; DMARC is absent.&lt;/p&gt;

&lt;p&gt;The PTR pointed at &lt;code&gt;legacy.example.org&lt;/code&gt;, whose A record led to &lt;code&gt;198.51.100.216&lt;/code&gt;, a different server entirely. &lt;code&gt;dig NS&lt;/code&gt; returned &lt;code&gt;ns-*.awsdns-*&lt;/code&gt;, so DNS was on Route 53, not Cloudflare. DMARC was present and had been all along, &lt;code&gt;v=DMARC1; p=none&lt;/code&gt;. One claim out of four survived. These were my own notes, from the previous day.&lt;/p&gt;

&lt;p&gt;The other rejection message had been telling me about the PTR the whole time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;550-5.7.25 [203.0.113.51] The IP address sending this message does not have a PTR
550-5.7.25 record setup, or the corresponding forward DNS entry does not match the sending IP
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The operative half is the second clause. Not "you have no PTR record" but "the forward entry doesn't match". I had read to the first familiar phrase, seen a PTR existed, and moved on. RFC 1912 §2.1 states the rule plainly: "Make sure your PTR and A records match. For every IP address, there should be a matching PTR record in the in-addr.arpa domain" (&lt;a href="https://www.rfc-editor.org/rfc/rfc1912.txt" rel="noopener noreferrer"&gt;RFC 1912&lt;/a&gt;). The same section warns that getting this wrong "can cause loss of Internet services similar to not being registered in the DNS at all", which is a fair description of a bounce column four digits wide.&lt;/p&gt;

&lt;p&gt;The fix had two possible shapes. Editing the A record of &lt;code&gt;legacy.example.org&lt;/code&gt; to point here would have satisfied the check, but that hostname is live and fronts an unrelated service, so the blast radius covered somebody else's system. Repointing the PTR to &lt;code&gt;mail.example.org&lt;/code&gt;, whose A record already resolved to the sending IP, changed nothing outside this server. I also set Postfix &lt;code&gt;myhostname&lt;/code&gt; to &lt;code&gt;mail.example.org&lt;/code&gt; so the HELO name matches the PTR.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the SPF record that existed didn't count
&lt;/h2&gt;

&lt;p&gt;There was a TXT record on the domain, and it was worth exactly nothing, because it was a SenderID record rather than an SPF one. It began &lt;code&gt;spf2.0/mfrom,pra&lt;/code&gt;. RFC 7208 §4.5 is unambiguous about what a verifier does with that: "Starting with the set of records that were returned by the lookup, discard records that do not begin with a version section of exactly 'v=spf1'" (&lt;a href="https://www.rfc-editor.org/rfc/rfc7208.txt" rel="noopener noreferrer"&gt;RFC 7208&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Discarded means the domain had no SPF record at all as far as Gmail was concerned, which is precisely what &lt;code&gt;SPF [example.org] ... = did not pass&lt;/code&gt; was reporting. A record that looks like SPF to a human and is invisible to every verifier is worse than an empty zone, because it stops anyone from looking further. Published in its place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;v&lt;/span&gt;=&lt;span class="n"&gt;spf1&lt;/span&gt; &lt;span class="n"&gt;ip4&lt;/span&gt;:&lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;51&lt;/span&gt; &lt;span class="n"&gt;include&lt;/span&gt;:&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="n"&gt;spf&lt;/span&gt;.&lt;span class="n"&gt;google&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt; &lt;span class="n"&gt;include&lt;/span&gt;:&lt;span class="n"&gt;spf&lt;/span&gt;.&lt;span class="n"&gt;example&lt;/span&gt;.&lt;span class="n"&gt;net&lt;/span&gt; ~&lt;span class="n"&gt;all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why grepping the mail log for DKIM-Signature proves nothing
&lt;/h2&gt;

&lt;p&gt;Postfix does not write message headers to its log, so the count is zero whether signing works or not. My first check was &lt;code&gt;grep -c "DKIM-Signature" /var/log/mail.log&lt;/code&gt;, which returned &lt;code&gt;0&lt;/code&gt;, and I took that as evidence that nothing was being signed. It wasn't evidence of anything. The method cannot distinguish a broken signer from a working one, and I used it as the basis for the next hour of work.&lt;/p&gt;

&lt;p&gt;The check that does work is to send a message to a local mailbox and read the headers of the delivered file, &lt;code&gt;/var/mail/nobody&lt;/code&gt;. That shows the actual &lt;code&gt;DKIM-Signature&lt;/code&gt; header or its absence, with no inference in between. Building that first would have made every subsequent change a one-minute yes or no. I built it fourth.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenDKIM reports active, parses clean, and signs nothing
&lt;/h2&gt;

&lt;p&gt;OpenDKIM writes no log line when it fails to find a signing key for a message, so &lt;code&gt;systemctl is-active&lt;/code&gt; returning &lt;code&gt;active&lt;/code&gt; alongside a clean startup is zero evidence that signing happens. I found and fixed two genuine defects on the strength of that assumption, and neither produced a signature.&lt;/p&gt;

&lt;p&gt;The first was the config itself. &lt;code&gt;/etc/opendkim.conf&lt;/code&gt; had been truncated to four lines, with no &lt;code&gt;Mode&lt;/code&gt;, no &lt;code&gt;KeyTable&lt;/code&gt;, no &lt;code&gt;SigningTable&lt;/code&gt;. The fingerprints of a package upgrade were right there: &lt;code&gt;/etc/opendkim/&lt;/code&gt; had been touched recently while the files inside it were years older. I restored the missing directives and restarted. Clean start, no errors, no signature.&lt;/p&gt;

&lt;p&gt;The second was key ownership. The private key belonged to &lt;code&gt;_apt:uuidd&lt;/code&gt; with mode &lt;code&gt;0600&lt;/code&gt;, and the daemon runs as &lt;code&gt;opendkim&lt;/code&gt;. Checking as the service user rather than as root is the only way to see this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;su &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/sh opendkim &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"head -c 20 /etc/opendkim/default/mail.private"&lt;/span&gt;
&lt;span class="nb"&gt;head&lt;/span&gt;: cannot open &lt;span class="s1"&gt;'/etc/opendkim/default/mail.private'&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;reading: Permission denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real defect, and &lt;code&gt;chown opendkim:opendkim&lt;/code&gt; is mandatory. The key became readable. Still no signature. Two genuine bugs repaired, both necessary, neither sufficient, and my supply of hypotheses was gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual root cause was one word, and it was in the man page
&lt;/h2&gt;

&lt;p&gt;The tables were attached as &lt;code&gt;refile:&lt;/code&gt; while their contents were in plain two-column format, and under &lt;code&gt;refile:&lt;/code&gt; the lookup key is treated as a regex matched against the entire &lt;code&gt;From:&lt;/code&gt; address. I stopped guessing and opened &lt;code&gt;man opendkim.conf&lt;/code&gt; on the server itself, same 2.10.3 as the running binary, which is more authoritative than whatever version the web is serving. The answer sits in one paragraph about dataset types:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"If this table specifies a regular expression file ("refile"), then the keys are wildcard patterns that are matched against the address found in the From: header field. For all other database types, the full user@host is checked first, then simply host, then &lt;a href="mailto:user@.domain"&gt;user@.domain&lt;/a&gt; (with all superdomains checked in sequence…)"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The tables held lines like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;example.org  mail._domainkey.example.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under &lt;code&gt;refile:&lt;/code&gt;, the pattern &lt;code&gt;example.org&lt;/code&gt; is matched against &lt;code&gt;noreply@example.org&lt;/code&gt; and does not match, so no key is found and the message leaves unsigned without a single log line. Under &lt;code&gt;file:&lt;/code&gt;, the lookup falls through to the second step, "then simply host", where &lt;code&gt;example.org&lt;/code&gt; matches exactly. The fix was one word in two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-KeyTable                refile:/etc/opendkim/KeyTable
-SigningTable            refile:/etc/opendkim/SigningTable
&lt;/span&gt;&lt;span class="gi"&gt;+KeyTable                file:/etc/opendkim/KeyTable
+SigningTable            file:/etc/opendkim/SigningTable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other direction works too: rewrite every table entry to &lt;code&gt;*@example.org&lt;/code&gt; and keep &lt;code&gt;refile:&lt;/code&gt;. I rejected it because the tables covered several domains, and rewriting every line of data is a wider edit than changing the dataset type to the one that already matches the format on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second half of the same bug: InternalHosts was a fraction of mynetworks
&lt;/h2&gt;

&lt;p&gt;OpenDKIM only signs mail arriving from hosts in &lt;code&gt;InternalHosts&lt;/code&gt;; everything else it verifies instead, so any app server missing from that list was sending bare mail by design. The man page states the whole behaviour in one line, describing the option as identifying "a set internal hosts whose mail should be signed rather than verified" (typo included, as printed in 2.10.3).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;InternalHosts&lt;/code&gt; pointed at &lt;code&gt;TrustedHosts&lt;/code&gt;, which listed fewer than ten addresses. Postfix &lt;code&gt;mynetworks&lt;/code&gt; listed two to three times as many. Syncing the short list against the long one tripled it, finally covering the application hosts that had been relaying through a server which was, on paper, configured to sign their mail. The two files are not required to match exactly, since they answer different questions, but a host that can relay and cannot be signed is almost always an oversight rather than a decision.&lt;/p&gt;

&lt;p&gt;This is also why my manual test had lied to me earlier. Mail sent with &lt;code&gt;sendmail&lt;/code&gt; from the relay itself originates from &lt;code&gt;127.0.0.1&lt;/code&gt;, which was in the list, so a signature appeared and I briefly thought the job was done. The traffic that actually mattered came from the application hosts, none of them in the file. Test along the path production traffic takes, not the path that is convenient from an SSH session.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four defects and the check that actually proves each one
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Broken&lt;/th&gt;
&lt;th&gt;Symptom you see&lt;/th&gt;
&lt;th&gt;Check that settles it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;v=spf1&lt;/code&gt; record, SenderID only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SPF … = did not pass&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dig +short TXT example.org&lt;/code&gt; — the string must begin &lt;code&gt;v=spf1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PTR forward mismatch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;550-5.7.25 … forward DNS entry does not match&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dig -x &amp;lt;ip&amp;gt;&lt;/code&gt;, then resolve that name's A record back — it must return &lt;code&gt;&amp;lt;ip&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;refile:&lt;/code&gt; on plain-format tables&lt;/td&gt;
&lt;td&gt;Nothing. No log line at all.&lt;/td&gt;
&lt;td&gt;Read the &lt;code&gt;DKIM-Signature&lt;/code&gt; header of a delivered message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;InternalHosts&lt;/code&gt; narrower than &lt;code&gt;mynetworks&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Signs locally, unsigned from apps&lt;/td&gt;
&lt;td&gt;Send from a real application host, not from the relay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is the one worth internalising. Three of these four failures announce themselves somewhere. The dataset-type bug produces no error, no warning, and a healthy-looking service, and the only way to observe it is to inspect the output rather than the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Build the verification path before touching anything: send to a local mailbox and read the delivered headers. Every later change then gives an immediate yes or no.&lt;/li&gt;
&lt;li&gt;Recheck every fact you were handed, including notes you wrote yourself yesterday.&lt;/li&gt;
&lt;li&gt;Read the whole SMTP rejection. &lt;code&gt;5.7.25&lt;/code&gt; and &lt;code&gt;5.7.26&lt;/code&gt; name different failures, and in both the specificity lives in the second clause of the sentence.&lt;/li&gt;
&lt;li&gt;Resolve the sending IP with &lt;code&gt;dig -x&lt;/code&gt;, then resolve the answer forward. Both directions, or it doesn't count.&lt;/li&gt;
&lt;li&gt;Confirm the TXT record begins with &lt;code&gt;v=spf1&lt;/code&gt; rather than merely mentioning SPF.&lt;/li&gt;
&lt;li&gt;For DKIM: confirm the dataset type matches the table format, confirm the service user can read the key with &lt;code&gt;su -s /bin/sh opendkim -c "…"&lt;/code&gt;, and confirm &lt;code&gt;InternalHosts&lt;/code&gt; covers every host in &lt;code&gt;mynetworks&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Send the final test from a host that is not the mail server.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The end state was &lt;code&gt;DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=mail&lt;/code&gt; in the delivered headers, &lt;code&gt;spf=pass&lt;/code&gt;, &lt;code&gt;dmarc=pass&lt;/code&gt;, and &lt;code&gt;250 2.0.0 OK&lt;/code&gt; from Gmail. Total time was about an hour and a half, most of it spent on the silent one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What authentication didn't fix: six messages in ten still bounce
&lt;/h2&gt;

&lt;p&gt;Passing SPF and DKIM gets mail accepted, not trusted, and this domain's reputation problem survived every fix above untouched. A full day of counters after the fix still showed more rejections than deliveries — roughly six messages in ten going nowhere — and among them close to a hundred a day addressed to &lt;code&gt;@iclaud.com&lt;/code&gt;, a typo of &lt;code&gt;@icloud.com&lt;/code&gt; that the application has been faithfully retrying for years. Bounce rate is not the metric Google publishes a threshold for; that one is spam rate, which bulk senders are asked to keep under 0.30% in Postmaster Tools. They are different numbers, but they feed the same reputation ledger, and 0.30% is a useful sense of the scale a receiver considers acceptable. Mail with flawless authentication still lands in the spam folder if the domain spends years hammering addresses that don't exist.&lt;/p&gt;

&lt;p&gt;The reason nobody knew is that the feedback loop had been cut at both ends. The server delivered non-delivery reports to the sender address, whose domain it considered local via &lt;code&gt;mydestination&lt;/code&gt;, and no local user by that name existed, so the reports were discarded on arrival. That was hundreds of destroyed reports a day. The application mails a dead address, the report about it is deleted by the same server, nobody finds out, and the application keeps mailing.&lt;/p&gt;

&lt;p&gt;What I deliberately did not do was tighten DMARC past &lt;code&gt;p=none&lt;/code&gt;. Moving to &lt;code&gt;quarantine&lt;/code&gt; before the statistics are clean would route legitimate mail to spam by my own hand, and there is no rush. Still outstanding: the envelope sender equals the &lt;code&gt;From:&lt;/code&gt; address, where a dedicated bounce address with VERP belongs; certbot 0.12.0 is dead on this box, still calling the retired &lt;code&gt;acme-v01&lt;/code&gt; endpoint, with a certificate that expired years ago. And every change here was made by hand on a container that lives outside Ansible, so none of it exists in any repository. The next person to open this server will find no trace of why it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;When a daemon reports &lt;code&gt;active&lt;/code&gt; and does not do its job while logging nothing, stop repairing hypotheses and go read the man page for the semantics of the specific option you are relying on. Silence is not the absence of a fault. It is its own class of behaviour, and it is always described somewhere in the documentation, usually in a sentence about how lookups are performed.&lt;/p&gt;

&lt;p&gt;The process mistake was ordering. I fixed three genuine defects (truncated config, key ownership, then the host list) before finding the one that actually mattered, and each time I expected the next test to pass. A reliable check built at minute one, reading the headers of a delivered message, would have turned each of those into a one-minute negative result instead of an hour of accumulating false hope. Build the instrument before you start turning things.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bitpage.me/incidents/gmail-bounces-everything-spf-dkim-fcrdns/" rel="noopener noreferrer"&gt;bitpage.me&lt;/a&gt; — BitPage, a technical blog on backend, databases, infrastructure and incident post-mortems.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postfix</category>
      <category>opendkim</category>
      <category>dkim</category>
      <category>spf</category>
    </item>
    <item>
      <title>PostgreSQL conflict with recovery: why both standard fixes make it worse under Patroni</title>
      <dc:creator>bitpage</dc:creator>
      <pubDate>Sun, 09 Aug 2026 05:21:01 +0000</pubDate>
      <link>https://dev.to/bitpage/postgresql-conflict-with-recovery-why-both-standard-fixes-make-it-worse-under-patroni-1ffb</link>
      <guid>https://dev.to/bitpage/postgresql-conflict-with-recovery-why-both-standard-fixes-make-it-worse-under-patroni-1ffb</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; Both top search results for &lt;code&gt;canceling statement due to conflict with recovery&lt;/code&gt; are wrong on a Patroni cluster with replication slots and one replica. &lt;code&gt;hot_standby_feedback=on&lt;/code&gt; parks the xmin horizon inside the slot, where it survives the replica's death and bloats the primary; raising &lt;code&gt;max_standby_streaming_delay&lt;/code&gt; converts held-back replay into the exact lag your load balancer ejects the replica for. What I shipped instead was removing &lt;code&gt;on-marked-down shutdown-sessions&lt;/code&gt; from the read backend, after measuring that replica lag runs in single-digit milliseconds at the median against a health-check threshold of 10 MB.&lt;/p&gt;

&lt;p&gt;The cluster: one primary and one replica under Patroni 3.0.2 with etcd, PostgreSQL 15, synchronous replication, &lt;code&gt;use_slots: true&lt;/code&gt;. Web requests from PHP-FPM read through HAProxy 2.4.30 against the replica; CLI work (queues, scheduler) goes to the primary. Once every quarter hour a cron job wipes and rebuilds a large table in full, and it belongs to someone else's business logic, so it is not negotiable. The application was catching &lt;code&gt;SQLSTATE[40001]&lt;/code&gt;, and a developer had already written an interceptor at the &lt;code&gt;PostgresConnection&lt;/code&gt; level that retries the cancelled read on the primary. The question I was handed was whether he'd done it right. That took a day to answer and turned out to be about something else entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; drop &lt;code&gt;on-marked-down shutdown-sessions&lt;/code&gt; from the read backend — it was killing dozens of live sessions per ejection, and the application does not see &lt;code&gt;40001&lt;/code&gt; for those, it sees a dead socket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't:&lt;/strong&gt; &lt;code&gt;hot_standby_feedback=on&lt;/code&gt; with replication slots and a single replica — the xmin outlives the replica, so the failure mode moves from "a report dies" to "the primary fills its disk."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't:&lt;/strong&gt; raise &lt;code&gt;max_standby_streaming_delay&lt;/code&gt; when the load balancer health-checks replayed position — you pay for it with fan-out read outages instead of a couple of dozen cancelled queries a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure:&lt;/strong&gt; &lt;code&gt;replay_lag&lt;/code&gt; sampled from the primary once a second for twenty minutes. Median in single-digit milliseconds, p95 in tens, worst case a couple of seconds — against a byte threshold that a ~170 MB burst clears in a few seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trap:&lt;/strong&gt; Patroni's &lt;code&gt;lag=&lt;/code&gt; parameter is bytes-only. A requirement stated in seconds cannot be expressed in it at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why hot_standby_feedback is the wrong first move with replication slots
&lt;/h2&gt;

&lt;p&gt;Replication slots change what &lt;code&gt;hot_standby_feedback=on&lt;/code&gt; costs you, because the xmin stops being tied to a live connection. The PostgreSQL 15 documentation is upfront about the general risk: the parameter "can be used to eliminate query cancels caused by cleanup records, but can cause database bloat on the primary for some workloads" (&lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html" rel="noopener noreferrer"&gt;runtime-config-replication&lt;/a&gt;). That sentence is what every answer on the internet is implicitly waving away when it recommends the setting.&lt;/p&gt;

&lt;p&gt;The part that applies specifically here is one section over, in the description of slots: "Replication slots provide an automated way to ensure that the primary does not remove WAL segments until they have been received by all standbys, and that the primary does not remove rows which could cause a recovery conflict even when the standby is disconnected" (&lt;a href="https://www.postgresql.org/docs/15/warm-standby.html" rel="noopener noreferrer"&gt;warm-standby&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Read that as an operator rather than as a feature description. Without slots, feedback xmin lives as long as the session does; kill the replica and the primary is free again. With slots, the xmin settles into &lt;code&gt;pg_replication_slots.xmin&lt;/code&gt; and stays there while the replica is gone. There's one replica in this cluster and no spare. Turning the setting on would mean any replica outage becomes unbounded bloat on the primary, and I'd have traded a couple of dozen cancelled read queries a day for a way to take down the whole database. That one didn't need a benchmark to reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why raising max_standby_streaming_delay feeds the flap
&lt;/h2&gt;

&lt;p&gt;Delaying WAL replay is only free if nothing downstream measures replay position, and on this cluster the load balancer does exactly that. &lt;code&gt;max_standby_streaming_delay&lt;/code&gt; is the second-most-popular answer, and on paper it is the polite one: instead of killing the query, hold back the apply. The docs describe it as "the maximum total time allowed to apply WAL data once it has been received from the primary server," defaulting to 30 seconds (&lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html" rel="noopener noreferrer"&gt;runtime-config-replication&lt;/a&gt;). The cost stays on the replica, which is what makes it look safe.&lt;/p&gt;

&lt;p&gt;It isn't safe here. HAProxy's health check calls Patroni's &lt;code&gt;/replica?lag=&lt;/code&gt;, and Patroni computes lag from the &lt;em&gt;replayed&lt;/em&gt; position. Held-back replay is, by definition, replay lag. Push the parameter from 30 s to 300 s and every conflict-heavy moment turns into a five-minute-wide window where the balancer can decide the replica is stale and pull it from rotation. The outcome would have been strictly worse than the problem: instead of a couple of dozen cancelled queries per day, fan-out outages across all reads.&lt;/p&gt;

&lt;p&gt;The full set of candidates, and why each one died:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Candidate&lt;/th&gt;
&lt;th&gt;Where the cost lands&lt;/th&gt;
&lt;th&gt;Why it was rejected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hot_standby_feedback=on&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Primary (bloat)&lt;/td&gt;
&lt;td&gt;With slots, xmin survives replica downtime; single replica, no spare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;max_standby_streaming_delay&lt;/code&gt; 30s → 300s&lt;/td&gt;
&lt;td&gt;Replica (replay lag)&lt;/td&gt;
&lt;td&gt;Replay lag is what the health check ejects on; amplifies flapping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health-check threshold 10 MB → 512 MB&lt;/td&gt;
&lt;td&gt;Nothing measurable&lt;/td&gt;
&lt;td&gt;The requirement was stated in seconds; any byte value is a guess at time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HAProxy &lt;code&gt;agent-check&lt;/code&gt; on wall-clock lag&lt;/td&gt;
&lt;td&gt;New moving part&lt;/td&gt;
&lt;td&gt;Correct, but a new component in the infrastructure; deferred until measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drop &lt;code&gt;on-marked-down shutdown-sessions&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;Shipped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is the one I would have gotten wrong on my own. I proposed raising the byte threshold, and the customer rejected it with a better argument than mine: the requirement was written in time ("a second of staleness is fine, half a minute is not"), and a byte threshold answers a different question. On steady write load the two are nearly interchangeable. On batch write load they diverge by orders of magnitude, which is the whole story of this cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two problems that looked like one
&lt;/h2&gt;

&lt;p&gt;Conflicts and replica ejections clustered in the same minutes because they share a cause, not because one causes the other; barely one conflict in twenty lines up with an ejection. There were two symptom streams. One in the PostgreSQL log — a couple of hundred cancelled queries over a week and a half:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  canceling statement due to conflict with recovery
DETAIL:  User query might have needed to see row versions that must be removed.

FATAL:  terminating connection due to conflict with recovery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And one in the HAProxy log (&lt;code&gt;N&lt;/code&gt; here is the live session count at the moment of the ejection — dozens, in this cluster):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server slave/pg_replica is DOWN, reason: Layer7 wrong status, code: 503,
info: "Service Unavailable", check duration: 4ms. 0 active and 0 backup
servers left. N sessions active, 0 requeued, 0 remaining in queue.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both landed in the same handful of minutes each hour. The connection seemed too obvious to check, which is precisely why it deserved checking.&lt;/p&gt;

&lt;p&gt;Before I got there I burned time on two hypotheses that a timestamp comparison would have killed in a minute each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Checkpoints.&lt;/strong&gt; With &lt;code&gt;checkpoint_timeout=15min&lt;/code&gt; the primary logs a checkpoint every quarter hour. The ejections land on entirely different minutes. No overlap at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The backup window.&lt;/strong&gt; The backup script raises &lt;code&gt;max_standby_streaming_delay&lt;/code&gt; to 3600 s for the duration of &lt;code&gt;pg_dump&lt;/code&gt;, which looked like an excellent suspect. Every single ejection lasted between 6 and 27 seconds. There is not a single hour-long window in the data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I lined up the two event streams properly (the logs are in different timezones, so this needs converting to one scale first) and compared: about a third of the replica ejections and barely a twentieth of the conflict events fall in shared windows. Two independent symptoms driven by the same cron job, not a chain.&lt;/p&gt;

&lt;p&gt;I should have run that correlation first. It cost one command, and it invalidated a morning's worth of theories I'd been carefully stacking on top of each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the lag actually was, in bytes and in seconds
&lt;/h2&gt;

&lt;p&gt;The replica was never slow at applying WAL; it was slow at receiving it, and the difference decides which knob is even relevant. I sampled &lt;code&gt;pg_stat_replication&lt;/code&gt; from the primary once per second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()::&lt;/span&gt;&lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;replay_lsn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;replay_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_wal_lsn_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_current_wal_lsn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;flush_lsn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;flush_bytes&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_replication&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put on a relative time scale, with the figures rounded off, a single burst looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T+0 s     replay=  0 MB   flush=  0 MB
T+1 s     replay=150 MB   flush=150 MB   ← cron batch starts
T+2 s     replay=170 MB   flush=170 MB
T+3 s     replay= 90 MB   flush= 90 MB
T+5 s     replay=  0 MB   flush=  0 MB   ← caught up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;flush&lt;/code&gt; tracks &lt;code&gt;replay&lt;/code&gt; to within a fraction of a percent. Nothing is queued waiting to be applied, so the lag is purely transport: the primary generated WAL faster than the link carried it. At rest the replica sits tens of kilobytes behind. The health-check threshold is 10 MB. A burst of ~170 MB clears that threshold by a factor of 17 and is gone within a few seconds.&lt;/p&gt;

&lt;p&gt;Then the measurement that actually settled the argument, using the &lt;code&gt;*_lag&lt;/code&gt; interval columns, sampled once a second over twenty minutes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;&lt;code&gt;replay_lag&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Median&lt;/td&gt;
&lt;td&gt;single-digit milliseconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;tens of milliseconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worst sample seen&lt;/td&gt;
&lt;td&gt;a couple of seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Samples above 5 s&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Samples above 30 s&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The stated requirement was "a second is acceptable, half a minute is not." The replica never came within three orders of magnitude of unacceptable. And the nature of the lag is confirmed again by subtracting one column from the other at the peaks — the three largest samples in the window, figures rounded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;peak 1   ~160 MB | flush 1.0 s | replay 1.0 s | delta: fractions of a millisecond
peak 2   ~100 MB | flush 0.7 s | replay 0.7 s | delta: fractions of a millisecond
peak 3    ~95 MB | flush 1.7 s | replay 1.7 s | delta: fractions of a millisecond
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applying WAL adds fractions of a millisecond. The two cron runs caught in that window sit exactly a quarter of an hour apart, which is the cron job signing its work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What lag= in the Patroni health check actually guards
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;lag=&lt;/code&gt; parameter covers exactly one scenario, "replica alive but behind," because role and state are checked independently of it. The source is more useful than the documentation here: the docs describe what the endpoint is for, the code describes what it does. From &lt;code&gt;patroni/api.py&lt;/code&gt; in &lt;a href="https://github.com/patroni/patroni/blob/v3.0.2/patroni/api.py" rel="noopener noreferrer"&gt;v3.0.2&lt;/a&gt;:&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="n"&gt;max_replica_lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path_query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;lag&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxsize&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;'&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;max_replica_lag&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;max_replica_lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxsize&lt;/span&gt;
&lt;span class="n"&gt;is_lagging&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;leader_optime&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;leader_optime&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;replayed_location&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;max_replica_lag&lt;/span&gt;

&lt;span class="n"&gt;replica_status_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;patroni&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noloadbalance&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_lagging&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; \
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;role&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;replica&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;state&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;running&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things fall out of those few lines. First, &lt;code&gt;parse_int(..., 'B')&lt;/code&gt;: the threshold is bytes, full stop. There's no time-based variant of this endpoint, so the entire discussion about "what number should we put there" was unanswerable until somebody said the units out loud. Second, &lt;code&gt;is_lagging&lt;/code&gt; sits in a chain with &lt;code&gt;role&lt;/code&gt; and &lt;code&gt;state&lt;/code&gt;, each able to produce a 503 on its own. A genuinely broken replica gets caught by &lt;code&gt;state&lt;/code&gt;/&lt;code&gt;role&lt;/code&gt; anyway: when streaming breaks, the row just vanishes from &lt;code&gt;pg_stat_replication&lt;/code&gt; and there's no byte figure left to compute.&lt;/p&gt;

&lt;p&gt;There's a third detail that makes the threshold shakier than it looks. &lt;code&gt;leader_optime&lt;/code&gt; is read from etcd, refreshed once per &lt;code&gt;loop_wait&lt;/code&gt; — 10 seconds by default (&lt;a href="https://patroni.readthedocs.io/en/latest/SETTINGS.html" rel="noopener noreferrer"&gt;Patroni settings&lt;/a&gt;) — so the byte figure being compared can be up to a &lt;code&gt;loop_wait&lt;/code&gt; stale before you even pick a number to compare it against. Against bursts that come and go in a few seconds, the measurement interval is longer than the event.&lt;/p&gt;

&lt;p&gt;Prometheus history sizes the one scenario the threshold does cover (&lt;code&gt;pg_replication_lag_seconds&lt;/code&gt;, 15 s scrape). Over a month, lag exceeded one minute exactly once: the single real failure in that window ran for hours, when the replica's VM came back from a hypervisor reboot without network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A couple of seconds in the worst normal case against hours for a real failure. Nearly four orders of magnitude between them, and the threshold was sitting right up against the lower bound.&lt;/strong&gt; A threshold that far from the signal it's supposed to catch isn't "tuned strictly," it's tuned arbitrarily. The &lt;code&gt;group_vars&lt;/code&gt; file even carries a comment from whoever raised it earlier, explaining that the bump was meant to avoid false DOWNs during checkpoint and autovacuum. Against a ~170 MB burst, no value in the tens of megabytes was ever going to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why pg_replication_lag_seconds lies when nobody is writing
&lt;/h2&gt;

&lt;p&gt;On a replica, postgres_exporter computes this metric as &lt;code&gt;now() - pg_last_xact_replay_timestamp()&lt;/code&gt;, so during write silence it grows on its own without the replica being behind by anything. Its p99 over the month runs to tens of seconds, and reading that as "the replica served data tens of seconds stale" is wrong: it means the last transaction it replayed was that old, because no newer transaction existed. On the primary the same metric is flat zero. Only direct &lt;code&gt;replay_lag&lt;/code&gt; sampling from the primary is trustworthy.&lt;/p&gt;

&lt;p&gt;The second limitation is resolution. At a 15-second scrape interval, a burst that lasts a few seconds is invisible by construction. For short events the instrument is the HAProxy log, not the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading burst length out of the HAProxy log
&lt;/h2&gt;

&lt;p&gt;When monitoring resolution is too coarse, the health check itself is a measuring instrument with known timing. With &lt;code&gt;inter 3s fall 3 rise 2&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DOWN&lt;/code&gt; is declared after &lt;code&gt;fall 3&lt;/code&gt; × &lt;code&gt;inter 3s&lt;/code&gt; = 9 seconds above threshold.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UP&lt;/code&gt; returns after &lt;code&gt;rise 2&lt;/code&gt; × &lt;code&gt;inter 3s&lt;/code&gt; = 6 seconds below it.&lt;/li&gt;
&lt;li&gt;Therefore burst length ≈ DOWN duration + 3 s.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Applied to thirty-odd ejections over a bit more than a week:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;DOWN lasted&lt;/th&gt;
&lt;th&gt;Share of ejections&lt;/th&gt;
&lt;th&gt;Burst was&lt;/th&gt;
&lt;th&gt;Survives &lt;code&gt;fall 5&lt;/code&gt;?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;6 s&lt;/td&gt;
&lt;td&gt;about two thirds&lt;/td&gt;
&lt;td&gt;~9 s&lt;/td&gt;
&lt;td&gt;no — eliminated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9 s&lt;/td&gt;
&lt;td&gt;a couple of cases&lt;/td&gt;
&lt;td&gt;~12 s&lt;/td&gt;
&lt;td&gt;no — eliminated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;18 s&lt;/td&gt;
&lt;td&gt;a couple of cases&lt;/td&gt;
&lt;td&gt;~21 s&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;21 s&lt;/td&gt;
&lt;td&gt;roughly a fifth&lt;/td&gt;
&lt;td&gt;~24 s&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;27 s&lt;/td&gt;
&lt;td&gt;one case&lt;/td&gt;
&lt;td&gt;~30 s&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two thirds of them lasted the minimum possible DOWN, meaning the burst barely crossed the threshold and was already over. Raising &lt;code&gt;fall 3&lt;/code&gt; → &lt;code&gt;fall 5&lt;/code&gt; requires 15 consecutive seconds above threshold and removes around 70% of the ejections, with no staleness risk given the numbers above. The remaining third ran 21–30 seconds and would still eject.&lt;/p&gt;

&lt;p&gt;To be clear about status: that change is argued for with the numbers above and agreed in principle, but it is not applied. It sits behind the same sign-off as anything else touching the balancer, and the residual third of the ejections needs either a higher byte threshold or the time-based check I keep coming back to.&lt;/p&gt;

&lt;p&gt;That arithmetic required no new tooling, no sampling, and no agreement from anyone. The data had been in the balancer log the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped, and how the rollout went sideways
&lt;/h2&gt;

&lt;p&gt;One line removed from the read backend, leaving the failover-critical backends untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; backend slave
     option httpchk GET /replica?lag=10485760 ...
     http-check expect status 200
&lt;span class="gd"&gt;-    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
&lt;/span&gt;&lt;span class="gi"&gt;+    default-server inter 3s fall 3 rise 2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HAProxy 2.4 manual describes the option plainly: with &lt;code&gt;shutdown-sessions&lt;/code&gt;, "all connections to the server are immediately terminated when the server goes down" (&lt;a href="https://docs.haproxy.org/2.4/configuration.html" rel="noopener noreferrer"&gt;configuration manual, §5.2&lt;/a&gt;). On a read backend fronting a replica that comes back within seconds, that is damage with no upside. In the logs it killed dozens of sessions at once. The application does not see &lt;code&gt;40001&lt;/code&gt; for those connections, it sees a severed socket, which the developer's interceptor never catches. The replica can still be ejected during a burst; live reads now finish, and new connections fall through to &lt;code&gt;use_backend master if { nbsrv(slave) eq 0 }&lt;/code&gt;. The option stays in the &lt;code&gt;master&lt;/code&gt; and &lt;code&gt;long_queries&lt;/code&gt; backends, where killing sessions during failover is the point.&lt;/p&gt;

&lt;p&gt;Three things about the rollout worth stealing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--check --diff&lt;/code&gt; on the whole playbook, not just my part.&lt;/strong&gt; Someone had added &lt;code&gt;weight 80&lt;/code&gt; / &lt;code&gt;weight 20&lt;/code&gt; by hand on the server and never put them in the template. A normal run would have silently erased them. I pulled the repository up to the server's actual state first, then applied my change on top.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;group_vars&lt;/code&gt; file turned out to be a hardlink shared by several inventories.&lt;/strong&gt; One edit, every environment changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I rolled it out with a restart instead of a reload.&lt;/strong&gt; In master-worker mode &lt;code&gt;kill -USR2&lt;/code&gt; to the master reloads without dropping anything: the master holds the sockets, the port never closes, and the old worker drains its sessions. A restart dropped every connection at once — which is precisely the damage this change exists to prevent. Shipping a fix for connection drops by dropping every connection is the kind of thing you only do once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Patroni ignores the log settings in your playbook
&lt;/h2&gt;

&lt;p&gt;Anything under &lt;code&gt;bootstrap.dcs&lt;/code&gt; is applied once, at cluster initialization, and edits to it afterwards do nothing. I found this while looking for something else entirely: &lt;code&gt;pg_log&lt;/code&gt; had never rotated. Tens of gigabytes on the primary, single-digit gigabytes on the replica, files that had been piling up for years, on a cluster whose Ansible template configures rotation perfectly well.&lt;/p&gt;

&lt;p&gt;Nobody had edited anything by hand this time. The Patroni documentation states the rule with unusual force: "Once Patroni has initialized the cluster for the first time and settings have been stored in the DCS, all future changes to the &lt;code&gt;bootstrap.dcs&lt;/code&gt; section of the YAML configuration will not take any effect!" (&lt;a href="https://patroni.readthedocs.io/en/latest/yaml_configuration.html" rel="noopener noreferrer"&gt;YAML configuration&lt;/a&gt;). The section "will be written into &lt;code&gt;/&amp;lt;namespace&amp;gt;/&amp;lt;scope&amp;gt;/config&lt;/code&gt; of the given configuration store after initializing the new cluster," and from that moment the copy in etcd is the one that counts.&lt;/p&gt;

&lt;p&gt;Changing those values later goes through &lt;code&gt;patronictl edit-config&lt;/code&gt; or the REST API, not the file (&lt;a href="https://patroni.readthedocs.io/en/latest/dynamic_configuration.html" rel="noopener noreferrer"&gt;dynamic configuration&lt;/a&gt;). Which means a Patroni cluster has a whole class of settings where the playbook is documentation rather than configuration, and &lt;code&gt;--check --diff&lt;/code&gt; will never tell you, because the file on disk matches the template exactly. It is right and the cluster still ignores it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the application layer got wrong
&lt;/h2&gt;

&lt;p&gt;The interceptor I was asked to review was correct; the defects were around it. Findings handed back to the developers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ERROR&lt;/code&gt; and &lt;code&gt;FATAL&lt;/code&gt; are different events.&lt;/strong&gt; &lt;code&gt;ERROR: canceling statement due to conflict with recovery&lt;/code&gt; cancels the statement and leaves the connection usable. &lt;code&gt;FATAL: terminating connection due to conflict with recovery&lt;/code&gt; does not. Over the week and a half of logs, nearly all the events were &lt;code&gt;ERROR&lt;/code&gt; and a handful were &lt;code&gt;FATAL&lt;/code&gt;. One retry strategy cannot serve both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The retry loop was unreachable&lt;/strong&gt; past its first iteration, so it retried once regardless of configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The application under-reports by half.&lt;/strong&gt; Over two weeks the database logged roughly twice as many conflict events as the application reported through its single &lt;code&gt;Log::warning&lt;/code&gt;. That gap is why nobody knew the size of the problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;application_name&lt;/code&gt; is empty&lt;/strong&gt; in every event, so a conflict cannot be attributed to a service from the database logs — only to a database user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pre-existing lazy-connection bug in the Laravel wrapper.&lt;/strong&gt; &lt;code&gt;bindValues()&lt;/code&gt; opens with &lt;code&gt;$this-&amp;gt;getPdo()-&amp;gt;getAttribute(...)&lt;/code&gt;, and &lt;code&gt;getPdo()&lt;/code&gt; resolves the lazy closure and opens a connection to the primary. Every query with bindings calls it, read-only ones included, so the read/write split silently leaks connections to the primary on any parameterised read.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Half of my initial review comments on the interceptor did not survive my own re-checking. Separating "I would have written this differently" from "this is a defect" is most of what code review is, and I am not always good at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step by step
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Count the events before theorizing: split &lt;code&gt;ERROR&lt;/code&gt; from &lt;code&gt;FATAL&lt;/code&gt; in the PostgreSQL log and get the rate per day.&lt;/li&gt;
&lt;li&gt;Correlate every symptom stream by timestamp on one timezone scale, first, before building any causal story.&lt;/li&gt;
&lt;li&gt;Sample &lt;code&gt;pg_stat_replication&lt;/code&gt; from the primary once per second; compare &lt;code&gt;replay_lsn&lt;/code&gt; against &lt;code&gt;flush_lsn&lt;/code&gt; to separate transport lag from apply lag.&lt;/li&gt;
&lt;li&gt;Sample the &lt;code&gt;*_lag&lt;/code&gt; interval columns to get lag in seconds, which is the unit your requirement is almost certainly written in.&lt;/li&gt;
&lt;li&gt;Read your health-check endpoint's source for the units it actually accepts before negotiating a threshold value.&lt;/li&gt;
&lt;li&gt;Derive burst duration from the balancer's own &lt;code&gt;inter&lt;/code&gt;/&lt;code&gt;fall&lt;/code&gt;/&lt;code&gt;rise&lt;/code&gt; timing when scrape intervals are too coarse.&lt;/li&gt;
&lt;li&gt;Check whether &lt;code&gt;hot_standby_feedback&lt;/code&gt; is safe &lt;em&gt;for your topology&lt;/em&gt;: with slots and one replica, it is not.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;--check --diff&lt;/code&gt; across the whole playbook before applying, and reload rather than restart.&lt;/li&gt;
&lt;li&gt;For anything Patroni owns, read the live values with &lt;code&gt;patronictl show-config&lt;/code&gt; rather than trusting the YAML in your repository.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Neither of the two canonical answers to &lt;code&gt;conflict with recovery&lt;/code&gt; was usable on this cluster, and both failures were topology-specific rather than wrong in general. &lt;code&gt;hot_standby_feedback&lt;/code&gt; assumes you have no slots or a spare replica; raising the streaming delay assumes nothing downstream measures replay position. Check those assumptions against your own cluster before pasting either one into &lt;code&gt;postgresql.conf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The larger lesson is about units. The health check measured "how much WAL has not arrived" in bytes while the business requirement said "how stale is the data I am serving" in seconds. Every discussion about the right threshold was unanswerable until somebody wrote both sentences down next to each other. The measured answer was single-digit milliseconds at the median against a real failure measured in hours — and the deferred work, an &lt;code&gt;agent-check&lt;/code&gt; agent reporting &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; from &lt;code&gt;now() - pg_last_xact_replay_timestamp()&lt;/code&gt;, is now a decision that can be made with numbers instead of a guess.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://bitpage.me/databases/postgres-conflict-with-recovery-patroni-haproxy/" rel="noopener noreferrer"&gt;bitpage.me&lt;/a&gt; — BitPage, a technical blog on backend, databases, infrastructure and incident post-mortems.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>patroni</category>
      <category>haproxy</category>
      <category>replication</category>
    </item>
  </channel>
</rss>
