A Name That Does Not Resolve Is Not Transient

A retry exists to give a transient failure another chance. A hostname that does not exist will not start existing, so retrying it is not resilience — it is three guaranteed failures, three log lines and a delayed answer.

What was measured

The control instance, 2026-09-17 (#4613). Two outbound fetches — an agent reading a company's website through the WebSearch plugin's FetchWebPage tool — failed like this:

fail: Polly[3]
  Execution attempt. Source: '-standard//Standard-Retry', Result: 'Name or service not known
  (www.boss-software.ch:443)', Handled: 'True', Attempt: '3'
  System.Net.Http.HttpRequestException: Name or service not known (www.boss-software.ch:443)
   ---> System.Net.Sockets.SocketException (0xFFFDFFFF): Name or service not known

Attempt: '3' is the whole story: the standard predicate handles every HttpRequestException, so each URL was tried three times.

Whose defect this is — measured, not assumed

The issue offered three environmental explanations (cluster DNS, an egress NetworkPolicy, a pod with no route). All three are ruled out from outside the cluster. Resolved from a laptop on the public internet, 2026-09-17:

name answer
www.boss-software.ch NXDOMAIN
www.bosssw.ch NXDOMAIN
boss-software.ch NXDOMAIN
bosssw.ch NXDOMAIN

Neither apex domain exists, so nothing inside the cluster is broken and "confirm whether those hostnames resolve from inside the cluster" never needed asking. Their half is a URL in somebody's data that points at a hostname that does not exist; no code change fixes that, and neither hostname appears anywhere in MeshWeaver, MeshWeaver.Plugins or Memex, so nothing we ship put it there.

Our half, and why it is worth fixing

Three attempts, each logged at Error under category Polly — and Error lines are what the fleet's log watcher folds into a LogIncident and a GitHub ticket. So a bad URL in customer data manufactured a platform defect report. The incident pipeline could not tell "our software is broken" from "somebody typed a hostname that does not exist", and the second is not ours to fix.

Memex.Portal.ServiceDefaults therefore excludes a name-resolution failure from the default pipeline's retry predicate and from its circuit breaker:

Excluding it from ShouldHandle also drops the log level: Polly logs an unhandled outcome far below Error, so the incident stops being manufactured at its source rather than being filtered downstream.

🚨 Exactly one socket error, and the control matters more than the case

SocketError.HostNotFound only.

SocketError.TryAgain (EAI_AGAIN) is a nameserver that failed to answer — genuinely transient, and excluding it would turn a DNS hiccup into a hard failure, which is the opposite defect and a worse one. ConnectionRefused is a host that exists and is not listening: about the service. ConnectionReset, TimedOut and every TLS failure other than a rejected certificate (below) are about the network or the peer. All of them keep being retried exactly as before.

ServiceDefaults.NameDoesNotResolve walks the inner-exception chain, because HttpClient wraps the resolver's SocketException in an HttpRequestException and a handler pipeline can wrap that again. It is pure, and NonexistentHostIsNotRetriedTest asserts it directly — a predicate that can only be exercised through a real failed connection is a predicate nobody checks.

A rejected certificate is the second deterministic failure

#5910: an agent fetch of a site whose certificate had expired (NotTimeValid) was attempted three times. No retry makes an expired or untrusted certificate valid, and one site's certificate says nothing about any other host the client calls, so it is excluded from the retry and the breaker on the same two grounds as a dead name.

🚨 The signal is raised where the verdict is made, never parsed afterwards. A bare AuthenticationException is not it: SslStream raises other handshake failures in exactly that shape — the peer closing the transport stream mid-handshake is one — and those are transient. A first attempt keyed on "an AuthenticationException with no inner exception" was withdrawn for that reason. Instead the defaults install a certificate validation callback on every primary handler that has none of its own. It keeps the runtime's verdict (accept exactly when SslPolicyErrors is None) and reports a rejection by throwing RemoteCertificateRejectedException (in MeshWeaver.Mesh.Contract), which reaches the caller as the inner exception of the HttpRequestException and carries the policy errors and the chain status. ServiceDefaults.IsDeterministicTransportFailure is the one predicate both the retry and the breaker read.

A client that installs its own validation callback or its own primary handler keeps it, and gets no marker — its rejections are retried as before. That holds whether the client is registered before or after the defaults: the defaults' action runs on the handler the factory built for them, and a client's own primary-handler factory replaces that handler afterwards, so the marker never reaches the client's handler (pinned in both orders). RejectedCertificateIsNotRetriedTest runs the real defaults against a loopback TLS listener: an expired certificate is contacted once and arrives typed, and the control — a server that drops the handshake — is still retried.

The half this does not reach

The caller is still not told in words it can act on. FetchWebPageCore already draws the right line for an HTTP status — a 404 is reported to the model as an answer rather than raised as a fault — but a dead hostname still arrives as a raw Name or service not known, and the person who owns the data that holds the URL learns nothing at all. That is a separate change in MeshWeaver.AI.WebSearch, and the principle it should follow is the one already written at that call site: a fact about the URL is an answer, not a malfunction.