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.

A remote site giving up is not a platform incident

The two carve-outs above stop retrying what no retry can fix. They leave a third shape untouched on purpose: a TLS handshake reset, a refused connect, an EAGAIN at connect. Those ARE transient-shaped, so the pipeline keeps retrying them, and when the last retry fails Polly reports the event at Error.

fail: Polly[3]
  Execution attempt. Source: '-standard/(unnamed)/Standard-Retry', Result: 'The SSL connection could
  not be established, see inner exception.', Handled: 'True', Attempt: '3'

The fleet's log watcher folds every Error line into a LogIncident. For a client that fetches whatever URL an agent or a document hands it, every remote site's bad day became a ticket against the platform: the one log site of #7085 fanned out into 46 fingerprints.

The rule (policy arbitrary-url-fetch-failure-is-warning): a named client that fetches arbitrary, user-supplied URLs logs the pipeline's give-up at Warning. Every other client keeps Error, because a platform or service client giving up is an incident.

A client declares itself; the host keeps no list. The module that registers the client says what it fetches:

services.AddHttpClient(WebSearchPlugin.HttpClientName);
services.DeclareArbitraryUrlFetcher(WebSearchPlugin.HttpClientName);

DeclareArbitraryUrlFetcher (in MeshWeaver.Mesh.Contract) registers an IArbitraryUrlFetcher. The host's resilience defaults install a Polly TelemetryOptions.SeverityProvider that reads the sending client's name off the request (the stamp ClientNameOf already reads, #4528) and lowers an Error to Warning only when a declaration names that client. Three details:

The level is a cost decision, not a cosmetic one. An Error line costs a person: an incident, triage and a ticket. A Warning still ships to Loki and still counts in a rate, so a site that fails constantly stays visible, but it no longer claims that the platform is broken. The caller still reports the failure in words the model can act on (the per-URL answers in MeshWeaver.AI.WebSearch).

AnArbitraryUrlFetcherGivingUpIsAWarningTest (core, MeshWeaver.Hosting.Test) builds the real defaults and reads the level off the Polly line. A declared client's final attempt is Warning. The negative control is an undeclared platform client and the unnamed client failing the same way: both are still Error.

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.