kernel/reach.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Where a call to a provider stops, stage by stage.
7//! The part `crates/kernel/spec/Reach.lean` specifies this module.
8//!
9//! **A refusal reading `error sending request for url (...): operation
10//! timed out` tells a person nothing they can act on.** Four things go
11//! wrong before a provider ever sees a request, and each has a different
12//! next step: the name does not resolve (check the spelling, or the
13//! proxy), the connection is refused or never answers (a firewall, a
14//! wrong port, a proxy that is not running), the handshake fails (a
15//! hostname that is not a legal DNS name, a certificate this machine
16//! does not trust), or the provider answers with a status (401 is a key,
17//! 404 is a path). This is the vocabulary of that reading; `gateway`
18//! takes it, because the reading needs a socket and this crate has none.
19
20use serde::{Deserialize, Serialize};
21
22/// How the name resolved.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26pub enum Named {
27 /// How many addresses came back. A name that resolves to nothing is
28 /// `NotFound`, never `Resolved(0)`.
29 Resolved(u32),
30 /// The resolver knows the name does not exist.
31 NotFound,
32 /// The resolver itself failed, and said this.
33 Refused(String),
34 /// A proxy resolves the name, so this machine did not.
35 ProxiedAway,
36}
37
38/// Whether a socket opened to the first address the name gave.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub enum Connected {
43 Open,
44 /// Something answered and said no: a closed port, or a firewall
45 /// that refuses rather than drops.
46 Refused,
47 /// Nothing answered inside the deadline. The most common shape of a
48 /// blocked network, and the one that looks like a slow provider.
49 Silent,
50 Failed(String),
51 /// Not attempted: the name did not resolve, or a proxy connects.
52 Skipped,
53}
54
55/// What the request itself did, once the transport was somebody else's
56/// problem. Carries the handshake, because a TLS failure surfaces as a
57/// request that never got a status.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61pub enum Answered {
62 /// The provider answered. The status is the whole finding: 200 is
63 /// reachable, 401 is a key, 404 is a base URL with a path on it.
64 Status(u16),
65 /// The name is not one TLS can put in a handshake — an underscore,
66 /// a trailing dot, a label that is not ASCII.
67 NameNotUsable(String),
68 /// The handshake failed for any other reason, certificates
69 /// included.
70 HandshakeFailed(String),
71 /// The request never reached a handshake.
72 Unreachable(String),
73}
74
75/// What the city believes it will send this request through.
76///
77/// Four of the five readings mean "no proxy", and they are kept apart
78/// because the next step differs for each: nothing names one, the
79/// person's own `NO_PROXY` excludes this host, the city's rule takes an
80/// address on this machine off the proxy, or the person settled
81/// [`Proxying::Never`] for this endpoint. A person whose call fails can
82/// act on the reason and cannot act on "direct".
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case", tag = "state", content = "detail")]
85#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
86pub enum Through {
87 /// Straight out, as far as this machine's environment says.
88 Direct,
89 /// An environment variable named it, and this is the variable.
90 Environment(String),
91 /// This host is on `NO_PROXY`, so the variables do not apply to it.
92 Excluded,
93 /// The address is on the machine the city runs on, and
94 /// [`Proxying::ExceptLocal`] keeps such a call off the proxy.
95 LocalAddress,
96 /// The person settled [`Proxying::Never`] for this endpoint, so no
97 /// proxy applies whatever this machine is configured with.
98 Disabled,
99}
100
101/// Which of this endpoint's calls go through the machine's proxy.
102///
103/// A proxy that intercepts loopback answers 502 for a local inference
104/// server, so the city takes an address on its own machine off the
105/// proxy by default — and that default is a rule about the common
106/// machine, not a fact about every machine. Two other settings are real
107/// needs rather than symmetry: a person whose provider sits behind a
108/// relay on loopback that their organisation requires them to audit
109/// needs [`Always`](Proxying::Always), and a person on a virtual network
110/// adapter, whose routes already carry every packet, needs
111/// [`Never`](Proxying::Never) so a stale proxy variable cannot break a
112/// call the machine would otherwise make.
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116pub enum Proxying {
117 /// The machine's proxy applies, except to an address on this
118 /// machine. What a person who has not thought about proxies wants.
119 #[default]
120 ExceptLocal,
121 /// The machine's proxy applies to every call, loopback included.
122 Always,
123 /// No proxy applies, whatever this machine is configured with.
124 Never,
125}
126
127/// One staged reading of one host.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130pub struct Reach {
131 pub host: String,
132 pub named: Named,
133 pub connected: Connected,
134 pub answered: Answered,
135 pub through: Through,
136 /// Stamped by the caller, which is the one place a clock is read.
137 pub elapsed_ms: u64,
138}
139
140impl Reach {
141 /// Whether the provider answered at all, whatever it answered.
142 pub fn answered_at_all(&self) -> bool {
143 matches!(self.answered, Answered::Status(_))
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 /// Any status is an answer, a refusing one included; every other
152 /// last stage is a provider that did not answer at all.
153 #[test]
154 fn any_status_is_an_answer_and_nothing_else_is() {
155 let reading = |answered: Answered| Reach {
156 host: "api.example.test".to_owned(),
157 named: Named::Resolved(1),
158 connected: Connected::Open,
159 answered,
160 through: Through::Direct,
161 elapsed_ms: 40,
162 };
163 let verdicts: Vec<bool> = [
164 Answered::Status(200),
165 Answered::Status(401),
166 Answered::NameNotUsable("a_b".to_owned()),
167 Answered::HandshakeFailed("certificate".to_owned()),
168 Answered::Unreachable("timed out".to_owned()),
169 ]
170 .into_iter()
171 .map(|answered| reading(answered).answered_at_all())
172 .collect();
173 assert_eq!(verdicts, [true, true, false, false, false]);
174 }
175}