Skip to content

Commit 67f46a7

Browse files
seanthegeekclaude
andauthored
DNS lookup reliability improvements (9.7.1) (#710)
Port DNS reliability fixes from checkdmarc 5.15.x: cap per-query UDP timeout at min(1.0, timeout) so a single dropped datagram no longer consumes the entire lifetime budget, scale lifetime by nameserver count for proper failover, and add a retries kwarg that retries on LifetimeTimeout, NoNameservers (SERVFAIL), and OSError during TCP fallback (NXDOMAIN and NoAnswer remain non-retryable). Thread dns_retries through the parser API and expose it via --dns-retries / the dns_retries INI option. Centralize DNS defaults in parsedmarc.constants and add RECOMMENDED_DNS_NAMESERVERS for opt-in cross-provider failover. Co-authored-by: Sean Whalen <seanthegeek@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6effd80 commit 67f46a7

5 files changed

Lines changed: 171 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## 9.7.1
4+
5+
### Changes
6+
7+
- Ported DNS lookup reliability improvements from checkdmarc 5.15.x:
8+
- Per-query UDP timeout is now capped at `min(1.0, timeout)` in `query_dns()`, so a single dropped UDP datagram no longer consumes the entire lifetime budget — dnspython retries UDP within the lifetime window (mirroring `dig`'s default `+tries=3`). With multiple nameservers configured, the same cap also makes a slow or broken nameserver fall through to the next quickly.
9+
- With multiple nameservers configured, the resolver lifetime is now `timeout × len(nameservers)` so each nameserver gets its own timeout budget for failover rather than sharing one overall deadline.
10+
- New `retries` kwarg on `query_dns()`, `get_reverse_dns()`, and `get_ip_address_info()` retries the whole query on transient errors (`LifetimeTimeout`, `NoNameservers`/SERVFAIL, and `OSError` during TCP fallback). `NXDOMAIN` and `NoAnswer` remain non-retryable. Default is 0 (no behavior change for existing callers).
11+
- Threaded `dns_retries` through the parser API (`parse_report_file`, `parse_aggregate_report_xml`, `parse_forensic_report`, `parse_report_email`, `get_dmarc_reports_from_mbox`, `get_dmarc_reports_from_mailbox`, `watch_inbox`).
12+
- Added `--dns-retries N` CLI flag and `dns_retries` INI option (`[general]` section, also surfaced via `PARSEDMARC_GENERAL_DNS_RETRIES` env var).
13+
- Centralized DNS defaults in `parsedmarc.constants`: `DEFAULT_DNS_TIMEOUT`, `DEFAULT_DNS_MAX_RETRIES`, and `RECOMMENDED_DNS_NAMESERVERS` (a cross-provider mix — `("1.1.1.1", "8.8.8.8")` — for callers that want public-resolver failover). The existing default nameservers (all-Cloudflare) are preserved for backward compatibility; callers opt in by passing `nameservers=RECOMMENDED_DNS_NAMESERVERS`.
14+
315
## 9.7.0
416

517
### Changes

parsedmarc/__init__.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@
3838
from expiringdict import ExpiringDict
3939
from mailsuite.smtp import send_email
4040

41-
from parsedmarc.constants import __version__
41+
from parsedmarc.constants import (
42+
DEFAULT_DNS_MAX_RETRIES,
43+
DEFAULT_DNS_TIMEOUT,
44+
__version__,
45+
)
4246
from parsedmarc.log import logger
4347
from parsedmarc.mail import (
4448
GmailConnection,
@@ -301,7 +305,8 @@ def _parse_report_record(
301305
reverse_dns_map_url: Optional[str] = None,
302306
offline: bool = False,
303307
nameservers: Optional[list[str]] = None,
304-
dns_timeout: float = 2.0,
308+
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
309+
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
305310
) -> dict[str, Any]:
306311
"""
307312
Converts a record from a DMARC aggregate report into a more consistent
@@ -317,6 +322,8 @@ def _parse_report_record(
317322
nameservers (list): A list of one or more nameservers to use
318323
(Cloudflare's public DNS resolvers by default)
319324
dns_timeout (float): Sets the DNS timeout in seconds
325+
dns_retries (int): Number of times to retry DNS queries on timeout
326+
or other transient errors
320327
321328
Returns:
322329
dict: The converted record
@@ -336,6 +343,7 @@ def _parse_report_record(
336343
offline=offline,
337344
nameservers=nameservers,
338345
timeout=dns_timeout,
346+
retries=dns_retries,
339347
)
340348
new_record["source"] = new_record_source
341349
new_record["count"] = int(record["row"]["count"])
@@ -668,7 +676,8 @@ def parse_aggregate_report_xml(
668676
reverse_dns_map_url: Optional[str] = None,
669677
offline: bool = False,
670678
nameservers: Optional[list[str]] = None,
671-
timeout: float = 2.0,
679+
timeout: float = DEFAULT_DNS_TIMEOUT,
680+
retries: int = DEFAULT_DNS_MAX_RETRIES,
672681
keep_alive: Optional[Callable] = None,
673682
normalize_timespan_threshold_hours: float = 24.0,
674683
) -> AggregateReport:
@@ -684,6 +693,8 @@ def parse_aggregate_report_xml(
684693
nameservers (list): A list of one or more nameservers to use
685694
(Cloudflare's public DNS resolvers by default)
686695
timeout (float): Sets the DNS timeout in seconds
696+
retries (int): Number of times to retry DNS queries on timeout or
697+
other transient errors
687698
keep_alive (callable): Keep alive function
688699
normalize_timespan_threshold_hours (float): Normalize timespans beyond this
689700
@@ -828,6 +839,7 @@ def parse_aggregate_report_xml(
828839
reverse_dns_map_url=reverse_dns_map_url,
829840
nameservers=nameservers,
830841
dns_timeout=timeout,
842+
dns_retries=retries,
831843
)
832844
_append_parsed_record(
833845
parsed_record=report_record,
@@ -849,6 +861,7 @@ def parse_aggregate_report_xml(
849861
offline=offline,
850862
nameservers=nameservers,
851863
dns_timeout=timeout,
864+
dns_retries=retries,
852865
)
853866
_append_parsed_record(
854867
parsed_record=report_record,
@@ -982,7 +995,8 @@ def parse_aggregate_report_file(
982995
reverse_dns_map_url: Optional[str] = None,
983996
ip_db_path: Optional[str] = None,
984997
nameservers: Optional[list[str]] = None,
985-
dns_timeout: float = 2.0,
998+
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
999+
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
9861000
keep_alive: Optional[Callable] = None,
9871001
normalize_timespan_threshold_hours: float = 24.0,
9881002
) -> AggregateReport:
@@ -999,6 +1013,8 @@ def parse_aggregate_report_file(
9991013
nameservers (list): A list of one or more nameservers to use
10001014
(Cloudflare's public DNS resolvers by default)
10011015
dns_timeout (float): Sets the DNS timeout in seconds
1016+
dns_retries (int): Number of times to retry DNS queries on timeout
1017+
or other transient errors
10021018
keep_alive (callable): Keep alive function
10031019
normalize_timespan_threshold_hours (float): Normalize timespans beyond this
10041020
@@ -1020,6 +1036,7 @@ def parse_aggregate_report_file(
10201036
offline=offline,
10211037
nameservers=nameservers,
10221038
timeout=dns_timeout,
1039+
retries=dns_retries,
10231040
keep_alive=keep_alive,
10241041
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
10251042
)
@@ -1230,7 +1247,8 @@ def parse_forensic_report(
12301247
offline: bool = False,
12311248
ip_db_path: Optional[str] = None,
12321249
nameservers: Optional[list[str]] = None,
1233-
dns_timeout: float = 2.0,
1250+
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
1251+
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
12341252
strip_attachment_payloads: bool = False,
12351253
) -> ForensicReport:
12361254
"""
@@ -1248,6 +1266,8 @@ def parse_forensic_report(
12481266
nameservers (list): A list of one or more nameservers to use
12491267
(Cloudflare's public DNS resolvers by default)
12501268
dns_timeout (float): Sets the DNS timeout in seconds
1269+
dns_retries (int): Number of times to retry DNS queries on timeout
1270+
or other transient errors
12511271
strip_attachment_payloads (bool): Remove attachment payloads from
12521272
forensic report results
12531273
@@ -1302,6 +1322,7 @@ def parse_forensic_report(
13021322
offline=offline,
13031323
nameservers=nameservers,
13041324
timeout=dns_timeout,
1325+
retries=dns_retries,
13051326
)
13061327
parsed_report["source"] = parsed_report_source
13071328
del parsed_report["source_ip"]
@@ -1461,7 +1482,8 @@ def parse_report_email(
14611482
reverse_dns_map_path: Optional[str] = None,
14621483
reverse_dns_map_url: Optional[str] = None,
14631484
nameservers: Optional[list[str]] = None,
1464-
dns_timeout: float = 2.0,
1485+
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
1486+
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
14651487
strip_attachment_payloads: bool = False,
14661488
keep_alive: Optional[Callable] = None,
14671489
normalize_timespan_threshold_hours: float = 24.0,
@@ -1478,6 +1500,8 @@ def parse_report_email(
14781500
offline (bool): Do not query online for geolocation on DNS
14791501
nameservers (list): A list of one or more nameservers to use
14801502
dns_timeout (float): Sets the DNS timeout in seconds
1503+
dns_retries (int): Number of times to retry DNS queries on timeout
1504+
or other transient errors
14811505
strip_attachment_payloads (bool): Remove attachment payloads from
14821506
forensic report results
14831507
keep_alive (callable): keep alive function
@@ -1604,6 +1628,7 @@ def parse_report_email(
16041628
offline=offline,
16051629
nameservers=nameservers,
16061630
timeout=dns_timeout,
1631+
retries=dns_retries,
16071632
keep_alive=keep_alive,
16081633
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
16091634
)
@@ -1639,6 +1664,7 @@ def parse_report_email(
16391664
reverse_dns_map_url=reverse_dns_map_url,
16401665
nameservers=nameservers,
16411666
dns_timeout=dns_timeout,
1667+
dns_retries=dns_retries,
16421668
strip_attachment_payloads=strip_attachment_payloads,
16431669
)
16441670
except InvalidForensicReport as e:
@@ -1665,7 +1691,8 @@ def parse_report_file(
16651691
input_: Union[bytes, str, os.PathLike[str], os.PathLike[bytes], BinaryIO],
16661692
*,
16671693
nameservers: Optional[list[str]] = None,
1668-
dns_timeout: float = 2.0,
1694+
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
1695+
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
16691696
strip_attachment_payloads: bool = False,
16701697
ip_db_path: Optional[str] = None,
16711698
always_use_local_files: bool = False,
@@ -1684,6 +1711,8 @@ def parse_report_file(
16841711
nameservers (list): A list of one or more nameservers to use
16851712
(Cloudflare's public DNS resolvers by default)
16861713
dns_timeout (float): Sets the DNS timeout in seconds
1714+
dns_retries (int): Number of times to retry DNS queries on timeout
1715+
or other transient errors
16871716
strip_attachment_payloads (bool): Remove attachment payloads from
16881717
forensic report results
16891718
ip_db_path (str): Path to a MMDB file from MaxMind or DBIP
@@ -1723,6 +1752,7 @@ def parse_report_file(
17231752
offline=offline,
17241753
nameservers=nameservers,
17251754
dns_timeout=dns_timeout,
1755+
dns_retries=dns_retries,
17261756
keep_alive=keep_alive,
17271757
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
17281758
)
@@ -1742,6 +1772,7 @@ def parse_report_file(
17421772
offline=offline,
17431773
nameservers=nameservers,
17441774
dns_timeout=dns_timeout,
1775+
dns_retries=dns_retries,
17451776
strip_attachment_payloads=strip_attachment_payloads,
17461777
keep_alive=keep_alive,
17471778
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
@@ -1758,7 +1789,8 @@ def get_dmarc_reports_from_mbox(
17581789
input_: str,
17591790
*,
17601791
nameservers: Optional[list[str]] = None,
1761-
dns_timeout: float = 2.0,
1792+
dns_timeout: float = DEFAULT_DNS_TIMEOUT,
1793+
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
17621794
strip_attachment_payloads: bool = False,
17631795
ip_db_path: Optional[str] = None,
17641796
always_use_local_files: bool = False,
@@ -1775,6 +1807,8 @@ def get_dmarc_reports_from_mbox(
17751807
nameservers (list): A list of one or more nameservers to use
17761808
(Cloudflare's public DNS resolvers by default)
17771809
dns_timeout (float): Sets the DNS timeout in seconds
1810+
dns_retries (int): Number of times to retry DNS queries on timeout
1811+
or other transient errors
17781812
strip_attachment_payloads (bool): Remove attachment payloads from
17791813
forensic report results
17801814
always_use_local_files (bool): Do not download files
@@ -1811,6 +1845,7 @@ def get_dmarc_reports_from_mbox(
18111845
offline=offline,
18121846
nameservers=nameservers,
18131847
dns_timeout=dns_timeout,
1848+
dns_retries=dns_retries,
18141849
strip_attachment_payloads=sa,
18151850
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
18161851
)
@@ -1855,6 +1890,7 @@ def get_dmarc_reports_from_mailbox(
18551890
offline: bool = False,
18561891
nameservers: Optional[list[str]] = None,
18571892
dns_timeout: float = 6.0,
1893+
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
18581894
strip_attachment_payloads: bool = False,
18591895
results: Optional[ParsingResults] = None,
18601896
batch_size: int = 10,
@@ -1878,6 +1914,8 @@ def get_dmarc_reports_from_mailbox(
18781914
offline (bool): Do not query online for geolocation or DNS
18791915
nameservers (list): A list of DNS nameservers to query
18801916
dns_timeout (float): Set the DNS query timeout
1917+
dns_retries (int): Number of times to retry DNS queries on timeout
1918+
or other transient errors
18811919
strip_attachment_payloads (bool): Remove attachment payloads from
18821920
forensic report results
18831921
results (dict): Results from the previous run
@@ -2001,6 +2039,7 @@ def get_dmarc_reports_from_mailbox(
20012039
msg_content,
20022040
nameservers=nameservers,
20032041
dns_timeout=dns_timeout,
2042+
dns_retries=dns_retries,
20042043
ip_db_path=ip_db_path,
20052044
always_use_local_files=always_use_local_files,
20062045
reverse_dns_map_path=reverse_dns_map_path,
@@ -2159,6 +2198,7 @@ def get_dmarc_reports_from_mailbox(
21592198
test=test,
21602199
nameservers=nameservers,
21612200
dns_timeout=dns_timeout,
2201+
dns_retries=dns_retries,
21622202
strip_attachment_payloads=strip_attachment_payloads,
21632203
results=results,
21642204
ip_db_path=ip_db_path,
@@ -2189,6 +2229,7 @@ def watch_inbox(
21892229
offline: bool = False,
21902230
nameservers: Optional[list[str]] = None,
21912231
dns_timeout: float = 6.0,
2232+
dns_retries: int = DEFAULT_DNS_MAX_RETRIES,
21922233
strip_attachment_payloads: bool = False,
21932234
batch_size: int = 10,
21942235
since: Optional[Union[datetime, date, str]] = None,
@@ -2216,6 +2257,8 @@ def watch_inbox(
22162257
nameservers (list): A list of one or more nameservers to use
22172258
(Cloudflare's public DNS resolvers by default)
22182259
dns_timeout (float): Set the DNS query timeout
2260+
dns_retries (int): Number of times to retry DNS queries on timeout
2261+
or other transient errors
22192262
strip_attachment_payloads (bool): Replace attachment payloads in
22202263
forensic report samples with None
22212264
batch_size (int): Number of messages to read and process before saving
@@ -2239,6 +2282,7 @@ def check_callback(connection):
22392282
offline=offline,
22402283
nameservers=nameservers,
22412284
dns_timeout=dns_timeout,
2285+
dns_retries=dns_retries,
22422286
strip_attachment_payloads=strip_attachment_payloads,
22432287
batch_size=batch_size,
22442288
since=since,

parsedmarc/cli.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ def cli_parse(
211211
sa,
212212
nameservers,
213213
dns_timeout,
214+
dns_retries,
214215
ip_db_path,
215216
offline,
216217
always_use_local_files,
@@ -228,6 +229,7 @@ def cli_parse(
228229
sa: Strip attachment payloads flag
229230
nameservers: List of nameservers
230231
dns_timeout: DNS timeout
232+
dns_retries: Number of DNS retries on transient errors
231233
ip_db_path: Path to IP database
232234
offline: Offline mode flag
233235
always_use_local_files: Always use local files flag
@@ -251,6 +253,7 @@ def cli_parse(
251253
reverse_dns_map_url=reverse_dns_map_url,
252254
nameservers=nameservers,
253255
dns_timeout=dns_timeout,
256+
dns_retries=dns_retries,
254257
strip_attachment_payloads=sa,
255258
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours,
256259
)
@@ -344,6 +347,10 @@ def _parse_config(config: ConfigParser, opts):
344347
opts.dns_timeout = general_config.getfloat("dns_timeout")
345348
if opts.dns_timeout is None:
346349
opts.dns_timeout = 2
350+
if "dns_retries" in general_config:
351+
opts.dns_retries = general_config.getint("dns_retries")
352+
if opts.dns_retries is None:
353+
opts.dns_retries = 0
347354
if "dns_test_address" in general_config:
348355
opts.dns_test_address = general_config["dns_test_address"]
349356
if "nameservers" in general_config:
@@ -1652,6 +1659,14 @@ def log_output_error(destination, error):
16521659
type=float,
16531660
default=2.0,
16541661
)
1662+
arg_parser.add_argument(
1663+
"--dns-retries",
1664+
dest="dns_retries",
1665+
help="number of times to retry DNS queries on timeout or other "
1666+
"transient errors (default: 0)",
1667+
type=int,
1668+
default=0,
1669+
)
16551670
arg_parser.add_argument(
16561671
"--offline",
16571672
action="store_true",
@@ -1704,6 +1719,7 @@ def log_output_error(destination, error):
17041719
silent=args.silent,
17051720
warnings=args.warnings,
17061721
dns_timeout=args.dns_timeout,
1722+
dns_retries=args.dns_retries,
17071723
debug=args.debug,
17081724
verbose=args.verbose,
17091725
prettify_json=args.prettify_json,
@@ -1984,6 +2000,7 @@ def log_output_error(destination, error):
19842000
opts.strip_attachment_payloads,
19852001
opts.nameservers,
19862002
opts.dns_timeout,
2003+
opts.dns_retries,
19872004
opts.ip_db_path,
19882005
opts.offline,
19892006
opts.always_use_local_files,
@@ -2044,6 +2061,7 @@ def log_output_error(destination, error):
20442061
mbox_path,
20452062
nameservers=opts.nameservers,
20462063
dns_timeout=opts.dns_timeout,
2064+
dns_retries=opts.dns_retries,
20472065
strip_attachment_payloads=strip,
20482066
ip_db_path=opts.ip_db_path,
20492067
always_use_local_files=opts.always_use_local_files,
@@ -2189,6 +2207,7 @@ def log_output_error(destination, error):
21892207
test=opts.mailbox_test,
21902208
strip_attachment_payloads=opts.strip_attachment_payloads,
21912209
since=opts.mailbox_since,
2210+
dns_retries=opts.dns_retries,
21922211
normalize_timespan_threshold_hours=normalize_timespan_threshold_hours_value,
21932212
)
21942213

@@ -2272,6 +2291,7 @@ def _handle_sighup(signum, frame):
22722291
check_timeout=mailbox_check_timeout_value,
22732292
nameservers=opts.nameservers,
22742293
dns_timeout=opts.dns_timeout,
2294+
dns_retries=opts.dns_retries,
22752295
strip_attachment_payloads=opts.strip_attachment_payloads,
22762296
batch_size=mailbox_batch_size_value,
22772297
since=opts.mailbox_since,

0 commit comments

Comments
 (0)