Skip to content

Commit 96d9a96

Browse files
fix(server): Prevent Envoy retries of capacity rejections (#732)
Since #703, capacity rejections return 503. Our Envoy routes retry on `gateway-error`, which includes 503. As a result, a saturated instance gets retries of its own rejections. This PR sets `x-envoy-ratelimited: true` on rate limit and capacity rejections. Envoy does not retry a response that has this header. Real gateway errors are still retried, and outlier detection still counts the 503s. Ref FS-577, INC-2579 <!-- junior-request-attribution:start --> via **async jauer**. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:slack%3AD0AQCS7VDC2%3A1792685297.094279 --> -- [View Junior Session](https://junior-prod.sentry.dev/conversations/slack%3AD0AQCS7VDC2%3A1792685297.094279) [[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AD0AQCS7VDC2%3A1792685297.094279/?project=4510944073809921) <!-- junior-session-footer:end --> --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Jan Michael Auer <jan.auer@sentry.io>
1 parent 2612bee commit 96d9a96

6 files changed

Lines changed: 83 additions & 6 deletions

File tree

‎objectstore-server/docs/architecture.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@ scope, then explicit rules. The first exceeded limit determines the response.
151151
Responses identify only capacity protection or an exceeded traffic allowance;
152152
the specific limit is recorded in logs and metrics.
153153

154+
Rejected requests, including those rejected by the web concurrency limit and
155+
service backpressure below, carry `x-envoy-ratelimited: true`. Envoy does not retry responses with
156+
this header, even if the route retries on `gateway-error`. Outlier detection
157+
still counts the 503 responses.
158+
154159
### Throughput
155160

156161
Throughput limits use **token bucket** rate limiting with configurable burst.

‎objectstore-server/src/endpoints/common.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use thiserror::Error;
1313

1414
use crate::auth::AuthError;
1515
use crate::extractors::batch::BatchError;
16+
use crate::rate_limits::LimitExceeded;
1617

1718
/// A JSON error response returned by the API.
1819
#[derive(Serialize, Deserialize, Debug)]
@@ -151,7 +152,7 @@ impl ApiError {
151152
ServiceErrorKind::RangeNotSatisfiable { .. } => StatusCode::RANGE_NOT_SATISFIABLE,
152153
ServiceErrorKind::UploadOffsetMismatch { .. } => StatusCode::CONFLICT,
153154
ServiceErrorKind::UploadSessionGone => StatusCode::GONE,
154-
ServiceErrorKind::AtCapacity => StatusCode::SERVICE_UNAVAILABLE,
155+
ServiceErrorKind::AtCapacity => LimitExceeded::Capacity.status(),
155156
ServiceErrorKind::Unsupported => StatusCode::NOT_IMPLEMENTED,
156157
ServiceErrorKind::BackendRateLimited => StatusCode::TOO_MANY_REQUESTS,
157158
ServiceErrorKind::BackendTimeout | ServiceErrorKind::BackendUnavailable => {
@@ -189,6 +190,12 @@ impl ApiError {
189190

190191
impl IntoResponse for ApiError {
191192
fn into_response(self) -> Response {
193+
if let ApiError::Service(e) = &self
194+
&& e.kind() == ServiceErrorKind::AtCapacity
195+
{
196+
return LimitExceeded::Capacity.into_response();
197+
}
198+
192199
self.capture();
193200
let body = ApiErrorResponse::from_error(&self);
194201
(self.status(), Json(body)).into_response()

‎objectstore-server/src/extractors/id.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,7 @@ impl IntoResponse for ObjectRejection {
3030
"Object access is disabled for this scope through killswitches",
3131
)
3232
.into_response(),
33-
ObjectRejection::RateLimited(error) => {
34-
(error.status(), error.to_string()).into_response()
35-
}
33+
ObjectRejection::RateLimited(error) => error.into_response(),
3634
}
3735
}
3836
}

‎objectstore-server/src/rate_limits.rs‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,30 @@ use std::sync::atomic::{AtomicU64, Ordering};
1919
use std::task::{Context, Poll};
2020
use std::time::{Duration, Instant};
2121

22+
use axum::response::{IntoResponse, Response};
2223
use bytes::Bytes;
2324
use futures_util::Stream;
24-
use http::StatusCode;
25+
use http::{HeaderValue, StatusCode};
2526
use objectstore_service::id::ObjectContext;
2627
use objectstore_types::scope::Scopes;
2728
use objectstore_types::usecase::Usecase;
2829
use serde::{Deserialize, Serialize};
2930
use thiserror::Error;
3031

32+
/// Response header that prevents Envoy from retrying a response.
33+
///
34+
/// Envoy does not retry a response with this header, even if the route retries on
35+
/// `gateway-error` or `5xx`. Only the `envoy-ratelimited` retry condition retries it. Outlier
36+
/// detection still counts the response. See the [Envoy router
37+
/// documentation](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/router_filter#x-envoy-ratelimited).
38+
pub const HEADER_ENVOY_RATELIMITED: &str = "x-envoy-ratelimited";
39+
3140
/// Classifies a rejected request as capacity protection or an exceeded traffic allowance.
41+
///
42+
/// This is the single conversion for rate limit and capacity rejections into HTTP responses.
43+
/// Its [`IntoResponse`] implementation sets the status from [`status`](Self::status) and marks
44+
/// the response with [`HEADER_ENVOY_RATELIMITED`], so that Envoy does not retry a rejection and
45+
/// add load to an instance that is already at capacity.
3246
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
3347
pub enum LimitExceeded {
3448
/// A general capacity protection limit was exceeded.
@@ -49,6 +63,16 @@ impl LimitExceeded {
4963
}
5064
}
5165

66+
impl IntoResponse for LimitExceeded {
67+
fn into_response(self) -> Response {
68+
let mut response = (self.status(), self.to_string()).into_response();
69+
response
70+
.headers_mut()
71+
.insert(HEADER_ENVOY_RATELIMITED, HeaderValue::from_static("true"));
72+
response
73+
}
74+
}
75+
5276
/// Identifies which rate limit triggered a rejection.
5377
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5478
enum RateLimitRejection {

‎objectstore-server/src/web/middleware.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use tower_http::set_header::SetResponseHeaderLayer;
1111
use crate::endpoints;
1212
use crate::extractors::downstream_service::DownstreamService;
1313
use crate::extractors::request_time::RequestTime;
14+
use crate::rate_limits::LimitExceeded;
1415
use crate::web::RequestCounter;
1516
use crate::web::metrics_body::{EmitMetricsGuard, MetricsBody};
1617
use crate::web::sentry_body::SentryBody;
@@ -41,7 +42,7 @@ pub async fn limit_web_concurrency(
4142
let service = request.extract_parts::<DownstreamService>().await.unwrap();
4243
objectstore_metrics::count!("web.concurrency.rejected", service = service.to_string());
4344
objectstore_log::warn!("Request rejected: web concurrency limit reached");
44-
return StatusCode::SERVICE_UNAVAILABLE.into_response();
45+
return LimitExceeded::Capacity.into_response();
4546
}
4647

4748
next.run(request).await

‎objectstore-server/tests/limits.rs‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ use objectstore_server::rate_limits::{
1515
use objectstore_server::usecases::{ExpirationConfig, PolicyConfig, UseCaseConfig, UseCases};
1616
use objectstore_test::server::TestServer;
1717

18+
/// Asserts that a rejection tells Envoy not to retry it.
19+
fn assert_no_retry(response: &reqwest::Response) {
20+
assert_eq!(
21+
response
22+
.headers()
23+
.get(objectstore_server::rate_limits::HEADER_ENVOY_RATELIMITED)
24+
.expect("rejection must prevent Envoy retries"),
25+
"true"
26+
);
27+
}
28+
1829
#[tokio::test]
1930
async fn test_web_concurrency_limit() -> Result<()> {
2031
// Setting max_requests = 0 means every non-exempt request is rejected immediately
@@ -37,6 +48,8 @@ async fn test_web_concurrency_limit() -> Result<()> {
3748
reqwest::StatusCode::SERVICE_UNAVAILABLE,
3849
"expected 503 for regular request at limit"
3950
);
51+
assert_no_retry(&response);
52+
assert_eq!(response.text().await?, "service at capacity");
4053

4154
// Health endpoints need no auth and bypass the concurrency limit.
4255
for path in ["/health", "/ready"] {
@@ -52,6 +65,33 @@ async fn test_web_concurrency_limit() -> Result<()> {
5265
Ok(())
5366
}
5467

68+
#[tokio::test]
69+
async fn test_service_concurrency_limit() -> Result<()> {
70+
// With max_concurrency = 0, every operation fails to acquire a service permit.
71+
let server = TestServer::with_config(Config {
72+
service: Service {
73+
max_concurrency: 0,
74+
..Default::default()
75+
},
76+
auth: AuthZ {
77+
enforce: false,
78+
..Default::default()
79+
},
80+
..Default::default()
81+
})
82+
.await;
83+
84+
let response = reqwest::Client::new()
85+
.get(server.url("/v1/objects/test/org=1/key"))
86+
.send()
87+
.await?;
88+
assert_eq!(response.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE);
89+
assert_no_retry(&response);
90+
assert_eq!(response.text().await?, "service at capacity");
91+
92+
Ok(())
93+
}
94+
5595
#[tokio::test]
5696
async fn test_killswitches() -> Result<()> {
5797
let server = TestServer::with_config(Config {
@@ -134,6 +174,7 @@ async fn test_throughput_global_rps_limit() -> Result<()> {
134174
.send()
135175
.await?;
136176
assert_eq!(response.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE);
177+
assert_no_retry(&response);
137178
assert_eq!(response.text().await?, "service at capacity");
138179

139180
// Refill bucket
@@ -305,6 +346,7 @@ async fn test_throughput_rule() -> Result<()> {
305346
.send()
306347
.await?;
307348
assert_eq!(response.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
349+
assert_no_retry(&response);
308350
assert_eq!(response.text().await?, "rate limit exceeded");
309351

310352
// Different usecase should not be affected by rule

0 commit comments

Comments
 (0)