I don't have a way to make .waiting become .failed, and I don't know why Ventura changed it either. Quinn's advice to file a bug is still the right thing. But there is a workaround that makes this behave, and it's small, so in case anyone is still stuck here:
First, the honest caveat — your error is not my error. ECONNABORTED after a completed handshake and an HTTP 403 is a different event from what I hit, which was ECONNREFUSED on a connection that never established. I have not tested yours. What I think is the same is the shape: .waiting arrives carrying a reason that will never clear, and the framework offers you nothing to tell that apart from the reasons that will.
The documentation for the case still says this today:
The connection is waiting for a network path change.
Connections that are waiting will indicate the reason that the connection couldn’t be established in the associated error. These errors are not fatal.
https://developer.apple.com/documentation/network/nwconnection/state-swift.enum/waiting(_:)
That's accurate for most of what lands in .waiting. It's what you get before a Local Network permission prompt has been answered, and while Wi-Fi and DNS settle after a device wakes, and in both of those the connection genuinely does reach .ready on its own. It is not accurate for an error where a host has already answered you.
So the state is doing two jobs — "hold on, this may come good" and "the host said no" — and only the associated error separates them. The fix is to do that triage yourself:
func isTerminal(_ error: NWError) -> Bool {
guard case .posix(let code) = error else { return false }
switch code {
// A host answered. Nothing is pending.
case .ECONNREFUSED, .ECONNRESET:
return true
// No answer, but the path is there: the host itself is not
// responding, and no path change will fix that.
case .EHOSTDOWN:
return true
// Deliberately absent: .EHOSTUNREACH, .ENETUNREACH, .ENETDOWN.
// Those describe a path that isn't there yet, which is the case
// the patient behavior exists for.
default:
return false
}
}
case .waiting(let error):
log("waiting: \(error)")
guard isTerminal(error) else { return } // may still come good
guard !resumed else { return }
resumed = true
connection.cancel()
continuation.resume(throwing: error)
Whether ECONNABORTED belongs in that list is your call rather than mine, and I'd lean yes for your case on the grounds that the server demonstrably answered — but I haven't tested it, so test it against a path you expect to stay patient before you ship it.
One more caveat, since the April reply is on iOS: I've only tested this on tvOS, which has no Local Network prompt. On iOS, check connection.currentPath?.unsatisfiedReason for .localNetworkDenied before treating any .waiting reason as final, so you don't cancel a connection that is only waiting for the user to tap Allow.
Two things that bit me implementing this, both worth knowing before you write it:
.waiting fires repeatedly while NWConnection retries on its own, so if you are bridging into async with withCheckedThrowingContinuation, a bare continuation.resume in that case traps on the second pass with a continuation misuse. Hence the resumed flag above.
And throwing out of the await does not stop the connection. Without the cancel() it keeps retrying on its queue behind you.
On whether this still reproduces: yes, as of October 2026. I hit it on tvOS 27, on an Apple TV 4K, with ECONNREFUSED (POSIX 61) against a device whose camera port was closed. The refusal arrived in the first log line and my own 8-second connect timeout fired eight seconds later, every attempt. With the triage above, measured on the tvOS 27 simulator against a refused port, it's 8.1s to 0.0s per attempt.
Worth saying what the real cost was, because it isn't obvious from an error log: nothing crashed and nothing reported a failure, so the symptom was latency, plus a timeout message that blamed the user's network. The user went and checked his Wi-Fi, which was fine.
Longer write-up, with the timeout message that sent him there and a threshold I then "fixed" and had to revert: https://mcmizzle.com/blog/nwconnection-connection-refused-waiting/