Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,47 @@ tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }

[lints.clippy]
all = { level = "warn", priority = -1 }
# All lint groups except restriction and cargo
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }

# Explicit panics
expect_used = "forbid"
panic = "forbid"
todo = "forbid"
unimplemented = "forbid"
unreachable = "forbid"
unwrap_used = "forbid"

# Implicit panics (and wrapping)
arithmetic_side_effects = "deny" # Desired for atomic counters meant to wrap
indexing_slicing = "forbid"
string_slice = "forbid"

# Ignoring `#[must_use]`
let_underscore_must_use = "forbid"
unused_result_ok = "forbid"

# Unsafe discipline (only relevant when `unsafe_code` is not set to "forbid")
multiple_unsafe_ops_per_block = "forbid"
undocumented_unsafe_blocks = "forbid"

# Lint suppression
allow_attributes = "forbid"
allow_attributes_without_reason = "forbid"
expect_used = "forbid"
unwrap_used = "forbid"

# Binding shadowing
shadow_reuse = "forbid"
shadow_same = "forbid"
shadow_unrelated = "forbid"

# Redundant constructs
redundant_test_prefix = "deny"
unnecessary_self_imports = "warn"

[lints.rust]
unsafe_code = "forbid"

closure_returning_async_block = "warn"
redundant_imports = "warn"
unused_qualifications = "warn"
64 changes: 33 additions & 31 deletions src/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ impl HealthChecker {
Self { backends, path, interval }
}

