Skip to content

dns: distinguish "this name does not resolve" from "this resolver failed" - #18

Merged
myleshorton merged 2 commits into
mainfrom
fisk/dns-error-discrimination
Jul 31, 2026
Merged

dns: distinguish "this name does not resolve" from "this resolver failed"#18
myleshorton merged 2 commits into
mainfrom
fisk/dns-error-discrimination

Conversation

@myleshorton

@myleshorton myleshorton commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Unblocks the re-selection follow-up recorded in spark's ADR 0014 — the first of the three open items from the proxyless port.

The problem this removes

spark's proxyless transport wants to drop a strategy whose resolver has died, but couldn't: every resolve failure arrived as io::Error::other, so NXDOMAIN for a mistyped host was indistinguishable from a blackholed resolver.

That is not hypothetical — I shipped exactly that self-heal in spark#137 and had to revert it during review, because acting on the signal would have discarded a working strategy and forced a full verified search every time a user visited a domain that does not exist.

The information was always there

codec::parse_response already returns Rcode(3) for NXDOMAIN. validate_answers already separates Empty from Poisoned. It was map_err(io::Error::other) throwing the distinction away at the very last step.

Each failure now maps onto a meaningful io::ErrorKind:

Kind Cases Blame
NotFound NXDOMAIN; answer with no usable address the name
InvalidData SERVFAIL/REFUSED, truncated, not-a-response, mismatched txn ID, bogon-only the resolver
InvalidInput unencodable name us — nothing was sent
transport kinds timeout, refused, unreachable the resolver

Poisoned deliberately sits with the resolver rather than with NotFound: bogons mean something is lying, which is very much about the resolver (or whoever is speaking for it).

The API callers actually want

pub fn indicts_resolver(err: &io::Error) -> bool

"Should I stop trusting this resolver?" — so callers needn't learn the mapping. No signatures changed, so existing callers are unaffected; they just get better-kinded errors.

One honest exception

Kind::System cannot be discriminated: getaddrinfo reports "no such host" and "the network is down" through the same opaque failure, so a system-resolver error is reported as indicting. That errs toward re-selecting, which is the safe direction — moving off the OS resolver is cheap and is the right call if the network really did change. Documented on the function.

Verification

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo test --workspace — clean; flint-dns 33 tests, 3 new covering both sides of the distinction plus the caller-bug case
  • cargo doc — zero warnings

Follow-up

With this merged and the pin bumped, spark can reinstate the self-heal it had to revert — gated on indicts_resolver so a typo'd domain no longer evicts a good strategy. That is the other half of the re-selection item; an explicit network-change control handle remains a separate, larger option.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BufGsK81otiqihvrZdfoJX

Summary by CodeRabbit

  • New Features
    • Improved DNS error classification for missing records, invalid names, and resolver-related failures.
    • Added consistent error handling across DNS transport paths.
  • Bug Fixes
    • Prevented expected “not found” responses from being attributed to resolvers.
    • Improved handling of malformed, unavailable, poisoned, server-failure, and truncated responses.
    • Ensured truncated responses are rejected appropriately while preserving “not found” results.
  • Tests
    • Added coverage for invalid names, resolver failures, transport errors, unencodable names, and truncated responses.

…led"

Unblocks the re-selection follow-up recorded in spark's ADR 0014. The proxyless transport
wants to drop a strategy whose resolver has died, but could not: every resolve failure
arrived as `io::Error::other`, so NXDOMAIN for a mistyped host was indistinguishable from
a blackholed resolver. Acting on that signal would have discarded a working strategy —
and forced a full verified search — every time a user visited a domain that does not
exist, which is why the earlier attempt at self-healing had to be reverted.

The information was always there, just flattened. `codec::parse_response` already returns
`Rcode(3)` for NXDOMAIN and `validate_answers` already separates `Empty` from `Poisoned`;
`map_err(io::Error::other)` threw the distinction away at the last step. Now each failure
maps onto a meaningful `io::ErrorKind`:

