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
55 changes: 55 additions & 0 deletions .github/workflows/stress-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Stress Test

on:
workflow_dispatch:
inputs:
duration_minutes:
description: "Stress test duration in minutes (max 50 to leave setup/teardown headroom)"
required: false
default: "30"
type: string
test_filter:
description: "Optional nextest filter expression, substring match on test names"
required: false
default: ""
type: string
schedule:
# Daily at 06:00 UTC
- cron: "0 6 * * *"

permissions:
contents: read

concurrency:
group: stress-test
cancel-in-progress: false

jobs:
stress-test:
name: Stress Test
runs-on: ubuntu-latest
# Accommodates the documented max duration_minutes of 50 plus toolchain install,
# cache restore, and build headroom.
timeout-minutes: 75
env:
RUST_BACKTRACE: 1
DURATION_MINUTES: ${{ inputs.duration_minutes || '30' }}
TEST_FILTER: ${{ inputs.test_filter }}
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@v2
with:
tool: cargo-nextest@0.9.132
- name: Run stress test
run: |
if [ -n "$TEST_FILTER" ]; then
cargo nextest run --workspace --all-features --no-fail-fast \
--stress-duration "${DURATION_MINUTES}m" -E "test($TEST_FILTER)"
else
cargo nextest run --workspace --all-features --no-fail-fast \
--stress-duration "${DURATION_MINUTES}m"
fi
13 changes: 12 additions & 1 deletion tower-http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

# Unreleased

# 0.6.10

## Added