pub async fn run(self) {
pub async fn run(self) -> ! {
loop {
for backend in &self.backends {
let is_healthy = self.check_backend(backend.addr()).await;
Expand All @@ -42,14 +42,16 @@ impl HealthChecker {
}

async fn check_backend(&self, addr: SocketAddr) -> bool {
let check_fut = async move {
let check_fut = async {
let Ok(stream) = TcpStream::connect(addr).await else { return false };
let Ok((mut sender, conn)) = client_http1::handshake(TokioIo::new(stream)).await else {
return false;
};

tokio::spawn(async move {
let _ = conn.await;
tokio::spawn(async {
if let Err(e) = conn.await {
warn!("{e}");
}
});

let Ok(req) = Request::builder()
Expand Down Expand Up @@ -78,10 +80,7 @@ mod tests {
use crate::test_utils::tokio_test;
use anyhow::Result;
use http_body_util::Full;
use hyper::{
Response, StatusCode, body::Bytes, server::conn::http1 as server_http1, service::service_fn,
};
use hyper_util::rt::TokioIo;
use hyper::{Response, StatusCode, server::conn::http1 as server_http1, service::service_fn};
use std::convert::Infallible;
use tokio::net::TcpListener;

Expand All @@ -95,39 +94,42 @@ mod tests {
let Ok((stream, _)) = listener.accept().await else { break };

tokio::spawn(async move {
let _ = server_http1::Builder::new()
.serve_connection(
TokioIo::new(stream),
service_fn(|_| async move {
let resp = Response::builder()
.status(status)
.body(Full::new(Bytes::new()))
.unwrap_or_else(|_| Response::new(Full::new(Bytes::new())));
Ok::<_, Infallible>(resp)
}),
)
.await;
assert!(
server_http1::Builder::new()
.serve_connection(
TokioIo::new(stream),
service_fn(async |_| {
let resp = Response::builder()
.status(status)
.body(Full::new(Bytes::new()))
.unwrap_or_else(|_| Response::new(Full::new(Bytes::new())));
Ok::<_, Infallible>(resp)
}),
)
.await
.is_ok()
);
});
}
});

Ok(addr)
}

fn make_checker(addr: SocketAddr, starts_healthy: bool) -> (HealthChecker, Vec<Arc<Backend>>) {
let backends = vec![Arc::new(if starts_healthy {
fn make_checker(addr: SocketAddr, starts_healthy: bool) -> (HealthChecker, Arc<Backend>) {
let backend = Arc::new(if starts_healthy {
Backend::healthy(addr)
} else {
Backend::unhealthy(addr)
})];
});

let checker = HealthChecker::new(
backends.clone(),
vec![Arc::clone(&backend)],
String::from("/"),
Duration::from_millis(20),
);

(checker, backends)
(checker, backend)
}

#[test]
Expand All @@ -137,11 +139,11 @@ mod tests {
let unreachable_addr = listener.local_addr()?;
drop(listener);

let (checker, backends) = make_checker(unreachable_addr, true);
let (checker, backend) = make_checker(unreachable_addr, true);
tokio::spawn(checker.run());
tokio::time::sleep(Duration::from_millis(200)).await;

assert!(!backends[0].is_healthy());
assert!(!backend.is_healthy());
Ok(())
})
}
Expand All @@ -150,12 +152,12 @@ mod tests {
fn marks_backend_healthy_when_responding_with_2xx() -> Result<()> {
tokio_test(async {
let backend_addr = spawn_test_backend(StatusCode::OK).await?;
let (checker, backends) = make_checker(backend_addr, false);
let (checker, backend) = make_checker(backend_addr, false);

tokio::spawn(checker.run());
tokio::time::sleep(Duration::from_millis(200)).await;

assert!(backends[0].is_healthy());
assert!(backend.is_healthy());
Ok(())
})
}
Expand All @@ -164,12 +166,12 @@ mod tests {
fn marks_backend_unhealthy_when_responding_with_non_2xx() -> Result<()> {
tokio_test(async {
let backend_addr = spawn_test_backend(StatusCode::INTERNAL_SERVER_ERROR).await?;
let (checker, backends) = make_checker(backend_addr, true);
let (checker, backend) = make_checker(backend_addr, true);

tokio::spawn(checker.run());
tokio::time::sleep(Duration::from_millis(200)).await;

assert!(!backends[0].is_healthy());
assert!(!backend.is_healthy());
Ok(())
})
}
Expand Down
16 changes: 8 additions & 8 deletions src/load_balancer/least_connections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ impl LoadBalancer for LeastConnections {

let &idx = tied.choose(&mut rand::rng())?;

Some(Arc::clone(&self.backends[idx]).acquire())
Some(Arc::clone(self.backends.get(idx).as_ref()?).acquire())
}
}

Expand All @@ -54,7 +54,7 @@ mod tests {
#[test]
fn single_backend_always_returns_same_addr() -> Result<()> {
let addr = localhost_addr(8001);
let lc = LeastConnections::init(make_healthy_backends(&[8001]))?;
let lc = LeastConnections::init(make_healthy_backends([8001]).to_vec())?;

for _ in 0..3 {
assert_eq!(lc.next().context("expected backend")?.addr(), addr);
Expand All @@ -65,7 +65,7 @@ mod tests {

#[test]
fn empty_backends_returns_err() {
assert!(LeastConnections::init(make_healthy_backends(&[])).is_err());
assert!(LeastConnections::init(make_healthy_backends([]).to_vec()).is_err());
}

#[test]
Expand Down Expand Up @@ -98,8 +98,8 @@ mod tests {

#[test]
fn routes_to_backend_with_fewest_connections() -> Result<()> {
let backends = make_healthy_backends(&[8000, 8001]);
let lc = LeastConnections::init(backends.clone())?;
let backends = make_healthy_backends([8000, 8001]);
let lc = LeastConnections::init(backends.to_vec())?;

// Give 8000 two active connections so 8001 always wins
let _c1 = Arc::clone(&backends[0]).acquire();
Expand All @@ -119,8 +119,8 @@ mod tests {

#[test]
fn increments_count_on_next_and_decrements_on_drop() -> Result<()> {
let backends = make_healthy_backends(&[8001]);
let lc = LeastConnections::init(backends.clone())?;
let backends = make_healthy_backends([8001]);
let lc = LeastConnections::init(backends.to_vec())?;

assert_eq!(backends[0].num_connections(), 0);

Expand All @@ -136,7 +136,7 @@ mod tests {
#[test]
fn chooses_valid_backend_when_tied() -> Result<()> {
let (a, b) = (localhost_addr(8001), localhost_addr(8002));
let lc = LeastConnections::init(make_healthy_backends(&[8001, 8002]))?;
let lc = LeastConnections::init(make_healthy_backends([8001, 8002]).to_vec())?;

for _ in 0..10 {
let addr = lc.next().context("expected backend")?.addr();
Expand Down
14 changes: 9 additions & 5 deletions src/load_balancer/round_robin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,23 @@ impl RoundRobin {
}

impl LoadBalancer for RoundRobin {
#[expect(
clippy::arithmetic_side_effects,
reason = "Wrapping desired for round robin counter"
)]
fn next(&self) -> Option<BackendGuard> {
let n = self.backends.len();
let start = self.counter.fetch_add(1, Ordering::Relaxed) % n;
let mut i = start;

while !self.backends[i].is_healthy() {
while !self.backends.get(i)?.is_healthy() {
i = self.counter.fetch_add(1, Ordering::Relaxed) % n;
if i == start {
return None;
}
}

Some(Arc::clone(&self.backends[i]).acquire())
Some(Arc::clone(self.backends.get(i).as_ref()?).acquire())
}
}

Expand All @@ -55,7 +59,7 @@ mod tests {
localhost_addr(8003),
);

let rr = RoundRobin::init(make_healthy_backends(&[8001, 8002, 8003]))?;
let rr = RoundRobin::init(make_healthy_backends([8001, 8002, 8003]).to_vec())?;

assert_eq!(rr.next().map(|g| g.addr()), Some(a1));
assert_eq!(rr.next().map(|g| g.addr()), Some(a2));
Expand All @@ -68,7 +72,7 @@ mod tests {
#[test]
fn single_backend_always_returns_same_addr() -> Result<()> {
let addr = localhost_addr(8001);
let rr = RoundRobin::init(make_healthy_backends(&[8001]))?;
let rr = RoundRobin::init(make_healthy_backends([8001]).to_vec())?;

assert_eq!(rr.next().map(|g| g.addr()), Some(addr));
assert_eq!(rr.next().map(|g| g.addr()), Some(addr));
Expand All @@ -79,7 +83,7 @@ mod tests {

#[test]
fn empty_backends_returns_err() {
assert!(RoundRobin::init(make_healthy_backends(&[])).is_err());
assert!(RoundRobin::init(make_healthy_backends([]).to_vec()).is_err());
}

#[test]
Expand Down
11 changes: 8 additions & 3 deletions src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ use tokio::net::TcpStream;
use tracing::{error, warn};

/// A generic `Response` containing a boxed `Body` (may be `Incoming`, `Empty<Bytes>`, etc.).
#[expect(
unused_qualifications,
reason = "Absolute paths for clarity in type alias"
)]
type BoxBodyResp = hyper::Response<http_body_util::combinators::BoxBody<Bytes, hyper::Error>>;

const X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for");
Expand Down Expand Up @@ -129,9 +133,10 @@ fn prepare_response<B>(mut resp: Response<B>) -> Response<B> {
/// Removes the standard hop-by-hop headers and any custom headers named in the Connection header.
fn strip_hop_by_hop_headers(headers: &mut HeaderMap) {
let extra = get_str_val(headers, &CONNECTION)
.map(|s| {
s.split(',')
.map(|s| s.trim().to_lowercase())
.map(|connection_val| {
connection_val
.split(',')
.map(|header_name| header_name.trim().to_lowercase())
.collect::<Vec<_>>()
})
.unwrap_or_default();
Expand Down
Loading