Skip to content

Tell "the host didn't answer" apart from "we refuse that address" - #1

Merged
jeremy merged 1 commit into
mainfrom
unresolvable-distinct
Aug 7, 2026
Merged

Tell "the host didn't answer" apart from "we refuse that address"#1
jeremy merged 1 commit into
mainfrom
unresolvable-distinct

Conversation

@jeremy

@jeremy jeremy commented Aug 7, 2026

Copy link
Copy Markdown
Member

"The host didn't answer" and "we refuse that address" arrived as the same nil, empty list, or false. That reads fine until a caller treats a refusal as permanent — a webhook that deactivates a customer's endpoint when the guard refuses it would retire that endpoint on one bad DNS minute, because a lookup returning nothing was indistinguishable from a lookup returning the metadata address.

A refusal is a statement about the address. An empty lookup is a statement about the moment. So a host that resolves to nothing now raises Unresolvable, deliberately not a subclass of Violation — callers that don't care can rescue both, and the ones that do can finally choose.

resolves to something public resolves, all blocked resolves to nothing malformed URL
resolve_public_ips (takes a host) the public addresses [] raises Unresolvable
resolve_public_ip first public address nil raises Unresolvable nil
enforce_public_ip returns raises Violation raises Unresolvable raises Violation
resolvable_public_ip? true false false false

resolvable_public_ip? still answers false either way — a predicate answers the question it was asked and doesn't raise.

This is also where a guard built on Resolv.getaddress used to raise Resolv::ResolvError, so callers migrating from one keep a distinction they already had rather than silently losing it.

Two bugs the tests turned up

The resolver's ResolvError rescue was dead code. Resolv.getaddresses sat inside the rescue IPAddr::InvalidAddressError clause that detects an IP literal, and sibling rescue clauses don't catch each other — so the rescue Resolv::ResolvError, Resolv::ResolvTimeout below it could never fire. Only reachable through a resolver configured to raise timeout errors, so nothing was breaking today, but it read as protection it wasn't providing. The literal check moves into its own method and the DNS call moves back into the body where the rescue reaches it.

A blank host resolved to nothing instead of being refused. URI#host is "" for "http://", not nil, so it slipped past host_of and came back as a failed lookup. There was never a name to look up — that's malformed, and it now says so.

Compatibility

Breaking for anyone relying on resolve_public_ip/resolve_public_ips returning nil/[] for an unresolvable host, or on enforce_public_ip raising Violation for one. Rescuing Unresolvable alongside Violation restores the old behavior exactly.

63 tests green on the existing suite plus the new cases.

Both arrived as the same nil, empty list or false, which reads fine until a
caller treats a refusal as permanent. A webhook that deactivates a customer's
endpoint when the guard refuses it would retire that endpoint on one bad DNS
minute, because a lookup returning nothing was indistinguishable from a lookup
returning the metadata address.

They are not the same answer. A refusal is a statement about the address; an
empty lookup is a statement about the moment. So a host that resolves to nothing
now raises Unresolvable, deliberately not a subclass of Violation -- callers that
don't care can rescue both, and the ones that do can finally choose. This is also
where a guard built on Resolv.getaddress used to raise Resolv::ResolvError, so
callers migrating from one keep the distinction they already had.

resolvable_public_ip? still answers false either way. A predicate answers the
question it was asked and doesn't raise.

Two things fell out of writing the tests:

Resolv.getaddresses sat inside the `rescue IPAddr::InvalidAddressError` clause
that detects an IP literal, so the `rescue Resolv::ResolvError, ResolvTimeout`
below it could never fire -- sibling rescue clauses don't catch each other. The
literal check moves into its own method and the DNS call moves back into the
body, where the rescue reaches it. It was only ever reachable through a resolver
configured to raise timeouts, but it read as protection it wasn't providing.

URI#host is "" for "http://", not nil, so a blank host slipped past host_of and
came back as a lookup that resolved to nothing. There was never a name to look
up: that's malformed, and it now says so.
Copilot AI balanced review requested due to automatic review settings August 7, 2026 17:35

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy
jeremy merged commit a865bd2 into main Aug 7, 2026
9 of 11 checks passed
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