- **NotFound** — NXDOMAIN, or an answer with no usable address. The resolver did its job.
- **InvalidData** — SERVFAIL/REFUSED, truncated, not-a-response, mismatched transaction ID,
  or bogon-only records. The resolver answered and the answer cannot be believed. Poisoned
  belongs here rather than with NotFound: bogons mean something is lying, which is very
  much about the resolver.
- **InvalidInput** — an unencodable name. Our bug; nothing was ever sent.
- transport kinds pass through untouched, which is the clearest case of all.

`indicts_resolver(&io::Error)` is the predicate callers actually want — "should I stop
trusting this resolver?" — so they need not learn the mapping. No signatures changed, so
existing callers are unaffected; they simply get better-kinded errors.

`Kind::System` is documented as the exception: `getaddrinfo` reports "no such host" and
"the network is down" through the same opaque failure, so it is reported as indicting.
That errs toward re-selecting, the safe direction — moving off the OS resolver is cheap
and is the right move if the network really did change.

Gate: fmt, clippy --workspace --all-targets -D warnings, cargo test --workspace
(flint-dns 33, three new covering both sides of the distinction), and cargo doc with zero
warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BufGsK81otiqihvrZdfoJX
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 89f04471-112d-441f-a7d5-69e82aa2fe52

📥 Commits

Reviewing files that changed from the base of the PR and between 01a3bd5 and 43fb7a1.

📒 Files selected for processing (2)
  • crates/flint-dns/src/codec.rs
  • crates/flint-dns/src/lib.rs

📝 Walkthrough

Walkthrough

The DNS resolver now classifies failures by resolver attribution. NXDOMAIN and empty validated answers map to NotFound. Resolver failures map to indicting error kinds. Invalid names map to InvalidInput. Truncated responses return DnsError::Truncated after RCODE validation.

Changes

DNS resolver attribution

Layer / File(s) Summary
Truncated response handling
crates/flint-dns/src/codec.rs
parse_response rejects TC-flagged responses after RCODE validation. Tests cover truncated answers and truncated NXDOMAIN responses.
Error classification and transport integration
crates/flint-dns/src/lib.rs
Adds indicts_resolver, codec_err, and validate_err. DoH, DoT, TCP, UDP, and final validation paths use the centralized mappings. Tests cover name errors, resolver failures, transport failures, and validation failures.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DNSResolver
  participant DNSTransport
  participant parse_response
  participant ErrorClassifier
  DNSResolver->>DNSTransport: send DNS query
  DNSTransport->>parse_response: provide DNS response
  parse_response-->>ErrorClassifier: return response or codec error
  ErrorClassifier-->>DNSResolver: classify resolver attribution
Loading

