buzz-acp: the DNS classifier catches 1 of the 3 transient Windows resolver codes, so a name resolution blip consumes the reconnect backoff ladder
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
At 02:51:33Z this morning seven of my managed agents lost the relay in the same second and tried to reconnect in the same second. Six of them called it a DNS failure. One of them did not.
Same box, same relay hostname, same outage, same second. That is a natural experiment I did not have to build, so here it is.
## What the logs say
Windows 11, Buzz Desktop, seven managed agents against one hosted relay. All times UTC, 2026-09-09. Relay hostname redacted.
Six agents took this path:
```
02:51:33.543 WARN buzz_acp::relay: autonomous reconnect DNS failure (1/10), flat retry in 2.0s: WebSocket error: IO error: No such host is known. (os error 11001)
02:51:35.264 INFO buzz_acp::relay: autonomous reconnect attempt 1/5 to wss://
02:51:36.165 INFO buzz_acp::relay: autonomous reconnect succeeded (attempt 1)
```
The seventh took this one:
```
02:51:33.612 WARN buzz_acp::relay: autonomous reconnect attempt 1 failed: WebSocket error: IO error: The requested name is valid, but no data of the requested type was found. (os error 11004)
02:51:33.612 INFO buzz_acp::relay: retrying autonomous reconnect in 0.9s
02:51:34.484 INFO buzz_acp::relay: autonomous reconnect attempt 2/5 to wss://
02:51:35.500 INFO buzz_acp::relay: autonomous reconnect succeeded (attempt 2)
```
Both of those are `getaddrinfo` failing. 11001 is `WSAHOST_NOT_FOUND`, 11004 is `WSANO_DATA`. One consumed a backoff ladder rung and the other did not.
Straight about the blast radius on this particular incident, there was none. Everybody was back inside 3 seconds and the misclassified agent actually recovered fastest of the seven. I am filing this as the latent defect it is. What the logs buy me is proof that the misclassification is reachable in production rather than something I found by squinting at code.
## The mechanism
Everything below is read at `c045321a7`.
`crates/buzz-acp/src/relay.rs:3536`:
```rust
pub(crate) fn is_dns_error(err: &RelayError) -> bool {
let msg = err.to_string();
msg.contains("nodename nor servname")
|| msg.contains("Name or service not known")
|| msg.contains("No such host")
|| msg.contains("failed to lookup address")
}
```
Four substrings. The doc comment on it claims to cover "common BSD/Windows variants". It covers exactly one Windows variant, and Windows has four resolver failures. Rendered through the same `FormatMessage` path Rust uses to build an `io::Error` message:
| code | name | message | DNS today |
|---|---|---|---|
| 11001 | `WSAHOST_NOT_FOUND` | No such host is known | yes |
| 11002 | `WSATRY_AGAIN` | This is usually a temporary error during hostname resolution and means that the local server did not receive a response from an authoritative server | no |
| 11003 | `WSANO_RECOVERY` | A non-recoverable error occurred during a database lookup | no |
| 11004 | `WSANO_DATA` | The requested name is valid, but no data of the requested type was found | no |
11003 belongs on the outside. The name says non-recoverable and a flat retry is the wrong answer for it. 11004 is the one my logs caught. 11002 is the one that actually bothers me, because that is Windows saying "the name server did not answer, try again later". It is the only one of the four with the word temporary in it, it is the exact case the flat retry was written for, and it is on the wrong side of this branch.
## Why it costs something
Two callers, and they lose differently.
`try_autonomous_reconnect` (`relay.rs:3144`) is bounded at 5 ladder attempts, with up to 10 flat 2s DNS retries that consume no rung. Classified right, you get roughly 20 seconds of brownout tolerance before the ladder is touched at all. Classified wrong, the ladder is the whole budget, so sleeps of 1, 2, 4 and 8 seconds and then `ReconnectOutcome::Failed`.
That one is survivable and I would rather say so than oversell this. `Failed` falls through to `wait_for_reconnect` (`relay.rs:1788`, `1978`, `2047`, `2086`, `2119`), which loops forever. Nothing goes permanently silent.
The cost that made me write this up is in `wait_for_reconnect`. `relay.rs:3301`:
```rust
state.backoff_step = attempt;
```
That persists the ladder position across reconnects, deliberately, so a flapping link keeps its elevated place. The DNS arm at `relay.rs:3287` hits a `continue` before it ever reaches that line, so a correctly classified DNS failure never elevates the ladder. A misclassified one does, and it stays elevated until 60 seconds of stable connection reset it at `relay.rs:2154`. A name resolution blip gets written down as link instability, and the next drop pays for it. The ladder runs 1, 2, 4, 8, 16, 32 and then sits at 60.
So the defect is small, and it is small in the direction of quietly making recovery worse on exactly the platform where the classifier is thinnest.
## What I would do
Match the code, not the message.
`FormatMessage` renders in the machine's display language. Every string in that function is English, so on a Windows install running any other display language the classifier degrades to matching nothing at all, including the 11001 that works today. I have not put a localized box in front of this and I am flagging that as reasoning rather than measurement. It is still reason enough to stop pattern matching prose the OS wrote.
A pure function of the code, compiled on every platform so it can be asserted on every platform:
```rust
pub(crate) fn is_windows_dns_errno(code: i32) -> bool {
matches!(code, 11001 | 11002 | 11004)
}
```
Then pull `raw_os_error()` off the `RelayError::WebSocket(tungstenite::Error::Io(_))` shape, which is what `connect_async` actually hands back on a failed resolution, and keep the existing string list as the fallback for errors that arrive already flattened into `RelayError::Http`.
Keeping that helper free of `cfg(windows)` is the point. A `cfg` gated test does not run on Linux CI, and I have made that exact mistake in this repo before.
PR to follow.
## Not a duplicate of
- **#5121, #4873, #6494.** All three are genuine DNS resolution failures and all three quote `failed to lookup address information: nodename nor servname provided, or not known`, a string this function already matches correctly. They are about resolution failing. This is about what the harness does with a resolution failure it fails to recognise.
- **#4477.** A transient outage paralysing an agent for 12 minutes, but that one lives in `crates/buzz-agent/src/llm.rs` and is about per attempt timeouts against the LLM gateway. Different crate, different retry loop, different error type.
- **#4908, #3975, #5056, #5030.** Relay connection and recovery reports. None of them touches error classification and none is specific to the Windows resolver.
- Searched issues and open PRs for `is_dns_error`, `11004`, `WSANO_DATA`, `os error 11001`, DNS, resolver, reconnect backoff and DNS brownout. Nothing covers this, and no open PR touches this function.
Contributor guide
Assessment
This issue has not been assessed yet.