1use std::time::Duration;
15
16use fast_telemetry::otlp::{build_export_request, build_resource, pb};
17use prost::Message;
18use tokio::time::{MissedTickBehavior, interval};
19use tokio_util::sync::CancellationToken;
20
21#[derive(Clone)]
23pub struct OtlpConfig {
24 pub endpoint: String,
27 pub interval: Duration,
29 pub service_name: String,
31 pub scope_name: String,
33 pub resource_attributes: Vec<(String, String)>,
35 pub timeout: Duration,
37 pub headers: Vec<(String, String)>,
43}
44
45impl Default for OtlpConfig {
46 fn default() -> Self {
47 Self {
48 endpoint: "http://localhost:4318".to_string(),
49 interval: Duration::from_secs(60),
50 service_name: "unknown_service".to_string(),
51 scope_name: "fast-telemetry".to_string(),
52 resource_attributes: Vec::new(),
53 timeout: Duration::from_secs(10),
54 headers: Vec::new(),
55 }
56 }
57}
58
59impl OtlpConfig {
60 pub fn new(endpoint: impl Into<String>) -> Self {
61 Self {
62 endpoint: endpoint.into(),
63 ..Default::default()
64 }
65 }
66
67 pub fn with_interval(mut self, interval: Duration) -> Self {
68 self.interval = interval;
69 self
70 }
71
72 pub fn with_service_name(mut self, name: impl Into<String>) -> Self {
73 self.service_name = name.into();
74 self
75 }
76
77 pub fn with_scope_name(mut self, name: impl Into<String>) -> Self {
78 self.scope_name = name.into();
79 self
80 }
81
82 pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
83 self.resource_attributes.push((key.into(), value.into()));
84 self
85 }
86
87 pub fn with_timeout(mut self, timeout: Duration) -> Self {
88 self.timeout = timeout;
89 self
90 }
91
92 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
93 self.headers.push((name.into(), value.into()));
94 self
95 }
96}
97
98const MAX_BACKOFF: Duration = Duration::from_secs(300);
100
101const BASE_BACKOFF: Duration = Duration::from_secs(5);
103
104const GZIP_THRESHOLD: usize = 1024;
107
108fn gzip_compress(data: &[u8], out: &mut Vec<u8>) -> bool {
113 if data.len() < GZIP_THRESHOLD {
114 return false;
115 }
116 use flate2::Compression;
117 use flate2::write::GzEncoder;
118 use std::io::Write;
119
120 out.clear();
121 let mut encoder = GzEncoder::new(out, Compression::fast());
122 let _ = encoder.write_all(data);
123 let _ = encoder.finish();
124 true
125}
126
127async fn send_otlp(
129 client: &reqwest::Client,
130 url: &str,
131 body: &[u8],
132 gzip_buf: &mut Vec<u8>,
133 extra_headers: &[(String, String)],
134) -> Result<reqwest::Response, reqwest::Error> {
135 let mut req = client
136 .post(url)
137 .header("Content-Type", "application/x-protobuf");
138
139 for (name, value) in extra_headers {
140 req = req.header(name, value);
141 }
142
143 if gzip_compress(body, gzip_buf) {
144 req.header("Content-Encoding", "gzip")
145 .body(gzip_buf.clone())
146 .send()
147 .await
148 } else {
149 req.body(body.to_vec()).send().await
150 }
151}
152
153pub async fn run<F>(config: OtlpConfig, cancel: CancellationToken, mut collect_fn: F)
186where
187 F: FnMut(&mut Vec<pb::Metric>),
188{
189 let url = format!("{}/v1/metrics", config.endpoint.trim_end_matches('/'));
190
191 log::info!(
192 "Starting OTLP metrics exporter, endpoint={url}, interval={}s",
193 config.interval.as_secs()
194 );
195
196 let attr_refs: Vec<(&str, &str)> = config
197 .resource_attributes
198 .iter()
199 .map(|(k, v)| (k.as_str(), v.as_str()))
200 .collect();
201 let resource = build_resource(&config.service_name, &attr_refs);
202
203 let client = match reqwest::Client::builder().timeout(config.timeout).build() {
204 Ok(c) => c,
205 Err(e) => {
206 log::error!("Failed to build HTTP client for OTLP exporter: {e}");
207 return;
208 }
209 };
210
211 let mut interval = interval(config.interval);
212 interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
213 interval.tick().await;
214
215 let mut consecutive_failures: u32 = 0;
216 let mut bufs = ExportBufs::default();
217
218 let ctx = ExportContext {
219 client: &client,
220 url: &url,
221 resource: &resource,
222 scope_name: &config.scope_name,
223 extra_headers: &config.headers,
224 };
225
226 loop {
227 tokio::select! {
228 _ = interval.tick() => {}
229 _ = cancel.cancelled() => {
230 log::info!("OTLP metrics exporter shutting down, performing final export");
231 export_once(&ctx, &mut collect_fn, &mut bufs).await;
232 return;
233 }
234 }
235
236 if consecutive_failures > 0 {
237 let backoff = backoff_with_jitter(consecutive_failures);
238 log::debug!(
239 "OTLP export backing off {}ms (failures={consecutive_failures})",
240 backoff.as_millis()
241 );
242 tokio::select! {
243 _ = tokio::time::sleep(backoff) => {}
244 _ = cancel.cancelled() => {
245 export_once(&ctx, &mut collect_fn, &mut bufs).await;
246 return;
247 }
248 }
249 }
250
251 let mut metric_messages = Vec::new();
252 collect_fn(&mut metric_messages);
253
254 if metric_messages.is_empty() {
255 continue;
256 }
257
258 let metric_count = metric_messages.len();
259 let request = build_export_request(&resource, &config.scope_name, metric_messages);
260
261 bufs.encode.clear();
262 if let Err(e) = request.encode(&mut bufs.encode) {
263 log::warn!("OTLP protobuf encode failed: {e}");
264 continue;
265 }
266 let body_len = bufs.encode.len();
267
268 match send_otlp(&client, &url, &bufs.encode, &mut bufs.gzip, &config.headers).await {
269 Ok(resp) if resp.status().is_success() => {
270 consecutive_failures = 0;
271 log::debug!("Exported {metric_count} OTLP metrics ({body_len} bytes)");
272 }
273 Ok(resp) => {
274 consecutive_failures = consecutive_failures.saturating_add(1);
275 let status = resp.status();
276 let body = resp.text().await.unwrap_or_default();
277 log::warn!("OTLP export failed: status={status}, body={body}");
278 }
279 Err(e) => {
280 consecutive_failures = consecutive_failures.saturating_add(1);
281 log::warn!("OTLP export request failed: {e}");
282 }
283 }
284 }
285}
286
287#[cfg(feature = "monoio")]
297pub async fn run_monoio<F>(config: OtlpConfig, cancel: CancellationToken, mut collect_fn: F)
298where
299 F: FnMut(&mut Vec<pb::Metric>),
300{
301 use monoio::time::MissedTickBehavior;
302
303 let url = format!("{}/v1/metrics", config.endpoint.trim_end_matches('/'));
304 let target = match MonoioHttpTarget::parse(&url) {
305 Ok(target) => target,
306 Err(e) => {
307 log::error!("Invalid monoio OTLP endpoint {url}: {e}");
308 return;
309 }
310 };
311
312 log::info!(
313 "Starting monoio OTLP metrics exporter, endpoint={url}, interval={}s",
314 config.interval.as_secs()
315 );
316
317 let attr_refs: Vec<(&str, &str)> = config
318 .resource_attributes
319 .iter()
320 .map(|(k, v)| (k.as_str(), v.as_str()))
321 .collect();
322 let resource = build_resource(&config.service_name, &attr_refs);
323
324 let mut interval = monoio::time::interval(config.interval);
325 interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
326 interval.tick().await;
327
328 let mut consecutive_failures: u32 = 0;
329 let mut bufs = ExportBufs::default();
330
331 let ctx = MonoioExportContext {
332 target: &target,
333 resource: &resource,
334 scope_name: &config.scope_name,
335 extra_headers: &config.headers,
336 timeout: config.timeout,
337 };
338
339 loop {
340 monoio::select! {
341 _ = interval.tick() => {}
342 _ = cancel.cancelled() => {
343 log::info!("monoio OTLP metrics exporter shutting down, performing final export");
344 export_once_monoio(&ctx, &mut collect_fn, &mut bufs).await;
345 return;
346 }
347 }
348
349 if consecutive_failures > 0 {
350 let backoff = backoff_with_jitter(consecutive_failures);
351 log::debug!(
352 "monoio OTLP export backing off {}ms (failures={consecutive_failures})",
353 backoff.as_millis()
354 );
355 monoio::select! {
356 _ = monoio::time::sleep(backoff) => {}
357 _ = cancel.cancelled() => {
358 export_once_monoio(&ctx, &mut collect_fn, &mut bufs).await;
359 return;
360 }
361 }
362 }
363
364 let mut metric_messages = Vec::new();
365 collect_fn(&mut metric_messages);
366
367 if metric_messages.is_empty() {
368 continue;
369 }
370
371 let metric_count = metric_messages.len();
372 let request = build_export_request(&resource, &config.scope_name, metric_messages);
373
374 bufs.encode.clear();
375 if let Err(e) = request.encode(&mut bufs.encode) {
376 log::warn!("monoio OTLP protobuf encode failed: {e}");
377 continue;
378 }
379 let body_len = bufs.encode.len();
380
381 match send_otlp_monoio(
382 &target,
383 &bufs.encode,
384 &mut bufs.gzip,
385 &config.headers,
386 config.timeout,
387 )
388 .await
389 {
390 Ok(resp) if resp.is_success() => {
391 consecutive_failures = 0;
392 log::debug!("Exported {metric_count} monoio OTLP metrics ({body_len} bytes)");
393 }
394 Ok(resp) => {
395 consecutive_failures = consecutive_failures.saturating_add(1);
396 log::warn!(
397 "monoio OTLP export failed: status={}, body={}",
398 resp.status,
399 resp.body_text_lossy()
400 );
401 }
402 Err(e) => {
403 consecutive_failures = consecutive_failures.saturating_add(1);
404 log::warn!("monoio OTLP export request failed: {e}");
405 }
406 }
407 }
408}
409
410struct ExportContext<'a> {
411 client: &'a reqwest::Client,
412 url: &'a str,
413 resource: &'a pb::Resource,
414 scope_name: &'a str,
415 extra_headers: &'a [(String, String)],
416}
417
418#[derive(Default)]
419struct ExportBufs {
420 encode: Vec<u8>,
421 gzip: Vec<u8>,
422}
423
424async fn export_once<F>(ctx: &ExportContext<'_>, collect_fn: &mut F, bufs: &mut ExportBufs)
425where
426 F: FnMut(&mut Vec<pb::Metric>),
427{
428 let mut metric_messages = Vec::new();
429 collect_fn(&mut metric_messages);
430
431 if metric_messages.is_empty() {
432 return;
433 }
434
435 let request = build_export_request(ctx.resource, ctx.scope_name, metric_messages);
436
437 bufs.encode.clear();
438 if let Err(e) = request.encode(&mut bufs.encode) {
439 log::warn!("Final OTLP protobuf encode failed: {e}");
440 return;
441 }
442
443 match send_otlp(
444 ctx.client,
445 ctx.url,
446 &bufs.encode,
447 &mut bufs.gzip,
448 ctx.extra_headers,
449 )
450 .await
451 {
452 Ok(resp) if !resp.status().is_success() => {
453 let status = resp.status();
454 let body = resp.text().await.unwrap_or_default();
455 log::warn!("Final OTLP export returned {status}: {body}");
456 }
457 Err(e) => log::warn!("Final OTLP export failed: {e}"),
458 _ => {}
459 }
460}
461
462#[cfg(feature = "monoio")]
463struct MonoioExportContext<'a> {
464 target: &'a MonoioHttpTarget,
465 resource: &'a pb::Resource,
466 scope_name: &'a str,
467 extra_headers: &'a [(String, String)],
468 timeout: Duration,
469}
470
471#[cfg(feature = "monoio")]
472async fn export_once_monoio<F>(
473 ctx: &MonoioExportContext<'_>,
474 collect_fn: &mut F,
475 bufs: &mut ExportBufs,
476) where
477 F: FnMut(&mut Vec<pb::Metric>),
478{
479 let mut metric_messages = Vec::new();
480 collect_fn(&mut metric_messages);
481
482 if metric_messages.is_empty() {
483 return;
484 }
485
486 let request = build_export_request(ctx.resource, ctx.scope_name, metric_messages);
487
488 bufs.encode.clear();
489 if let Err(e) = request.encode(&mut bufs.encode) {
490 log::warn!("Final monoio OTLP protobuf encode failed: {e}");
491 return;
492 }
493
494 match send_otlp_monoio(
495 ctx.target,
496 &bufs.encode,
497 &mut bufs.gzip,
498 ctx.extra_headers,
499 ctx.timeout,
500 )
501 .await
502 {
503 Ok(resp) if !resp.is_success() => {
504 log::warn!(
505 "Final monoio OTLP export returned {}: {}",
506 resp.status,
507 resp.body_text_lossy()
508 );
509 }
510 Err(e) => log::warn!("Final monoio OTLP export failed: {e}"),
511 _ => {}
512 }
513}
514
515#[cfg(feature = "monoio")]
516#[derive(Debug)]
517struct MonoioHttpTarget {
518 connect_addr: String,
519 host_header: String,
520 path: String,
521}
522
523#[cfg(feature = "monoio")]
524impl MonoioHttpTarget {
525 fn parse(url: &str) -> Result<Self, MonoioHttpError> {
526 let rest = url
527 .strip_prefix("http://")
528 .ok_or_else(|| MonoioHttpError::InvalidEndpoint("only http:// is supported".into()))?;
529 let (authority, path) = match rest.find('/') {
530 Some(idx) => (&rest[..idx], &rest[idx..]),
531 None => (rest, "/"),
532 };
533
534 if authority.is_empty() {
535 return Err(MonoioHttpError::InvalidEndpoint(
536 "missing host in endpoint".into(),
537 ));
538 }
539
540 let connect_addr = connect_addr_for_authority(authority)?;
541
542 Ok(Self {
543 connect_addr,
544 host_header: authority.to_string(),
545 path: path.to_string(),
546 })
547 }
548}
549
550#[cfg(feature = "monoio")]
551fn connect_addr_for_authority(authority: &str) -> Result<String, MonoioHttpError> {
552 if let Some(rest) = authority.strip_prefix('[') {
553 let end = rest.find(']').ok_or_else(|| {
554 MonoioHttpError::InvalidEndpoint("IPv6 hosts must use [addr] syntax".into())
555 })?;
556 let suffix = &rest[end + 1..];
557 return if suffix.is_empty() {
558 Ok(format!("{authority}:80"))
559 } else if let Some(port) = suffix.strip_prefix(':') {
560 validate_port(port)?;
561 Ok(authority.to_string())
562 } else {
563 Err(MonoioHttpError::InvalidEndpoint(
564 "unexpected characters after IPv6 host".into(),
565 ))
566 };
567 }
568
569 match authority.rsplit_once(':') {
570 Some((_host, port)) => {
571 validate_port(port)?;
572 Ok(authority.to_string())
573 }
574 None => Ok(format!("{authority}:80")),
575 }
576}
577
578#[cfg(feature = "monoio")]
579fn validate_port(port: &str) -> Result<(), MonoioHttpError> {
580 if port.parse::<u16>().is_ok() {
581 Ok(())
582 } else {
583 Err(MonoioHttpError::InvalidEndpoint(
584 "invalid port in endpoint".into(),
585 ))
586 }
587}
588
589#[cfg(feature = "monoio")]
590#[derive(Debug)]
591struct MonoioHttpResponse {
592 status: u16,
593 body: Vec<u8>,
594}
595
596#[cfg(feature = "monoio")]
597impl MonoioHttpResponse {
598 fn is_success(&self) -> bool {
599 (200..300).contains(&self.status)
600 }
601
602 fn body_text_lossy(&self) -> String {
603 String::from_utf8_lossy(&self.body).into_owned()
604 }
605}
606
607#[cfg(feature = "monoio")]
608#[derive(Debug)]
609enum MonoioHttpError {
610 InvalidEndpoint(String),
611 InvalidHeader(String),
612 Io(std::io::Error),
613 Timeout,
614 InvalidResponse,
615}
616
617#[cfg(feature = "monoio")]
618impl std::fmt::Display for MonoioHttpError {
619 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620 match self {
621 Self::InvalidEndpoint(msg) => write!(f, "{msg}"),
622 Self::InvalidHeader(name) => write!(f, "invalid HTTP header {name:?}"),
623 Self::Io(e) => write!(f, "{e}"),
624 Self::Timeout => f.write_str("request timed out"),
625 Self::InvalidResponse => f.write_str("invalid HTTP response"),
626 }
627 }
628}
629
630#[cfg(feature = "monoio")]
631impl From<std::io::Error> for MonoioHttpError {
632 fn from(value: std::io::Error) -> Self {
633 Self::Io(value)
634 }
635}
636
637#[cfg(feature = "monoio")]
638async fn send_otlp_monoio(
639 target: &MonoioHttpTarget,
640 body: &[u8],
641 gzip_buf: &mut Vec<u8>,
642 extra_headers: &[(String, String)],
643 timeout: Duration,
644) -> Result<MonoioHttpResponse, MonoioHttpError> {
645 let compressed = gzip_compress(body, gzip_buf);
646 let request_body = if compressed {
647 gzip_buf.as_slice()
648 } else {
649 body
650 };
651 let request = build_monoio_http_request(target, request_body, compressed, extra_headers)?;
652
653 match monoio::time::timeout(timeout, send_monoio_http_request(target, request)).await {
654 Ok(result) => result,
655 Err(_) => Err(MonoioHttpError::Timeout),
656 }
657}
658
659#[cfg(feature = "monoio")]
660fn build_monoio_http_request(
661 target: &MonoioHttpTarget,
662 body: &[u8],
663 compressed: bool,
664 extra_headers: &[(String, String)],
665) -> Result<Vec<u8>, MonoioHttpError> {
666 use std::fmt::Write as _;
667
668 let mut head = String::with_capacity(512 + extra_headers.len() * 64);
669 write!(
670 &mut head,
671 "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/x-protobuf\r\nContent-Length: {}\r\nConnection: close\r\n",
672 target.path,
673 target.host_header,
674 body.len()
675 )
676 .expect("writing to String cannot fail");
677
678 if compressed {
679 head.push_str("Content-Encoding: gzip\r\n");
680 }
681
682 for (name, value) in extra_headers {
683 validate_header(name, value)?;
684 write!(&mut head, "{name}: {value}\r\n").expect("writing to String cannot fail");
685 }
686
687 head.push_str("\r\n");
688 let mut request = head.into_bytes();
689 request.extend_from_slice(body);
690 Ok(request)
691}
692
693#[cfg(feature = "monoio")]
694fn validate_header(name: &str, value: &str) -> Result<(), MonoioHttpError> {
695 let invalid_name = name.is_empty()
696 || name
697 .bytes()
698 .any(|b| b <= b' ' || b == b':' || b == b'\r' || b == b'\n');
699 let invalid_value = value.bytes().any(|b| b == b'\r' || b == b'\n');
700
701 if invalid_name || invalid_value {
702 Err(MonoioHttpError::InvalidHeader(name.to_string()))
703 } else {
704 Ok(())
705 }
706}
707
708#[cfg(feature = "monoio")]
709async fn send_monoio_http_request(
710 target: &MonoioHttpTarget,
711 request: Vec<u8>,
712) -> Result<MonoioHttpResponse, MonoioHttpError> {
713 use monoio::io::{AsyncReadRent, AsyncWriteRentExt};
714 use monoio::net::TcpStream;
715
716 let mut stream = TcpStream::connect(target.connect_addr.as_str()).await?;
717 let (write_result, _request) = stream.write_all(request).await;
718 write_result?;
719
720 let mut response = Vec::with_capacity(8192);
721 loop {
722 let buf = Vec::with_capacity(8192);
723 let (read_result, buf) = stream.read(buf).await;
724 let n = read_result?;
725 if n == 0 {
726 break;
727 }
728 let available = buf.len().min(n);
729 response.extend_from_slice(&buf[..available]);
730 if response.len() >= 64 * 1024 {
731 break;
732 }
733 }
734
735 parse_monoio_http_response(response)
736}
737
738#[cfg(feature = "monoio")]
739fn parse_monoio_http_response(response: Vec<u8>) -> Result<MonoioHttpResponse, MonoioHttpError> {
740 let header_end = response
741 .windows(4)
742 .position(|w| w == b"\r\n\r\n")
743 .map(|idx| idx + 4)
744 .ok_or(MonoioHttpError::InvalidResponse)?;
745
746 let status_line_end = response[..header_end]
747 .windows(2)
748 .position(|w| w == b"\r\n")
749 .ok_or(MonoioHttpError::InvalidResponse)?;
750 let status_line = std::str::from_utf8(&response[..status_line_end])
751 .map_err(|_| MonoioHttpError::InvalidResponse)?;
752 let status = parse_status_code(status_line).ok_or(MonoioHttpError::InvalidResponse)?;
753 let body = response[header_end..].to_vec();
754
755 Ok(MonoioHttpResponse { status, body })
756}
757
758#[cfg(feature = "monoio")]
759fn parse_status_code(status_line: &str) -> Option<u16> {
760 let mut parts = status_line.split_whitespace();
761 let version = parts.next()?;
762 if !version.starts_with("HTTP/") {
763 return None;
764 }
765 parts.next()?.parse().ok()
766}
767
768fn backoff_with_jitter(consecutive_failures: u32) -> Duration {
770 let exp = consecutive_failures.min(10);
771 let base_ms = BASE_BACKOFF.as_millis() as u64;
772 let backoff_ms = base_ms
773 .saturating_mul(1u64 << exp)
774 .min(MAX_BACKOFF.as_millis() as u64);
775
776 let nanos = std::time::SystemTime::now()
777 .duration_since(std::time::UNIX_EPOCH)
778 .unwrap_or_default()
779 .subsec_nanos();
780 let jitter_range = (backoff_ms / 4).max(1);
781 let jitter = (nanos as u64 % (jitter_range * 2 + 1)).saturating_sub(jitter_range);
782 let final_ms = backoff_ms.saturating_add(jitter);
783
784 Duration::from_millis(final_ms)
785}
786
787#[cfg(all(test, feature = "monoio"))]
788mod monoio_tests {
789 use super::*;
790
791 #[test]
792 fn parses_http_target_with_port() {
793 let target = MonoioHttpTarget::parse("http://localhost:4318/v1/metrics").unwrap();
794 assert_eq!(target.connect_addr, "localhost:4318");
795 assert_eq!(target.host_header, "localhost:4318");
796 assert_eq!(target.path, "/v1/metrics");
797 }
798
799 #[test]
800 fn parses_http_target_without_port() {
801 let target = MonoioHttpTarget::parse("http://collector/v1/metrics").unwrap();
802 assert_eq!(target.connect_addr, "collector:80");
803 assert_eq!(target.host_header, "collector");
804 assert_eq!(target.path, "/v1/metrics");
805 }
806
807 #[test]
808 fn rejects_https_target() {
809 let err = MonoioHttpTarget::parse("https://collector:4318/v1/metrics").unwrap_err();
810 assert!(matches!(err, MonoioHttpError::InvalidEndpoint(_)));
811 }
812
813 #[test]
814 fn rejects_invalid_port() {
815 let err = MonoioHttpTarget::parse("http://collector:nope/v1/metrics").unwrap_err();
816 assert!(matches!(err, MonoioHttpError::InvalidEndpoint(_)));
817 }
818
819 #[test]
820 fn builds_http_request() {
821 let target = MonoioHttpTarget::parse("http://localhost:4318/v1/metrics").unwrap();
822 let request = build_monoio_http_request(
823 &target,
824 b"abc",
825 true,
826 &[("Authorization".to_string(), "Bearer token".to_string())],
827 )
828 .unwrap();
829 let request = String::from_utf8(request).unwrap();
830 assert!(request.starts_with("POST /v1/metrics HTTP/1.1\r\n"));
831 assert!(request.contains("Host: localhost:4318\r\n"));
832 assert!(request.contains("Content-Length: 3\r\n"));
833 assert!(request.contains("Content-Encoding: gzip\r\n"));
834 assert!(request.ends_with("\r\n\r\nabc"));
835 }
836
837 #[test]
838 fn parses_http_status() {
839 let response = parse_monoio_http_response(
840 b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n".to_vec(),
841 )
842 .unwrap();
843 assert_eq!(response.status, 204);
844 assert!(response.body.is_empty());
845 }
846}