| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
BlastDNS is an ultra-fast DNS resolver written in Rust. Like massdns, it's designed to be faster the more resolvers you give it. Features include built-in caching, and high accuracy even with unreliable DNS servers. For details, see Architecture. BlastDNS is the main DNS library used by BBOT.
There are three ways to use it:
100K DNS lookups against local dnsmasq, with 100 workers:
This is one regime: a single resolver, no loss, no latency, which is the case that most favors raw dispatch and resembles no real workload. scripts/benchmark.py now reports several regimes — a resolver pool, injected loss, injected latency, and both socket transports — alongside delivery, retry amplification, completion percentiles, and socket plus connection-tracking peaks. Absolute rates here move over 30% between runs on shared CI hardware, so the ratio column is the durable figure.
| Library | Language | Time | QPS | Success | Failed | vs dnspython |
|---|---|---|---|---|---|---|
| massdns | C | 1.370s | 72,998 | 100,000 | 0 | 28.63x |
| blastdns-cli | Rust | 1.654s | 60,470 | 100,000 | 0 | 23.72x |
| blastdns-python | Python | 2.485s | 40,249 | 100,000 | 0 | 15.79x |
| dnspython | Python | 39.223s | 2,550 | 100,000 | 0 | 1.00x |
The CLI mass-resolves hosts using a specified list of resolvers. It outputs to JSON.
# send all results to jq
$ blastdns hosts.txt --rdtype A --resolvers resolvers.txt | jq
# print only the raw IPv4 addresses
$ blastdns hosts.txt --rdtype A --resolvers resolvers.txt | jq '.response.answers[].rdata.A'
# load from stdin
$ cat hosts.txt | blastdns --rdtype A --resolvers resolvers.txt
# skip empty responses (e.g., NXDOMAIN with no answers)
$ blastdns hosts.txt --rdtype A --resolvers resolvers.txt --skip-empty | jq
# skip error responses (e.g., timeouts, connection failures)
$ blastdns hosts.txt --rdtype A --resolvers resolvers.txt --skip-errors | jqBlastDNS - Ultra-fast DNS Resolver written in Rust
Usage: blastdns [OPTIONS] --resolvers <FILE> [HOSTS_TO_RESOLVE]
Arguments:
[HOSTS_TO_RESOLVE] File containing hostnames to resolve (one per line). Reads from stdin if not specified
Options:
--rdtype <RECORD_TYPE>
Record type to query (A, AAAA, MX, ...) [default: A]
--resolvers <FILE>
File containing DNS nameservers (one per line)
--max-inflight-per-resolver <MAX_INFLIGHT_PER_RESOLVER>
Maximum queries in flight to any single resolver [default: 2]
--max-concurrency <MAX_CONCURRENCY>
Maximum queries in flight across all resolvers [default: 256]
--rate-limit <RATE_LIMIT>
Ceiling on dispatch rate in queries per second (0 = unlimited) [default: 0]
--resolver-probe
Drop resolvers that don't answer a probe query at startup
--no-adaptive
Disable automatic backoff when resolvers start losing queries
--persistent-socket
Keep one long-lived socket per resolver instead of binding one per query
--timeout-ms <TIMEOUT_MS>
Per-request timeout in milliseconds [default: 1000]
--retries <RETRIES>
Retry attempts after a resolver failure [default: 10]
--purgatory-threshold <PURGATORY_THRESHOLD>
Consecutive errors before a worker is put into timeout [default: 10]
--purgatory-sentence-ms <PURGATORY_SENTENCE_MS>
How many milliseconds a worker stays in timeout [default: 1000]
--skip-empty
Don't show responses with no answers
--skip-errors
Don't show error responses
--brief
Output brief format (hostname, record type, answers only)
--cache-capacity <CACHE_CAPACITY>
DNS cache capacity (0 = disabled) [default: 10000]
-h, --help
Print help
-V, --version
Print version
BlastDNS outputs to JSON by default:
{
"host": "microsoft.com",
"response": {
"additionals": [],
"answers": [
{
"dns_class": "IN",
"name_labels": "microsoft.com.",
"rdata": {
"A": "13.107.213.41"
},
"ttl": 1968
},
{
"dns_class": "IN",
"name_labels": "microsoft.com.",
"rdata": {
"A": "13.107.246.41"
},
"ttl": 1968
}
],
"edns": {
"flags": {
"dnssec_ok": false,
"z": 0
},
"max_payload": 1232,
"options": {
"options": []
},
"rcode_high": 0,
"version": 0
},
"header": {
"additional_count": 1,
"answer_count": 2,
"authentic_data": false,
"authoritative": false,
"checking_disabled": false,
"id": 62150,
"message_type": "Response",
"name_server_count": 0,
"op_code": "Query",
"query_count": 1,
"recursion_available": true,
"recursion_desired": true,
"response_code": "NoError",
"truncation": false
},
"name_servers": [],
"queries": [
{
"name": "microsoft.com.",
"query_class": "IN",
"query_type": "A"
}
],
"signature": []
}
}BlastDNS uses the standard Rust tracing ecosystem. Enable debug logging by setting the RUST_LOG environment variable:
# Show debug logs from blastdns only
RUST_LOG=blastdns=debug blastdns hosts.txt --rdtype A --resolvers resolvers.txt
# Show debug logs from everything
RUST_LOG=debug blastdns hosts.txt --rdtype A --resolvers resolvers.txt
# Show trace-level logs for detailed internal behavior
RUST_LOG=blastdns=trace blastdns hosts.txt --rdtype A --resolvers resolvers.txtValid log levels (from least to most verbose): error, warn, info, debug, trace
# Install CLI tool
cargo install blastdns
# Add library to your project
cargo add blastdnsBlastDNS can either use system resolvers (detected automatically from OS configuration) or custom resolvers:
use blastdns::{BlastDNSClient, BlastDNSConfig};
use futures::StreamExt;
use hickory_client::proto::rr::RecordType;
use std::time::Duration;
// Option 1: Use system DNS resolvers (default)
let client = BlastDNSClient::new(vec![]).await?;
// Check what resolvers are being used
println!("Using resolvers: {:?}", client.resolvers());
// Option 2: Read DNS resolvers from a file (one per line -> vector of strings)
let resolvers = std::fs::read_to_string("resolvers.txt")
.expect("Failed to read resolvers file")
.lines()
.map(str::to_string)
.collect::<Vec<String>>();
// create a new blastdns client with default config
let client = BlastDNSClient::new(resolvers).await?;
// or with custom config
let mut config = BlastDNSConfig::default();
config.max_concurrency = 512; // total queries in flight
config.max_inflight_per_resolver = 4; // per-resolver politeness bound
config.request_timeout = Duration::from_secs(2);
let client = BlastDNSClient::with_config(resolvers, config).await?;
// resolve: lookup a domain, returns only the rdata strings
let answers = client.resolve("example.com", RecordType::A).await?;
for answer in answers {
println!("{}", answer); // e.g., "93.184.216.34"
}
// resolve_full: lookup a domain, returns the full DNS response
let result = client.resolve_full("example.com", RecordType::A).await?;
println!("{}", serde_json::to_string_pretty(&result).unwrap());
// resolve_batch: process many hosts in parallel, returns simplified output
// streams back (host, record_type, Vec<rdata>) tuples as they complete
// automatically filters out errors and empty responses
let wordlist = ["one.example", "two.example", "three.example"];
let mut stream = client.resolve_batch(
wordlist.into_iter().map(Ok::<_, std::convert::Infallible>),
RecordType::A,
);
while let Some((host, record_type, answers)) = stream.next().await {
println!("{} ({}):", host, record_type);
for answer in answers {
println!(" {}", answer); // e.g., "93.184.216.34" for A records
}
}
// resolve_batch_full: process many hosts with full DNS response structures
// streams back (host, Result<response>) tuples with configurable filtering
let wordlist = ["one.example", "two.example", "three.example"];
let mut stream = client.resolve_batch_full(
wordlist.into_iter().map(Ok::<_, std::convert::Infallible>),
RecordType::A,
false, // skip_empty: don't filter out empty responses
false, // skip_errors: don't filter out errors
);
while let Some((host, outcome)) = stream.next().await {
match outcome {
Ok(response) => println!("{}: {} answers", host, response.answers().len()),
Err(err) => eprintln!("{} failed: {err}", host),
}
}
// resolve_multi: resolve multiple record types for a single host
// returns only successful results with answers as dict[record_type, Vec<rdata>]
let record_types = vec![RecordType::A, RecordType::AAAA, RecordType::MX];
let results = client.resolve_multi("example.com", record_types).await?;
for (record_type, answers) in results {
println!("{}: {} answers", record_type, answers.len());
for answer in answers {
println!(" {}", answer);
}
}
// resolve_multi_full: resolve multiple record types with full responses
// returns all results (success and failure) as dict[record_type, Result<response>]
let record_types = vec![RecordType::A, RecordType::AAAA, RecordType::MX];
let results = client.resolve_multi_full("example.com", record_types).await?;
for (record_type, result) in results {
match result {
Ok(response) => println!("{}: {} answers", record_type, response.answers().len()),
Err(err) => eprintln!("{} failed: {err}", record_type),
}
}You can retrieve the system's configured DNS resolvers programmatically:
use blastdns::get_system_resolvers;
// Get system resolver IPs (works on Unix, Windows, macOS, Android)
let resolver_ips = get_system_resolvers()?;
for ip in resolver_ips {
println!("System resolver: {}", ip);
}MockBlastDNSClient implements the DnsResolver trait and provides a drop-in replacement that returns fabricated DNS responses without making real network requests.
use blastdns::{MockBlastDNSClient, DnsResolver};
use hickory_client::proto::rr::RecordType;
use std::collections::HashMap;
// Create a mock client
let mut mock_client = MockBlastDNSClient::new();
// Configure mock responses
let responses = HashMap::from([
(
"example.com".to_string(),
HashMap::from([
("A".to_string(), vec!["93.184.216.34".to_string()]),
("AAAA".to_string(), vec!["2606:2800:220:1:248:1893:25c8:1946".to_string()]),
]),
),
]);
// Hosts that should return NXDOMAIN
let nxdomains = vec!["notfound.example.com".to_string()];
mock_client.mock_dns(responses, nxdomains);
// Use like any DnsResolver
let answers = mock_client.resolve("example.com".to_string(), RecordType::A).await?;
assert_eq!(answers, vec!["93.184.216.34"]);
// NXDOMAIN hosts return empty responses
let answers = mock_client.resolve("notfound.example.com".to_string(), RecordType::A).await?;
assert_eq!(answers.len(), 0);MockBlastDNSClient supports all DnsResolver methods including resolve, resolve_full, resolve_batch, resolve_batch_full, resolve_multi, and resolve_multi_full.
The blastdns Python package is a thin wrapper around the Rust library.
# Using pip
pip install blastdns
# Using uv
uv add blastdns
# Using poetry
poetry add blastdns# install python dependencies
uv sync
# build and install the rust->python bindings
uv run maturin develop
# run tests
uv run pytestTo use it in Python, you can use the Client class:
import asyncio
from blastdns import Client, ClientConfig, DNSResult, DNSError, get_system_resolvers
async def main():
# Option 1: Use system resolvers (pass empty list)
client = Client([], ClientConfig(max_inflight_per_resolver=4, request_timeout_ms=1500))
# Check what resolvers are being used
print(f"Using resolvers: {client.resolvers}")
# Option 2: Manually get system resolvers
system_resolvers = get_system_resolvers()
print(f"System resolvers: {system_resolvers}")
# Option 3: Use custom resolvers
resolvers = ["1.1.1.1:53", "8.8.8.8:53"]
client = Client(resolvers, ClientConfig(max_inflight_per_resolver=4, request_timeout_ms=1500))
# resolve: lookup a single host, returns only rdata strings
answers = await client.resolve("example.com", "A")
for answer in answers:
print(f" {answer}") # e.g., "93.184.216.34"
# resolve_full: lookup a single host, returns full DNS response as Pydantic model
result = await client.resolve_full("example.com", "AAAA")
print(f"Host: {result.host}")
print(f"Response code: {result.response.header.response_code}")
for answer in result.response.answers:
print(f" {answer.name_labels}: {answer.rdata}")
# resolve_batch: simplified batch resolution with minimal output
# returns only (host, record_type, list[rdata]) - no full DNS response structures
# automatically filters out errors and empty responses
hosts = ["example.com", "google.com", "github.com"]
async for host, rdtype, answers in client.resolve_batch(hosts, "A"):
print(f"{host} ({rdtype}):")
for answer in answers:
print(f" {answer}") # e.g., "93.184.216.34" for A records
# resolve_batch_full: process many hosts in parallel with full responses
# streams results back as they complete
hosts = ["one.example.com", "two.example.com", "three.example.com"]
async for host, result in client.resolve_batch_full(hosts, "A"):
if isinstance(result, DNSError):
print(f"{host} failed: {result.error}")
else:
print(f"{host}: {len(result.response.answers)} answers")
# resolve_multi: resolve multiple record types for a single host in parallel
# returns only successful results with answers
record_types = ["A", "AAAA", "MX"]
results = await client.resolve_multi("example.com", record_types)
for record_type, answers in results.items():
print(f"{record_type}: {answers}")
# resolve_multi_full: resolve multiple record types with full response data
record_types = ["A", "AAAA", "MX"]
results = await client.resolve_multi_full("example.com", record_types)
for record_type, result in results.items():
if isinstance(result, DNSError):
print(f"{record_type} failed: {result.error}")
else:
print(f"{record_type}: {len(result.response.answers)} answers")
asyncio.run(main())Client.resolvers (property): Get the list of resolver addresses being used by this client. Returns a list of strings (e.g., ["8.8.8.8:53", "1.1.1.1:53"]).
get_system_resolvers() -> list[str]: Get system DNS resolver IP addresses from OS configuration. Works on Unix, Windows, macOS, and Android. Returns a list of IP addresses without ports (e.g., ["8.8.8.8", "1.1.1.1"]). Useful for inspecting what resolvers the OS is configured to use.
Client.resolve(host, record_type=None) -> list[str]: Lookup a single hostname, returning only rdata strings. Defaults to A records. Returns a list of strings (e.g., ["93.184.216.34"] for A records). Perfect for simple use cases where you just need the record data without the full DNS response structure.
Client.resolve_full(host, record_type=None) -> DNSResult: Lookup a single hostname, returning the full DNS response. Defaults to A records. Returns a Pydantic DNSResult model with typed fields for easy access to headers, queries, answers, etc.
Client.resolve_batch(hosts, record_type=None): Simplified batch resolution that returns only the essential data. Takes an iterable of hostnames and streams back (host, record_type, answers) tuples where answers is a list of rdata strings (e.g., ["93.184.216.34"] for A records, ["10 aspmx.l.google.com."] for MX records). Automatically filters out errors and empty responses. Perfect for processing large lists of hosts efficiently.
Client.resolve_batch_full(hosts, record_type=None, skip_empty=False, skip_errors=False): Resolve many hosts in parallel with full DNS responses. Takes an iterable of hostnames and streams back (host, result) tuples as results complete. Each result is either a DNSResult or DNSError Pydantic model. Set skip_empty=True to filter out successful responses with no answers. Set skip_errors=True to filter out error responses.
Client.resolve_multi(host, record_types) -> dict[str, list[str]]: Resolve multiple record types for a single hostname in parallel, returning only successful results with answers. Takes a list of record type strings (e.g., ["A", "AAAA", "MX"]) and returns a dictionary mapping record types to lists of rdata strings. Only includes record types that resolved successfully and have answers.
Client.resolve_multi_full(host, record_types) -> dict[str, DNSResultOrError]: Resolve multiple record types for a single hostname in parallel, returning full DNS responses. Takes a list of record type strings and returns a dictionary keyed by record type. Each value is either a DNSResult (success) or DNSError (failure) Pydantic model. Includes all record types, even those that failed or had no answers.
MockClient provides a drop-in replacement for Client that returns fabricated DNS responses without making real network requests. It implements the same interface as Client and is useful for testing code that depends on DNS lookups.
import pytest
from blastdns import MockClient, DNSResult
@pytest.fixture
def mock_client():
"""Create a mock client with pre-configured test data."""
client = MockClient()
client.mock_dns({
"example.com": {
"A": ["93.184.216.34"],
"AAAA": ["2606:2800:220:1:248:1893:25c8:1946"],
"MX": ["10 aspmx.l.google.com.", "20 alt1.aspmx.l.google.com."],
},
"cname.example.com": {
"CNAME": ["example.com."]
},
"_NXDOMAIN": ["notfound.example.com"], # hosts that return NXDOMAIN
})
return client
@pytest.mark.asyncio
async def test_my_function(mock_client):
# resolve() returns simple rdata strings
answers = await mock_client.resolve("example.com", "A")
assert answers == ["93.184.216.34"]
# resolve_full() returns full DNS response structure
result = await mock_client.resolve_full("example.com", "A")
assert isinstance(result, DNSResult)
assert len(result.response.answers) == 1
# NXDOMAIN hosts return empty responses (not errors)
answers = await mock_client.resolve("notfound.example.com", "A")
assert len(answers) == 0
# resolve_batch() works with all mocked hosts
async for host, rdtype, answers in mock_client.resolve_batch(["example.com"], "A"):
print(f"{host}: {answers}") # ["93.184.216.34"]
# resolve_multi() resolves multiple record types in parallel
results = await mock_client.resolve_multi("example.com", ["A", "AAAA", "MX"])
assert len(results) == 3
assert results["MX"] == ["10 aspmx.l.google.com.", "20 alt1.aspmx.l.google.com."]Regex Patterns:
Hostnames prefixed with regex: are treated as regex patterns, enabling wildcard and dynamic matching:
client = MockClient()
client.mock_dns({
# Exact match
"specific.example.com": {"A": ["10.0.0.1"]},
# Regex: match any subdomain of example.com
"regex:.*\\.example\\.com": {"A": ["192.168.1.1"]},
# Regex: match numbered servers
"regex:^server-\\d+\\.test\\.com$": {"A": ["10.0.0.1"]},
# Regex patterns work for NXDOMAIN too
"_NXDOMAIN": ["regex:^bad-.*\\.example\\.com$"],
})Exact matches take priority over regex patterns. When multiple regex patterns match, the first match wins.
Key Features:
All errors raised by blastdns are subclasses of BlastDNSError:
BlastDNSError ├── ConfigurationError # invalid resolver address, invalid hostname, bad config │ └── NoResolversError # no resolvers provided or detected └── ResolverError # resolver failed (timeout, connection failure, etc.)
from blastdns import Client, BlastDNSError, ConfigurationError, NoResolversError, ResolverError
# Catch broadly
try:
client = Client(["not-an-ip"])
except BlastDNSError as e:
print(f"blastdns error: {e}")
# Catch narrowly
try:
client = Client(["not-an-ip"])
except ConfigurationError as e:
print(f"bad config: {e}")
# Catch resolver failures during queries
try:
result = await client.resolve_full("example.com", "A")
except ResolverError as e:
print(f"resolver failed: {e}")The *_full() methods return Pydantic V2 models for type safety and IDE autocomplete:
The base methods (resolve, resolve_batch, resolve_multi) return simple Python types (lists, dicts, strings) for convenience when you don't need the full response structure.
ClientConfig exposes the knobs shown above (max_concurrency, max_inflight_per_resolver, rate_limit, adaptive, resolver_probe, persistent_socket, request_timeout_ms, max_retries, purgatory_threshold, purgatory_sentence_ms) and validates them before handing them to the Rust core. Unknown keys are rejected rather than ignored, so a stale or misspelled option fails loudly instead of silently taking a default.
Client.stats() returns a ResolverStats per resolver, with cumulative counters where attempted == answered + empty + timeout + error. Diff two snapshots to account for a batch in full, including queries that never came back:
before = {s.resolver: s.attempted for s in client.stats()}
async for host, rdtype, answers in client.resolve_batch(hosts, "A"):
...
for s in client.stats():
sent = s.attempted - before[s.resolver]
print(f"{s.resolver}: {sent} sent, {s.timeout} timed out, pacing={s.rate_qps or 'unlimited'}")BlastDNS is built on top of hickory-dns, but only makes use of the low-level Client API, not the Resolver API.
When a user calls BlastDNSClient::resolve, a new WorkItem is created which contains the request (host + rdtype) and a oneshot channel to hold the result. This WorkItem is put into a crossfire MPMC queue, to be picked up by the first available ResolverWorker. Workers are spawned lazily when the first request is made.
Workers are not bound to a resolver. BlastDNSConfig.max_concurrency (default: 256) sets how many workers exist and therefore how many queries are in flight overall, independently of how many resolvers are configured. Each worker picks a resolver per query, so adding resolvers increases the throughput available at a given politeness level rather than changing the concurrency limit.
Each query binds its own UDP socket by default, so file descriptor use tracks concurrency rather than the size of the resolver list. persistent_socket swaps that for one long-lived socket per resolver, shared by every worker that selects it; see Sockets for the tradeoff, which is mostly about connection-tracking state rather than descriptors.
Three independent limits govern throughput. The tightest one binds.
By default a UDP socket is bound per query and closed when it completes, which randomizes the source port. That is worth having: an off-path attacker forging a response has to guess the port as well as the 16-bit query ID, which is the difference between 65 thousand guesses and four billion. Live sockets therefore track max_concurrency, not the size of the resolver list.
The cost is connection-tracking state. Every query is a fresh source port, so every query is a distinct flow to any NAT or stateful firewall in the path, and those entries outlive the socket by the kernel's UDP timeout — 30 seconds is typical. Connection-tracking state therefore grows with the query rate, not with concurrency. A sustained few thousand queries per second is enough to fill a home router's table and start dropping unrelated traffic.
persistent_socket trades that away. One socket per resolver is opened on first use and multiplexed by query ID, so flows are bounded by the size of the resolver list however long the run goes. On a single-domain brute-force this measured 114,342 connection-tracking entries per-query against roughly 5,800 persistent, at the same throughput. Two things to know before enabling it:
With adaptive enabled (the default), BlastDNS does not need to be told how fast to go. It watches loss per resolver, and when a resolver starts dropping queries it records the rate at which that happened and holds below it. A configured rate_limit is a separate hard cap applied on top: adaptation happens either way, and the configured value only ever lowers the effective rate.
A discovered per-resolver limit expires after a while so the controller probes upward again, which keeps one transient blip from capping the rest of a long run. The global limit has no expiry and does not need one: it is re-judged on every batch of finished queries and climbs straight back on clean ones.
Backing off is only repeated while it is working. A resolver earns its first retreat as soon as it starts losing queries, and further ones only while the loss is actually falling. That distinction matters because not all loss is caused by the rate: a resolver that drops a fixed share of what it receives, or one behind a lossy link, loses just as much however slowly it is asked, and retreating on every tick would walk the rate to a standstill without recovering a single query. Two readings in a row that fail to improve hold the rate where it is instead.
Loss is measured over a window at least as long as request_timeout_ms, because a query only counts as lost once its timeout expires. A shorter window puts a dispatch and its eventual timeout in different windows, so lowering the rate would drop the denominator while the previous rate's losses were still arriving — which reads as more than 100% loss and, worse, reads as improvement on the way back down.
Loss on a single resolver throttles only that resolver. Cutting the global rate takes a different signal: queries that failed after exhausting every retry. Individual attempt failures do not count, because a large public resolver list refuses a few percent of attempts as a matter of course and a retry elsewhere answers them — treating that as congestion makes a healthy pool look permanently saturated and drives the rate to a standstill. Neither do queries that never reached the network: a hostname that does not parse fails identically at one query per second and at fifty thousand, so a wordlist's junk entries cannot pace a scan down. Retries are never abandoned, only paced; suppressing them under load was tried and measured, and it took unanswered queries from 0% to 3.5% on a 5,000-name brute-force to save load that was never shown to be a problem.
The global ratio is judged once enough queries have finished to trust it, over as many ticks as that takes. This matters because the signal feeds back on itself — the limit bounds throughput and throughput bounds the sample count — so a rate low enough that no single tick gathers a usable sample has to be judged over several ticks instead of not at all. The practical effect is that backing off gets more cautious the slower things already are, and a path that recovers is always seen to recover.
Client.stats() reports the current pacing rate per resolver, so a run that is going slowly can be explained rather than guessed at. Note the field is an instantaneous reading: a resolver that was throttled and has since recovered reports no rate, so it undercounts episodes rather than accumulating them.
Per-resolver judgement needs enough queries against that one resolver to trust the ratio, which only happens when a resolver is carrying real load. Spread a few hundred queries per second across thousands of resolvers and no single one is individually measurable, so at brute-force scale the politeness bound is max_inflight_per_resolver rather than the controller — each resolver is capped at roughly max_inflight_per_resolver / RTT regardless.
With resolver_probe enabled, each resolver is queried once at startup and those that do not answer are dropped for the life of the client. This is aimed at large public resolver lists, where a substantial fraction of entries are typically dead. Probing queries the root nameservers, so liveness does not depend on any external zone.
The probe allows a looser deadline than a normal query — at least two seconds, however short request_timeout_ms is. Failing the probe removes a resolver for the whole run, and a root-NS query to a cold resolver is slower than the cached lookups most workloads make, so judging it on a few hundred milliseconds would evict resolvers that serve real traffic perfectly well and let a passing latency spike take out much of the pool at once.
BlastDNS includes an optional TTL-aware cache using an LRU eviction policy. The cache is enabled by default with a capacity of 10,000 entries and can be configured or disabled entirely:
Configure via BlastDNSConfig:
BlastDNS handles unreliable resolvers through a multi-layered retry system:
Client-Level Retries: When a query fails with a retryable error (network timeouts, connection failures), the client automatically retries up to max_retries times (default: 10). Each retry creates a fresh WorkItem and sends it back to the shared queue, where it can be picked up by any available worker—not necessarily the same resolver. This means retries naturally route around problematic resolvers.
Purgatory System: Each resolver tracks consecutive errors. After hitting purgatory_threshold failures (default: 10), the resolver is benched for purgatory_sentence milliseconds (default: 1000ms) and skipped during selection. This temporarily sidelines struggling resolvers without removing them entirely, allowing the system to self-heal if resolver issues are transient.
Non-Retryable Errors: Configuration errors (invalid hostnames) and system errors (queue closed) fail immediately without retry, preventing wasted work on queries that can't succeed.
This architecture ensures maximum accuracy even with a mixed pool of reliable and unreliable DNS servers, as queries naturally migrate toward responsive resolvers while problematic ones throttle themselves.
BlastDNS has two types of tests:
Unit tests use MockBlastDNSClient (Rust) or MockClient (Python) and run without any external dependencies:
# Rust unit tests
cargo test
# Python unit tests
uv run pytestIntegration tests verify real DNS resolution against a local dnsmasq server running on 127.0.0.1:5353 and [::1]:5353.
Install dnsmasq:
sudo apt install dnsmasqStart the test DNS server:
sudo ./scripts/start-test-dns.shRun integration tests:
# Rust integration tests (marked with #[ignore])
cargo test -- --ignored
# Python integration tests with real DNS
uv run pytest -k "not mock"When done, stop the test DNS server:
./scripts/stop-test-dns.sh# Run clippy for lints
cargo clippy --all-targets --all-features
# Run rustfmt for formatting
cargo fmt --all# Run ruff for lints
uv run ruff check --fix
# Run ruff for formatting
uv run ruff format| Back | FazBrowse Home | New Git URL |