- `follow-redirect`: expose `Attempt::method()` and `Attempt::previous_method()`
so redirect policies can react to method changes across redirects (e.g.
POST to GET on 301/303) ([#559])

## Fixed

- Restore `tokio` and `async-compression` as no-op features. These will be
removed next breaking release
removed next breaking release ([#667])

[#559]: https://github.com/tower-rs/tower-http/pull/559
[#667]: https://github.com/tower-rs/tower-http/pull/667

# 0.6.9

Expand Down
2 changes: 1 addition & 1 deletion tower-http/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "tower-http"
description = "Tower middleware and utilities for HTTP clients and servers"
version = "0.6.9"
version = "0.6.10"
authors = ["Tower Maintainers <team@tower-rs.com>"]
edition = "2018"
license = "MIT"
Expand Down
60 changes: 60 additions & 0 deletions tower-http/src/classify/grpc_errors_as_failures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,41 @@ impl GrpcCode {
}
}

/// Converts an `i32` gRPC status code into a [`GrpcCode`].
///
/// Unrecognized codes (outside 0-16) map to [`GrpcCode::Unknown`].
impl From<i32> for GrpcCode {
fn from(value: i32) -> Self {
match value {
0 => GrpcCode::Ok,
1 => GrpcCode::Cancelled,
2 => GrpcCode::Unknown,
3 => GrpcCode::InvalidArgument,
4 => GrpcCode::DeadlineExceeded,
5 => GrpcCode::NotFound,
6 => GrpcCode::AlreadyExists,
7 => GrpcCode::PermissionDenied,
8 => GrpcCode::ResourceExhausted,
9 => GrpcCode::FailedPrecondition,
10 => GrpcCode::Aborted,
11 => GrpcCode::OutOfRange,
12 => GrpcCode::Unimplemented,
13 => GrpcCode::Internal,
14 => GrpcCode::Unavailable,
15 => GrpcCode::DataLoss,
16 => GrpcCode::Unauthenticated,

_ => GrpcCode::Unknown,
}
}
}

impl From<NonZeroI32> for GrpcCode {
fn from(value: NonZeroI32) -> Self {
GrpcCode::from(value.get())
}
}

bitflags! {
#[derive(Debug, Clone, Copy)]
pub(crate) struct GrpcCodeBitmask: u32 {
Expand Down Expand Up @@ -354,4 +389,29 @@ mod tests {
success_flags: GrpcCodeBitmask::OK | GrpcCodeBitmask::INVALID_ARGUMENT,
expected: ParsedGrpcStatus::NonSuccess(NonZeroI32::new(16).unwrap()),
}

#[test]
fn grpc_code_from_i32_known_codes() {
assert!(matches!(GrpcCode::from(0), GrpcCode::Ok));
assert!(matches!(GrpcCode::from(1), GrpcCode::Cancelled));
assert!(matches!(GrpcCode::from(4), GrpcCode::DeadlineExceeded));
assert!(matches!(GrpcCode::from(13), GrpcCode::Internal));
assert!(matches!(GrpcCode::from(16), GrpcCode::Unauthenticated));
}

#[test]
fn grpc_code_from_i32_unknown_codes() {
assert!(matches!(GrpcCode::from(17), GrpcCode::Unknown));
assert!(matches!(GrpcCode::from(-1), GrpcCode::Unknown));
assert!(matches!(GrpcCode::from(9999), GrpcCode::Unknown));
}

#[test]
fn grpc_code_from_non_zero_i32() {
let code = NonZeroI32::new(7).unwrap();
assert!(matches!(GrpcCode::from(code), GrpcCode::PermissionDenied));

let code = NonZeroI32::new(99).unwrap();
assert!(matches!(GrpcCode::from(code), GrpcCode::Unknown));
}
}
39 changes: 23 additions & 16 deletions tower-http/src/compression/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,17 +144,28 @@ mod tests {

#[tokio::test]
async fn accept_encoding_configuration_works() -> Result<(), crate::BoxError> {
use std::io::Read;

fn decode<R: Read>(mut r: R) -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
r.read_to_end(&mut buf)?;
Ok(buf)
}

// Read the source file once so we can verify each response round-trips to the same bytes.
let expected = tokio::fs::read("Cargo.toml").await?;

// Configure a layer that only offers deflate, then confirm the response is actually
// deflate-encoded by decoding it and comparing to the original content.
let deflate_only_layer = CompressionLayer::new()
.quality(CompressionLevel::Best)
.no_br()
.no_gzip();

let mut service = ServiceBuilder::new()
// Compress responses based on the `Accept-Encoding` header.
.layer(deflate_only_layer)
.service_fn(handle);

// Call the service with the deflate only layer
let request = Request::builder()
.header(ACCEPT_ENCODING, "gzip, deflate, br")
.body(Body::empty())?;
Expand All @@ -163,23 +174,23 @@ mod tests {

assert_eq!(response.headers()["content-encoding"], "deflate");

// Read the body
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let deflate_body = response.into_body().collect().await?.to_bytes();

let deflate_bytes_len = bytes.len();
// The "deflate" Content-Encoding is RFC 1950 zlib framing (2-byte header + Adler-32),
// not raw RFC 1951 deflate, so use ZlibDecoder rather than DeflateDecoder.
let decoded = decode(flate2::bufread::ZlibDecoder::new(&deflate_body[..]))?;
assert_eq!(decoded, expected);

// Same check for brotli.
let br_only_layer = CompressionLayer::new()
.quality(CompressionLevel::Best)
.no_gzip()
.no_deflate();

let mut service = ServiceBuilder::new()
// Compress responses based on the `Accept-Encoding` header.
.layer(br_only_layer)
.service_fn(handle);

// Call the service with the br only layer
let request = Request::builder()
.header(ACCEPT_ENCODING, "gzip, deflate, br")
.body(Body::empty())?;
Expand All @@ -188,15 +199,11 @@ mod tests {

assert_eq!(response.headers()["content-encoding"], "br");

// Read the body
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();

let br_byte_length = bytes.len();
let br_body = response.into_body().collect().await?.to_bytes();

// check the corresponding algorithms are actually used
// br should compresses better than deflate
assert!(br_byte_length < deflate_bytes_len * 9 / 10);
// 4096 is the decoder's internal read-buffer size, not a content-length bound.
let decoded = decode(brotli::Decompressor::new(&br_body[..], 4096))?;
assert_eq!(decoded, expected);

Ok(())
}
Expand Down
25 changes: 25 additions & 0 deletions tower-http/src/compression/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,31 @@ mod tests {
assert_eq!(res.headers()[CONTENT_ENCODING], "gzip");
}

#[tokio::test]
async fn does_compress_grpc_web() {
async fn handle(_req: Request<Body>) -> Result<Response<Body>, Infallible> {
let mut res = Response::new(Body::from(
"a".repeat((SizeAbove::DEFAULT_MIN_SIZE * 2) as usize),
));
res.headers_mut()
.insert(CONTENT_TYPE, "application/grpc-web+proto".parse().unwrap());
Ok(res)
}

let svc = Compression::new(service_fn(handle));

let res = svc
.oneshot(
Request::builder()
.header(ACCEPT_ENCODING, "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.headers()[CONTENT_ENCODING], "gzip");
}

#[tokio::test]
async fn compress_with_quality() {
const DATA: &str = "Check compression quality level! Check compression quality level! Check compression quality level!";
Expand Down
10 changes: 7 additions & 3 deletions tower-http/src/compression/predicate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,10 @@ pub struct NotForContentType {

impl NotForContentType {
/// Predicate that wont compress gRPC responses.
pub const GRPC: Self = Self::const_new("application/grpc");
pub const GRPC: Self = Self {
content_type: Str::Static("application/grpc"),
exception: Some(Str::Static("application/grpc-web")),
};

/// Predicate that wont compress images.
pub const IMAGES: Self = Self {
Expand Down Expand Up @@ -229,13 +232,14 @@ impl Predicate for NotForContentType {
where
B: Body,
{
let cty = content_type(response);
if let Some(except) = &self.exception {
if content_type(response) == except.as_str() {
if cty.starts_with(except.as_str()) {
return true;
}
}

!content_type(response).starts_with(self.content_type.as_str())
!cty.starts_with(self.content_type.as_str())
}
}

Expand Down
Loading
Loading