Possibly related PRs

  • getlantern/flint#13: Introduced related DNS codec and resolver transaction-ID handling used by these parsing and error-classification changes.

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: distinguishing nonexistent DNS names from resolver failures.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fisk/dns-error-discrimination

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/flint-dns/src/lib.rs`:
- Around line 96-108: Update codec::parse_response to inspect the DNS header
flags before parsing records, and return codec::DnsError::Truncated when flags &
0x0200 is nonzero. Add a test covering a syntactically valid response with the
TC flag set and verify it is rejected as truncation rather than producing
partial addresses; keep codec_err’s existing InvalidData mapping unchanged.
- Around line 92-94: Update indicts_resolver to return false for both
io::ErrorKind::NotFound and io::ErrorKind::InvalidInput, while preserving
indictment for other errors. In crates/flint-dns/src/lib.rs lines 375-383, add
an assertion verifying that an InvalidInput error does not indict the resolver.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4df7b01c-1571-4441-8140-0212f338bfd5

📥 Commits

Reviewing files that changed from the base of the PR and between 01a3bd5 and 2f2f0bb.

📒 Files selected for processing (1)
  • crates/flint-dns/src/lib.rs

Comment thread crates/flint-dns/src/lib.rs
Comment thread crates/flint-dns/src/lib.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves flint-dns error classification so callers can reliably distinguish “the name doesn’t resolve” (e.g., NXDOMAIN / no usable records) from “the resolver failed/misbehaved” (e.g., SERVFAIL, malformed responses, poisoning), enabling higher-level strategy re-selection without false positives.

Changes:

  • Adds indicts_resolver(&io::Error) -> bool as a simple policy helper for callers.
  • Preserves resolver-vs-name attribution by mapping codec and validation failures into meaningful io::ErrorKinds (instead of flattening to Other).
  • Adds unit tests covering the new kind mapping and the resolver-indictment decision boundary.
Suppressed comments (2)

crates/flint-dns/src/lib.rs:103

  • This comment says an unencodable DNS name is “our bug”, but codec::DnsError::BadName can be triggered by caller-supplied input (e.g., a label > 63 bytes). It’s more accurate to describe this as invalid input / nothing sent, rather than an internal bug.
        // A name we could not even encode is our bug, not the resolver's.
        codec::DnsError::BadName => io::ErrorKind::InvalidInput,

crates/flint-dns/src/lib.rs:380

  • This test’s name/comment (“our bug” / “still indicts”) becomes misleading once InvalidInput is treated as non-indicting. Consider asserting the indicts_resolver behavior here so the contract stays covered.
    fn an_unencodable_name_is_our_bug_not_a_resolver_failure() {
        // Still "indicts" (it is not a NotFound), but the kind records who to blame: nothing was ever
        // sent, so no resolver was involved.
        assert_eq!(
            codec_err(codec::DnsError::BadName).kind(),

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/flint-dns/src/lib.rs
…uncated answers

Review catches (CodeRabbit + Copilot, PR #18). Both reviewers independently found the
first, which is a logic bug of mine.

**`indicts_resolver` blamed the resolver for a caller error.** `DnsError::BadName` maps to
`InvalidInput` precisely because *we* could not encode the name and nothing was ever sent
— the doc and the PR's own mapping table both say so — yet the predicate returned `true`
for everything except `NotFound`. A caller would have discarded a perfectly good resolver
over a name that never left the process, which is the same class of mistake as blaming it
for a typo'd domain: exactly what this predicate exists to prevent.

My own test made it easy to miss: it asserted the *kind* was `InvalidInput` without ever
asking what `indicts_resolver` said about it. Now both are asserted.

**The TC flag was never checked.** `parse_response` validated QR and RCODE but not TC, so
a syntactically complete response the server had cut short would parse as a success and
return whatever records happened to fit. That mattered little before; it matters now,
because if truncation removed every address record the result would be "no addresses" →
`NotFound` → "this name does not resolve" — and this PR makes callers *act* on that. The
verdict would be exactly backwards: the resolver is the thing that failed to serve us, so
it should be indicted, not excused.

TC is now checked after the RCODE, so a truncated NXDOMAIN still reports the more specific
NXDOMAIN, and maps to `InvalidData` (indicting). We do no TCP retry, so a resolver that
truncates genuinely cannot answer the query and should lose to one that can. Two tests:
that a TC answer is rejected rather than partially parsed (with the flag cleared again to
prove TC is what rejected it, not the fixture), and that NXDOMAIN wins over TC.

Gate: fmt, clippy --workspace --all-targets -D warnings, cargo test --workspace
(flint-dns 35, two more new), and cargo doc with zero warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BufGsK81otiqihvrZdfoJX
@myleshorton
myleshorton requested a review from Copilot July 31, 2026 15:51
@myleshorton

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 59 minutes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@myleshorton
myleshorton merged commit 29a0046 into main Jul 31, 2026
3 checks passed
@myleshorton
myleshorton deleted the fisk/dns-error-discrimination branch July 31, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants