1mod benchmark;
9mod conformance;
10
11pub use benchmark::{
12 BenchmarkFailureCount, BenchmarkFailureKind, BenchmarkFuture, BenchmarkInvocation,
13 BenchmarkInvocationError, BenchmarkPlan, BenchmarkRegressionComparison,
14 BenchmarkRegressionMetric, BenchmarkRegressionPolicy, BenchmarkTarget, LatencyDistribution,
15 ModelBenchmarkTarget, ProviderBenchmarkError, ProviderBenchmarkReport, benchmark,
16 compare_benchmarks,
17};
18pub use conformance::{
19 ConformanceCheck, ErrorContract, ProviderConformanceError, ProviderConformanceReport,
20 SuccessContract, verify_error, verify_success,
21};
22
23use std::{
24 collections::BTreeMap,
25 io::{Read, Write},
26 net::{SocketAddr, TcpListener, TcpStream},
27 sync::{
28 Arc, Mutex,
29 atomic::{AtomicBool, AtomicUsize, Ordering},
30 },
31 thread::{self, JoinHandle},
32 time::Duration,
33};
34
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37use thiserror::Error;
38
39#[derive(Clone, Debug)]
41pub struct HttpExchange {
42 pub method: String,
44 pub path: String,
46 pub json_body: Option<Value>,
48 pub response: ScriptedResponse,
50}
51
52impl HttpExchange {
53 pub fn new(
55 method: impl Into<String>,
56 path: impl Into<String>,
57 response: ScriptedResponse,
58 ) -> Self {
59 Self {
60 method: method.into().to_ascii_uppercase(),
61 path: path.into(),
62 json_body: None,
63 response,
64 }
65 }
66
67 #[must_use]
69 pub fn with_json_body(mut self, body: Value) -> Self {
70 self.json_body = Some(body);
71 self
72 }
73}
74
75#[derive(Clone, Debug, Default)]
77pub struct ResponseChunk {
78 pub body: Vec<u8>,
80 pub delay: Duration,
82}
83
84impl ResponseChunk {
85 pub fn text(body: impl Into<String>) -> Self {
87 Self {
88 body: body.into().into_bytes(),
89 delay: Duration::ZERO,
90 }
91 }
92
93 #[must_use]
95 pub const fn after(mut self, delay: Duration) -> Self {
96 self.delay = delay;
97 self
98 }
99}
100
101#[derive(Clone, Debug)]
103pub struct ScriptedResponse {
104 pub status: u16,
106 pub headers: BTreeMap<String, String>,
108 pub chunks: Vec<ResponseChunk>,
110 pub disconnect: bool,
112}
113
114impl ScriptedResponse {
115 pub fn ok(chunks: Vec<ResponseChunk>) -> Self {
117 Self {
118 status: 200,
119 headers: BTreeMap::new(),
120 chunks,
121 disconnect: false,
122 }
123 }
124
125 pub fn json(status: u16, body: &Value) -> Result<Self, CassetteError> {
131 let encoded = serde_json::to_vec(body)?;
132 let mut headers = BTreeMap::new();
133 headers.insert("content-type".into(), "application/json".into());
134 Ok(Self {
135 status,
136 headers,
137 chunks: vec![ResponseChunk {
138 body: encoded,
139 delay: Duration::ZERO,
140 }],
141 disconnect: false,
142 })
143 }
144
145 #[must_use]
147 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
148 self.headers.insert(name.into(), value.into());
149 self
150 }
151
152 #[must_use]
154 pub const fn disconnect_after_chunks(mut self) -> Self {
155 self.disconnect = true;
156 self
157 }
158}
159
160#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
162pub struct ObservedRequest {
163 pub method: String,
165 pub path: String,
167 pub headers: BTreeMap<String, String>,
169 pub body: Vec<u8>,
171}
172
173impl ObservedRequest {
174 pub fn json_body(&self) -> Result<Value, CassetteError> {
180 Ok(serde_json::from_slice(&self.body)?)
181 }
182}
183
184#[derive(Debug, Error)]
186#[non_exhaustive]
187pub enum CassetteError {
188 #[error("cassette I/O failed: {0}")]
190 Io(#[from] std::io::Error),
191 #[error("cassette JSON failed: {0}")]
193 Json(#[from] serde_json::Error),
194 #[error("cassette request mismatch: {0}")]
196 RequestMismatch(String),
197 #[error("cassette server thread panicked")]
199 ServerPanicked,
200 #[error("cassette consumed {observed} of {expected} exchanges")]
202 Incomplete {
203 observed: usize,
205 expected: usize,
207 },
208}
209
210#[derive(Debug)]
212pub struct CassetteServer {
213 address: SocketAddr,
214 expected: usize,
215 observed: Arc<Mutex<Vec<ObservedRequest>>>,
216 failure: Arc<Mutex<Option<String>>>,
217 stats: Arc<ServerCounters>,
218 shutdown: Arc<AtomicBool>,
219 thread: Option<JoinHandle<()>>,
220}
221
222#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
224pub struct ServerStats {
225 pub accepted: usize,
227 pub completed: usize,
229 pub max_in_flight: usize,
231}
232
233#[derive(Debug, Default)]
234struct ServerCounters {
235 accepted: AtomicUsize,
236 completed: AtomicUsize,
237 in_flight: AtomicUsize,
238 max_in_flight: AtomicUsize,
239}
240
241impl CassetteServer {
242 pub fn start(exchanges: Vec<HttpExchange>) -> Result<Self, CassetteError> {
248 let listener = TcpListener::bind("127.0.0.1:0")?;
249 let address = listener.local_addr()?;
250 let expected = exchanges.len();
251 let observed = Arc::new(Mutex::new(Vec::new()));
252 let failure = Arc::new(Mutex::new(None));
253 let stats = Arc::new(ServerCounters::default());
254 let shutdown = Arc::new(AtomicBool::new(false));
255
256 let thread_observed = Arc::clone(&observed);
257 let thread_failure = Arc::clone(&failure);
258 let thread_stats = Arc::clone(&stats);
259 let thread_shutdown = Arc::clone(&shutdown);
260 let thread = thread::spawn(move || {
261 serve(
262 &listener,
263 exchanges,
264 &thread_observed,
265 &thread_failure,
266 &thread_stats,
267 &thread_shutdown,
268 );
269 });
270
271 Ok(Self {
272 address,
273 expected,
274 observed,
275 failure,
276 stats,
277 shutdown,
278 thread: Some(thread),
279 })
280 }
281
282 pub fn start_repeating(
292 exchange: HttpExchange,
293 request_count: usize,
294 ) -> Result<Self, CassetteError> {
295 let listener = TcpListener::bind("127.0.0.1:0")?;
296 let address = listener.local_addr()?;
297 let observed = Arc::new(Mutex::new(Vec::new()));
298 let failure = Arc::new(Mutex::new(None));
299 let stats = Arc::new(ServerCounters::default());
300 let shutdown = Arc::new(AtomicBool::new(false));
301
302 let thread_observed = Arc::clone(&observed);
303 let thread_failure = Arc::clone(&failure);
304 let thread_stats = Arc::clone(&stats);
305 let thread_shutdown = Arc::clone(&shutdown);
306 let thread = thread::spawn(move || {
307 serve_repeating(
308 &listener,
309 &exchange,
310 request_count,
311 &thread_observed,
312 &thread_failure,
313 &thread_stats,
314 &thread_shutdown,
315 );
316 });
317
318 Ok(Self {
319 address,
320 expected: request_count,
321 observed,
322 failure,
323 stats,
324 shutdown,
325 thread: Some(thread),
326 })
327 }
328
329 pub fn base_url(&self) -> String {
331 format!("http://{}/", self.address)
332 }
333
334 pub fn observed_requests(&self) -> Vec<ObservedRequest> {
336 self.observed
337 .lock()
338 .unwrap_or_else(std::sync::PoisonError::into_inner)
339 .clone()
340 }
341
342 pub fn stats(&self) -> ServerStats {
344 ServerStats {
345 accepted: self.stats.accepted.load(Ordering::Acquire),
346 completed: self.stats.completed.load(Ordering::Acquire),
347 max_in_flight: self.stats.max_in_flight.load(Ordering::Acquire),
348 }
349 }
350
351 pub fn assert_finished(&self) -> Result<(), CassetteError> {
357 if let Some(message) = self
358 .failure
359 .lock()
360 .unwrap_or_else(std::sync::PoisonError::into_inner)
361 .clone()
362 {
363 return Err(CassetteError::RequestMismatch(message));
364 }
365 let observed = self
366 .observed
367 .lock()
368 .unwrap_or_else(std::sync::PoisonError::into_inner)
369 .len();
370 if observed != self.expected {
371 return Err(CassetteError::Incomplete {
372 observed,
373 expected: self.expected,
374 });
375 }
376 Ok(())
377 }
378}
379
380impl Drop for CassetteServer {
381 fn drop(&mut self) {
382 self.shutdown.store(true, Ordering::Release);
383 let _ = TcpStream::connect(self.address);
384 if let Some(thread) = self.thread.take() {
385 let _ = thread.join();
386 }
387 }
388}
389
390fn serve(
391 listener: &TcpListener,
392 exchanges: Vec<HttpExchange>,
393 observed: &Mutex<Vec<ObservedRequest>>,
394 failure: &Mutex<Option<String>>,
395 stats: &ServerCounters,
396 shutdown: &AtomicBool,
397) {
398 for exchange in exchanges {
399 if shutdown.load(Ordering::Acquire) {
400 return;
401 }
402 let Ok((mut stream, _)) = listener.accept() else {
403 set_failure(failure, "failed to accept a connection");
404 return;
405 };
406 if shutdown.load(Ordering::Acquire) {
407 return;
408 }
409 request_started(stats);
410 let request = match read_request(&mut stream) {
411 Ok(request) => request,
412 Err(error) => {
413 set_failure(failure, error.to_string());
414 request_finished(stats, false);
415 return;
416 }
417 };
418 let mismatch = validate_request(&request, &exchange);
419 observed
420 .lock()
421 .unwrap_or_else(std::sync::PoisonError::into_inner)
422 .push(redact(request));
423 if let Err(error) = mismatch {
424 set_failure(failure, error.to_string());
425 let _ = write_simple_error(&mut stream);
426 request_finished(stats, false);
427 return;
428 }
429 if write_response(&mut stream, &exchange.response).is_err() {
430 request_finished(stats, false);
434 continue;
435 }
436 request_finished(stats, true);
437 }
438}
439
440fn serve_repeating(
441 listener: &TcpListener,
442 exchange: &HttpExchange,
443 request_count: usize,
444 observed: &Arc<Mutex<Vec<ObservedRequest>>>,
445 failure: &Arc<Mutex<Option<String>>>,
446 stats: &Arc<ServerCounters>,
447 shutdown: &AtomicBool,
448) {
449 let mut workers = Vec::with_capacity(request_count);
450 for _ in 0..request_count {
451 if shutdown.load(Ordering::Acquire) {
452 break;
453 }
454 let Ok((stream, _)) = listener.accept() else {
455 set_failure(failure, "failed to accept a connection");
456 break;
457 };
458 if shutdown.load(Ordering::Acquire) {
459 break;
460 }
461 let exchange = exchange.clone();
462 let observed = Arc::clone(observed);
463 let failure = Arc::clone(failure);
464 let stats = Arc::clone(stats);
465 workers.push(thread::spawn(move || {
466 handle_repeated(stream, &exchange, &observed, &failure, &stats);
467 }));
468 }
469 for worker in workers {
470 if worker.join().is_err() {
471 set_failure(failure, "cassette connection handler panicked");
472 }
473 }
474}
475
476fn handle_repeated(
477 mut stream: TcpStream,
478 exchange: &HttpExchange,
479 observed: &Mutex<Vec<ObservedRequest>>,
480 failure: &Mutex<Option<String>>,
481 stats: &ServerCounters,
482) {
483 request_started(stats);
484 let request = match read_request(&mut stream) {
485 Ok(request) => request,
486 Err(error) => {
487 set_failure(failure, error.to_string());
488 request_finished(stats, false);
489 return;
490 }
491 };
492 let mismatch = validate_request(&request, exchange);
493 observed
494 .lock()
495 .unwrap_or_else(std::sync::PoisonError::into_inner)
496 .push(redact(request));
497 if let Err(error) = mismatch {
498 set_failure(failure, error.to_string());
499 let _ = write_simple_error(&mut stream);
500 request_finished(stats, false);
501 return;
502 }
503 match write_response(&mut stream, &exchange.response) {
504 Ok(()) => request_finished(stats, true),
505 Err(error) => {
506 set_failure(failure, error.to_string());
507 request_finished(stats, false);
508 }
509 }
510}
511
512fn request_started(stats: &ServerCounters) {
513 stats.accepted.fetch_add(1, Ordering::AcqRel);
514 let current = stats.in_flight.fetch_add(1, Ordering::AcqRel) + 1;
515 stats.max_in_flight.fetch_max(current, Ordering::AcqRel);
516}
517
518fn request_finished(stats: &ServerCounters, completed: bool) {
519 stats.in_flight.fetch_sub(1, Ordering::AcqRel);
520 if completed {
521 stats.completed.fetch_add(1, Ordering::AcqRel);
522 }
523}
524
525fn read_request(stream: &mut TcpStream) -> Result<ObservedRequest, CassetteError> {
526 stream.set_read_timeout(Some(Duration::from_secs(5)))?;
527 let mut bytes = Vec::new();
528 let header_end = loop {
529 let mut buffer = [0_u8; 4096];
530 let count = stream.read(&mut buffer)?;
531 if count == 0 {
532 return Err(CassetteError::RequestMismatch(
533 "connection closed before HTTP headers completed".into(),
534 ));
535 }
536 bytes.extend_from_slice(&buffer[..count]);
537 if let Some(position) = find_subslice(&bytes, b"\r\n\r\n") {
538 break position + 4;
539 }
540 if bytes.len() > 1024 * 1024 {
541 return Err(CassetteError::RequestMismatch(
542 "request headers exceeded 1 MiB".into(),
543 ));
544 }
545 };
546
547 let header_text = std::str::from_utf8(&bytes[..header_end]).map_err(|_| {
548 CassetteError::RequestMismatch("request headers were not valid UTF-8".into())
549 })?;
550 let mut lines = header_text.split("\r\n");
551 let request_line = lines
552 .next()
553 .ok_or_else(|| CassetteError::RequestMismatch("missing request line".into()))?;
554 let mut request_parts = request_line.split_whitespace();
555 let method = required_part(&mut request_parts, "method")?.to_owned();
556 let path = required_part(&mut request_parts, "request target")?.to_owned();
557 let mut headers = BTreeMap::new();
558 for line in lines.filter(|line| !line.is_empty()) {
559 let (name, value) = line.split_once(':').ok_or_else(|| {
560 CassetteError::RequestMismatch(format!("malformed request header `{line}`"))
561 })?;
562 headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_owned());
563 }
564 let content_length = headers.get("content-length").map_or(Ok(0), |value| {
565 value
566 .parse::<usize>()
567 .map_err(|_| CassetteError::RequestMismatch("invalid content-length header".into()))
568 })?;
569 while bytes.len() - header_end < content_length {
570 let mut buffer = [0_u8; 4096];
571 let count = stream.read(&mut buffer)?;
572 if count == 0 {
573 return Err(CassetteError::RequestMismatch(
574 "connection closed before request body completed".into(),
575 ));
576 }
577 bytes.extend_from_slice(&buffer[..count]);
578 }
579 Ok(ObservedRequest {
580 method,
581 path,
582 headers,
583 body: bytes[header_end..header_end + content_length].to_vec(),
584 })
585}
586
587fn required_part<'a>(
588 parts: &mut impl Iterator<Item = &'a str>,
589 name: &str,
590) -> Result<&'a str, CassetteError> {
591 parts
592 .next()
593 .ok_or_else(|| CassetteError::RequestMismatch(format!("request line is missing {name}")))
594}
595
596fn validate_request(
597 request: &ObservedRequest,
598 exchange: &HttpExchange,
599) -> Result<(), CassetteError> {
600 if request.method != exchange.method {
601 return Err(CassetteError::RequestMismatch(format!(
602 "expected method {}, received {}",
603 exchange.method, request.method
604 )));
605 }
606 if request.path != exchange.path {
607 return Err(CassetteError::RequestMismatch(format!(
608 "expected path {}, received {}",
609 exchange.path, request.path
610 )));
611 }
612 if let Some(expected) = &exchange.json_body {
613 let actual = request.json_body()?;
614 if actual != *expected {
615 return Err(CassetteError::RequestMismatch(format!(
616 "JSON body differs: expected {expected}, received {actual}"
617 )));
618 }
619 }
620 Ok(())
621}
622
623fn redact(mut request: ObservedRequest) -> ObservedRequest {
624 for name in [
625 "authorization",
626 "x-api-key",
627 "x-goog-api-key",
628 "api-key",
629 "x-amz-security-token",
630 ] {
631 if let Some(value) = request.headers.get_mut(name) {
632 *value = "[REDACTED]".into();
633 }
634 }
635 request
636}
637
638fn write_response(
639 stream: &mut TcpStream,
640 response: &ScriptedResponse,
641) -> Result<(), CassetteError> {
642 let reason = reason_phrase(response.status);
643 write!(
644 stream,
645 "HTTP/1.1 {} {}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n",
646 response.status, reason
647 )?;
648 for (name, value) in &response.headers {
649 write!(stream, "{name}: {value}\r\n")?;
650 }
651 write!(stream, "\r\n")?;
652 stream.flush()?;
653 for chunk in &response.chunks {
654 if !chunk.delay.is_zero() {
655 thread::sleep(chunk.delay);
656 }
657 write!(stream, "{:X}\r\n", chunk.body.len())?;
658 stream.write_all(&chunk.body)?;
659 write!(stream, "\r\n")?;
660 stream.flush()?;
661 }
662 if !response.disconnect {
663 match stream.write_all(b"0\r\n\r\n").and_then(|()| stream.flush()) {
664 Ok(()) => {}
665 Err(error) if peer_closed(&error) => {}
666 Err(error) => return Err(error.into()),
667 }
668 }
669 Ok(())
670}
671
672fn peer_closed(error: &std::io::Error) -> bool {
673 matches!(
674 error.kind(),
675 std::io::ErrorKind::BrokenPipe
676 | std::io::ErrorKind::ConnectionAborted
677 | std::io::ErrorKind::ConnectionReset
678 )
679}
680
681fn write_simple_error(stream: &mut TcpStream) -> Result<(), CassetteError> {
682 stream.write_all(
683 b"HTTP/1.1 500 Cassette Mismatch\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
684 )?;
685 Ok(())
686}
687
688fn reason_phrase(status: u16) -> &'static str {
689 match status {
690 200 => "OK",
691 400 => "Bad Request",
692 401 => "Unauthorized",
693 403 => "Forbidden",
694 404 => "Not Found",
695 408 => "Request Timeout",
696 429 => "Too Many Requests",
697 500 => "Internal Server Error",
698 502 => "Bad Gateway",
699 503 => "Service Unavailable",
700 _ => "Scripted",
701 }
702}
703
704fn set_failure(failure: &Mutex<Option<String>>, message: impl Into<String>) {
705 *failure
706 .lock()
707 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(message.into());
708}
709
710fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
711 haystack
712 .windows(needle.len())
713 .position(|window| window == needle)
714}
715
716#[cfg(test)]
717mod tests {
718 use std::{
719 io::{Read, Write},
720 net::TcpStream,
721 };
722
723 use serde_json::json;
724
725 use super::{CassetteServer, HttpExchange, ResponseChunk, ScriptedResponse, peer_closed};
726
727 #[test]
728 fn peer_close_is_tolerated_only_after_the_scripted_body() {
729 for kind in [
730 std::io::ErrorKind::BrokenPipe,
731 std::io::ErrorKind::ConnectionAborted,
732 std::io::ErrorKind::ConnectionReset,
733 ] {
734 assert!(peer_closed(&std::io::Error::from(kind)));
735 }
736 assert!(!peer_closed(&std::io::Error::from(
737 std::io::ErrorKind::TimedOut
738 )));
739 }
740
741 #[test]
742 fn captures_json_and_redacts_credentials() {
743 let server = CassetteServer::start(vec![
744 HttpExchange::new(
745 "POST",
746 "/messages",
747 ScriptedResponse::ok(vec![ResponseChunk::text("hello")]),
748 )
749 .with_json_body(json!({"prompt": "hi"})),
750 ])
751 .unwrap();
752 let mut stream = TcpStream::connect(server.address).unwrap();
753 stream
754 .write_all(
755 b"POST /messages HTTP/1.1\r\nHost: localhost\r\nX-Api-Key: secret\r\nX-Amz-Security-Token: temporary-secret\r\nContent-Length: 15\r\n\r\n{\"prompt\":\"hi\"}",
756 )
757 .unwrap();
758 let mut response = String::new();
759 stream.read_to_string(&mut response).unwrap();
760
761 assert!(response.contains("5\r\nhello\r\n"));
762 server.assert_finished().unwrap();
763 let observed = server.observed_requests();
764 assert_eq!(observed[0].headers["x-api-key"], "[REDACTED]");
765 assert_eq!(observed[0].headers["x-amz-security-token"], "[REDACTED]");
766 assert_eq!(observed[0].json_body().unwrap(), json!({"prompt": "hi"}));
767 }
768}