use crate::common;
use crate::common::{
COLLAPSED_STATUS, Collapsed, Established, Journal, Trail, assert_classification,
assert_collapsed, assert_established, collapsing_mapper, drain, mark, marking, only, take,
};
use camber::http::{
Next, RejectionKind, RejectionProtocol, Request, Response, Router, StreamResponse,
};
use camber::{RuntimeError, runtime};
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, mpsc};
use std::time::{Duration, Instant};
const ORIGIN: &str = "acceptance_proxy";
const PROXY_WIRE_TIMEOUT: Duration = Duration::from_secs(45);
const BUFFERED: &str = "/buffered";
const STREAMING: &str = "/streaming";
const FAULTING: &str = "/faulting";
const UNHEALTHY: &str = "/unhealthy";
const UNHEALTHY_STREAM: &str = "/unhealthy-stream";
fn send(addr: SocketAddr, path: &str) -> TcpStream {
let mut peer = common::connect(addr).expect("the proxy peer could not connect");
common::write_request(&mut peer, "GET", path, &[], b"").expect("the request could not be sent");
peer
}
fn get(addr: SocketAddr, path: &str) -> common::HttpResponse {
let mut peer = send(addr, path);
common::read_http_response(&mut peer, Some(Instant::now() + PROXY_WIRE_TIMEOUT))
.expect("no answer to the proxied request")
}
fn get_until_closed(addr: SocketAddr, path: &str) -> String {
let mut peer = send(addr, path);
common::drain_to_close(&mut peer, PROXY_WIRE_TIMEOUT).expect("the proxied request never ended")
}
fn closed_backend() -> String {
"http://127.0.0.1:1".into()
}
#[derive(Clone, Copy)]
enum UpstreamScript {
Stall,
TruncatedBody,
}
struct ScriptedUpstream {
addr: SocketAddr,
_reservation: TcpListener,
truncate: Option<mpsc::SyncSender<()>>,
finished: mpsc::Receiver<()>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl ScriptedUpstream {
fn backend(&self) -> String {
format!("http://{}", self.addr)
}
fn truncate_body(&mut self) {
self.truncate
.take()
.expect("the truncated-body script has no release control")
.send(())
.expect("the scripted upstream stopped before body truncation");
}
}
impl Drop for ScriptedUpstream {
fn drop(&mut self) {
drop(self.truncate.take());
let served = self.finished.recv_timeout(PROXY_WIRE_TIMEOUT).is_ok();
if !served {
drop(TcpStream::connect(self.addr));
}
let joined = self
.thread
.take()
.is_some_and(|thread| thread.join().is_ok());
if !std::thread::panicking() {
assert!(
served,
"the scripted upstream never finished its connection"
);
assert!(joined, "the scripted upstream thread did not join");
}
}
}
fn scripted_upstream(script: UpstreamScript) -> ScriptedUpstream {
let reservation = TcpListener::bind("127.0.0.1:0").expect("no ephemeral port for the upstream");
let addr = reservation
.local_addr()
.expect("the upstream has no address");
let accepting = reservation
.try_clone()
.expect("the upstream reservation could not be shared with its thread");
let (truncate, truncate_on) = match script {
UpstreamScript::TruncatedBody => {
let (release, wait) = mpsc::sync_channel(0);
(Some(release), Some(wait))
}
UpstreamScript::Stall => (None, None),
};
let (report, finished) = mpsc::sync_channel(1);
let thread = std::thread::spawn(move || {
match accepting.accept() {
Ok((stream, _)) => serve_scripted(stream, script, truncate_on),
Err(error) => panic!("the scripted upstream could not accept: {error}"),
}
let _ = report.send(());
});
ScriptedUpstream {
addr,
_reservation: reservation,
truncate,
finished,
thread: Some(thread),
}
}
fn serve_scripted(
mut stream: TcpStream,
script: UpstreamScript,
truncate_on: Option<mpsc::Receiver<()>>,
) {
stream
.set_read_timeout(Some(PROXY_WIRE_TIMEOUT))
.expect("the upstream read bound could not be set");
common::read_head(&mut stream, PROXY_WIRE_TIMEOUT)
.expect("the scripted upstream did not receive a complete request head");
match script {
UpstreamScript::TruncatedBody => {
write_truncated_head(
&mut stream,
truncate_on.expect("the truncated-body script has no release signal"),
);
}
UpstreamScript::Stall => hold_until_closed(&mut stream),
}
}
fn write_truncated_head(stream: &mut TcpStream, truncate_on: mpsc::Receiver<()>) {
stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 64\r\n\r\nshort",
)
.expect("the scripted upstream could not write its truncated head");
stream
.flush()
.expect("the scripted upstream could not flush its truncated head");
match truncate_on.recv() {
Ok(()) | Err(_) => {}
}
}
fn hold_until_closed(stream: &mut TcpStream) {
let mut scratch = [0_u8; 1024];
while matches!(stream.read(&mut scratch), Ok(count) if count > 0) {}
}
const GATE_REFUSES: &str = "/streaming/refused";
const GATE_ANSWERS: &str = "/streaming/answered";
const DELIBERATE_STATUS: u16 = 401;
fn ordering_gate(router: &mut Router, trail: &Trail) {
let trail = Arc::clone(trail);
router.use_middleware(move |req: &Request, next: Next| {
let trail = Arc::clone(&trail);
mark(&trail, "gate:enter");
let path: Box<str> = req.path().into();
let short_circuits = &*path == GATE_REFUSES || &*path == GATE_ANSWERS;
let inner = (!short_circuits).then(|| next.call(req));
async move {
let response = match inner {
None if &*path == GATE_REFUSES => {
return Err(RuntimeError::BadRequest("gate refused".into()));
}
None => Response::text(DELIBERATE_STATUS, "gated")?,
Some(inner) => inner.await,
};
mark(&trail, "gate:exit");
Ok(response)
}
});
}
fn proxy_router(journal: &Journal, trail: &Trail, backend: &str) -> Router {
let mut router = Router::new();
ordering_gate(&mut router, trail);
router.proxy(BUFFERED, backend);
router.proxy_stream(STREAMING, backend);
router.proxy_checked(UNHEALTHY, backend, Arc::new(AtomicBool::new(false)));
router.proxy_checked_stream(UNHEALTHY_STREAM, backend, Arc::new(AtomicBool::new(false)));
router.get(FAULTING, |_req: &Request| async {
Err::<Response, RuntimeError>(RuntimeError::Http("the route could not be served".into()))
});
router.rejection_mapper(marking(
trail,
"mapper",
collapsing_mapper(journal, ORIGIN, COLLAPSED_STATUS),
))
}
fn proxy_fixture(backend: &str) -> (Journal, Trail, SocketAddr) {
let journal = Journal::default();
let trail: Trail = Arc::new(Mutex::new(Vec::new()));
let addr = common::spawn_server(proxy_router(&journal, &trail, backend));
(journal, trail, addr)
}
struct MatrixRow {
label: &'static str,
path: &'static str,
route: &'static str,
kind: RejectionKind,
status: u16,
message: &'static str,
protocol: RejectionProtocol,
trail: &'static [&'static str],
}
const MATRIX_ROWS: [MatrixRow; 5] = [
MatrixRow {
label: "buffered proxy cannot reach its upstream",
path: "/buffered/anything",
route: "/buffered/*proxy_path",
kind: RejectionKind::Proxy,
status: 502,
message: "bad gateway",
protocol: RejectionProtocol::Proxy,
trail: &["gate:enter", "mapper", "gate:exit"],
},
MatrixRow {
label: "streaming proxy cannot reach its upstream",
path: "/streaming/anything",
route: "/streaming/*proxy_path",
kind: RejectionKind::Proxy,
status: 502,
message: "bad gateway",
protocol: RejectionProtocol::Proxy,
trail: &["gate:enter", "gate:exit", "mapper"],
},
MatrixRow {
label: "no admissible backend",
path: "/unhealthy/anything",
route: "/unhealthy/*proxy_path",
kind: RejectionKind::Proxy,
status: 503,
message: "service unavailable",
protocol: RejectionProtocol::Proxy,
trail: &["mapper"],
},
MatrixRow {
label: "no admissible backend behind a streaming route",
path: "/unhealthy-stream/anything",
route: "/unhealthy-stream/*proxy_path",
kind: RejectionKind::Proxy,
status: 503,
message: "service unavailable",
protocol: RejectionProtocol::Proxy,
trail: &["mapper"],
},
MatrixRow {
label: "the service itself could not answer",
path: FAULTING,
route: FAULTING,
kind: RejectionKind::InternalService,
status: 500,
message: "internal server error",
protocol: RejectionProtocol::OrdinaryHttp,
trail: &["gate:enter", "mapper", "gate:exit"],
},
];
const _: () = assert!(!MATRIX_ROWS.is_empty());
#[test]
fn specialized_rejection_matrix_keeps_kind_context_and_stage_order() {
common::test_runtime()
.run(|| {
let (journal, trail, addr) = proxy_fixture(&closed_backend());
for row in &MATRIX_ROWS {
let answer = get(addr, row.path);
let label = row.label;
let seen = assert_collapsed(
&journal,
&answer,
label,
&Collapsed {
kind: row.kind,
status: row.status,
message: row.message,
},
);
assert_established(
&seen,
&Established {
method: "GET",
raw_path: row.path,
route: Some(row.route),
protocol: Some(row.protocol),
content_type: None,
},
label,
);
assert_eq!(take(&trail).as_ref(), row.trail, "{label}: stage order");
}
let kinds: std::collections::BTreeSet<RejectionKind> =
MATRIX_ROWS.iter().map(|row| row.kind).collect();
assert_eq!(
kinds.len(),
2,
"the proxy and internal-service categories stay distinct at one status"
);
runtime::request_shutdown();
})
.expect("the fixture runtime ran to completion");
}
struct OrderRow {
label: &'static str,
path: &'static str,
status: u16,
mapped: usize,
trail: &'static [&'static str],
}
const ORDER_ROWS: [OrderRow; 3] = [
OrderRow {
label: "the gate refuses before the upstream is dialled",
path: GATE_REFUSES,
status: COLLAPSED_STATUS,
mapped: 1,
trail: &["gate:enter", "mapper"],
},
OrderRow {
label: "the upstream fails after the gate completed",
path: "/streaming/anything",
status: COLLAPSED_STATUS,
mapped: 1,
trail: &["gate:enter", "gate:exit", "mapper"],
},
OrderRow {
label: "the gate answers deliberately",
path: GATE_ANSWERS,
status: DELIBERATE_STATUS,
mapped: 0,
trail: &["gate:enter", "gate:exit"],
},
];
const _: () = assert!(!ORDER_ROWS.is_empty());
#[test]
fn specialized_gate_rejections_are_not_replayed_around_a_later_failure() {
common::test_runtime()
.run(|| {
let (journal, trail, addr) = proxy_fixture(&closed_backend());
for row in &ORDER_ROWS {
let answer = get(addr, row.path);
let label = row.label;
assert_eq!(answer.status, row.status, "{label}: wire status");
assert_eq!(take(&trail).as_ref(), row.trail, "{label}: stage order");
assert_eq!(
drain(&journal).len(),
row.mapped,
"{label}: mapper invocations"
);
}
runtime::request_shutdown();
})
.expect("the fixture runtime ran to completion");
}
const TRUNCATED_STATUS: u16 = 200;
#[test]
fn proxy_preheader_failures_map_but_committed_stream_failure_does_not() {
common::test_runtime()
.run(|| {
let mut upstream = scripted_upstream(UpstreamScript::TruncatedBody);
let (journal, trail, addr) = proxy_fixture(&upstream.backend());
let refused = get(addr, "/unhealthy/data");
assert_collapsed(
&journal,
&refused,
"pre-header",
&Collapsed {
kind: RejectionKind::Proxy,
status: 503,
message: "service unavailable",
},
);
drop(take(&trail));
let mut peer = send(addr, "/streaming/data");
let head = common::read_head(&mut peer, PROXY_WIRE_TIMEOUT)
.expect("the upstream's committed status never reached the peer");
let head = String::from_utf8_lossy(&head).into_owned();
assert!(
head.starts_with(&format!("HTTP/1.1 {TRUNCATED_STATUS}")),
"the upstream's committed status reaches the peer: {head}"
);
upstream.truncate_body();
let tail = common::drain_to_close(&mut peer, PROXY_WIRE_TIMEOUT)
.expect("the truncated proxied response never ended");
let answered = format!("{head}{tail}");
assert_eq!(
answered.matches("HTTP/1.1 ").count(),
1,
"a failure after header commitment produces no replacement response"
);
assert!(
drain(&journal).is_empty(),
"a failure after header commitment claims no mapper execution"
);
assert_eq!(
take(&trail).as_ref(),
["gate:enter", "gate:exit"].as_slice(),
"the completed gate is not replayed around the committed stream"
);
runtime::request_shutdown();
})
.expect("the fixture runtime ran to completion");
}
#[test]
fn proxy_preheader_deadline_maps_as_a_gateway_timeout() {
common::test_runtime()
.run(|| {
let upstream = scripted_upstream(UpstreamScript::Stall);
let (journal, _trail, addr) = proxy_fixture(&upstream.backend());
let answered = get_until_closed(addr, "/buffered/slow");
assert!(
answered.starts_with(&format!("HTTP/1.1 {COLLAPSED_STATUS}")),
"collapsed wire status: {answered}"
);
let seen = only(&journal, "pre-header deadline");
assert_classification(
&seen,
&Collapsed {
kind: RejectionKind::Proxy,
status: 504,
message: "gateway timeout",
},
"pre-header deadline",
);
assert_eq!(
seen.protocol,
Some(RejectionProtocol::Proxy),
"the selected dispatch class is established"
);
runtime::request_shutdown();
})
.expect("the fixture runtime ran to completion");
}
#[test]
fn refused_proxy_routes_never_reach_an_application_handler() {
common::test_runtime()
.run(|| {
let entries = Arc::new(AtomicUsize::new(0));
let mut upstream = Router::new();
let counted = Arc::clone(&entries);
upstream.get_stream("/data", move |_req: &Request| {
counted.fetch_add(1, Ordering::SeqCst);
Box::pin(async {
let (response, _sender) = StreamResponse::new(200);
response
})
});
let upstream_addr = common::spawn_server(upstream);
let (journal, _trail, addr) = proxy_fixture(&format!("http://{upstream_addr}"));
let served = get(addr, "/streaming/data");
assert_eq!(served.status, 200, "the healthy route reaches its upstream");
assert_eq!(
entries.load(Ordering::SeqCst),
1,
"the counted upstream handler ran for the healthy route"
);
assert!(
drain(&journal).is_empty(),
"a proxied request the upstream answered invokes no mapper"
);
let refused = get(addr, "/unhealthy/data");
assert_eq!(
refused.status, COLLAPSED_STATUS,
"the unhealthy route is refused"
);
assert_eq!(
entries.load(Ordering::SeqCst),
1,
"a refused proxy route never reaches its upstream handler"
);
assert_eq!(only(&journal, "unhealthy").kind, RejectionKind::Proxy);
runtime::request_shutdown();
})
.expect("the fixture runtime ran to completion");
}