Skip to content

Commit c655b5b

Browse files
authored
feat: Initial support for HTTP range requests (#481)
Adds standard HTTP [range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests) ([RFC](https://www.rfc-editor.org/rfc/rfc9110.html#name-range-requests)) support for single-object GET operations. Spec vs implementation: - This implementation only supports byte ranges. The spec doesn't specifically talk about bytes, so in theory it admits ranges of other "types", which just don't make sense for us. - This implementation only supports [Single part ranges](https://developer.mozilla.org/enUS/docs/Web/HTTP/Guides/Range_requests#single_part_ranges), meaning stuff like `Range: bytes=0-1023`. The spec allows for [Multipart ranges](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests#multipart_ranges) too, i.e. fetching multiple ranges in a single request, which we don't support. We can add it in the future if needed. - We should probably implement [Conditional range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests#conditional_range_requests) in a follow-up PR, as they're pretty much essential to guarantee that clients read consistent data (if the object is swapped between range reads, we would otherwise serve chunks from 2 potentially different objects). Note that Symbolicator doesn't use these, so we should also update Symbolicator and any other user that uses range reads to actually use these to prevent random failures/data inconsistency. (maybe in practice this is fine as data that Symbolicator fetches cannot really change due to the way it's written, I don't know yet) **Looking for feedback on whether we think we need this now or not**. Implementation details: - All `get_object` paths now take `Option<ByteRange>` and return `ContentRange` alongside the payload. This seems to be the way to implement this with the least code duplication. Otherwise we would have to introduce a `get_object_range`, implement it for every backend and wrappers of the backends, etc. - Bigtable: we know that the objects there are small, so we just return the full payload. This behavior is compliant with the spec. - GCS/S3: `objectstore-service` reserializes the `Range` header and forwards it to the backend. On the response path, we proxy back the `content-range` provided by the backend. TODO: - Not supported for batch operations. - Not implemented in clients for now because Symbolicator (which would be the only user atm) doesn't use our client. We can easily implement it when needed. Close FS-363
1 parent 3de99d5 commit c655b5b

20 files changed

Lines changed: 1065 additions & 145 deletions

File tree

objectstore-server/src/auth/service.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use objectstore_service::service::{DeleteResponse, GetResponse, InsertResponse,
77
use objectstore_service::{ClientStream, StorageService};
88
use objectstore_types::auth::Permission;
99
use objectstore_types::metadata::Metadata;
10+
use objectstore_types::range::ByteRange;
1011

1112
use crate::auth::{AuthContext, AuthError};
1213
use crate::endpoints::common::ApiResult;
@@ -110,9 +111,13 @@ impl AuthAwareService {
110111
}
111112

112113
/// Auth-aware wrapper around [`StorageService::get_object`].
113-
pub async fn get_object(&self, id: ObjectId) -> ApiResult<GetResponse> {
114+
pub async fn get_object(
115+
&self,
116+
id: ObjectId,
117+
range: Option<ByteRange>,
118+
) -> ApiResult<GetResponse> {
114119
self.assert_authorized(Permission::ObjectRead, id.context())?;
115-
Ok(self.service.get_object(id).await?)
120+
Ok(self.service.get_object(id, range).await?)
116121
}
117122

118123
/// Auth-aware wrapper around [`StorageService::delete_object`].

objectstore-server/src/endpoints/batch.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ async fn convert_to_part(
147147
match result {
148148
Ok(OpResponse::Got {
149149
key,
150-
response: Some((metadata, stream)),
150+
response: Some((metadata, _content_range, stream)),
151151
}) => got_to_part(idx, key, metadata, stream, state, context)
152152
.await
153153
.unwrap_or_else(|e| create_error_part(idx, &e)),

objectstore-server/src/endpoints/common.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::error::Error;
55
use axum::Json;
66
use axum::http::StatusCode;
77
use axum::response::{IntoResponse, Response};
8+
use http::HeaderValue;
89
use objectstore_service::error::Error as ServiceError;
910
use serde::{Deserialize, Serialize};
1011
use thiserror::Error;
@@ -93,6 +94,9 @@ impl ApiError {
9394

9495
ApiError::Service(ServiceError::Client(_)) => StatusCode::BAD_REQUEST,
9596
ApiError::Service(ServiceError::Metadata(_)) => StatusCode::BAD_REQUEST,
97+
ApiError::Service(ServiceError::RangeNotSatisfiable { .. }) => {
98+
StatusCode::RANGE_NOT_SATISFIABLE
99+
}
96100
ApiError::Service(ServiceError::InvalidUploadId(_)) => StatusCode::BAD_REQUEST,
97101
ApiError::Service(ServiceError::AtCapacity) => StatusCode::TOO_MANY_REQUESTS,
98102
ApiError::Service(ServiceError::NotImplemented) => StatusCode::NOT_IMPLEMENTED,
@@ -115,3 +119,11 @@ impl IntoResponse for ApiError {
115119
(self.status(), Json(body)).into_response()
116120
}
117121
}
122+
123+
/// Inserts `Accept-Ranges: bytes` into the response headers.
124+
pub fn insert_accept_ranges(response: &mut Response) {
125+
response.headers_mut().insert(
126+
http::header::ACCEPT_RANGES,
127+
HeaderValue::from_static("bytes"),
128+
);
129+
}

objectstore-server/src/endpoints/objects.rs

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ use axum::{Json, Router};
99
use objectstore_service::error::Error as ServiceError;
1010
use objectstore_service::id::{ObjectContext, ObjectId};
1111
use objectstore_types::metadata::Metadata;
12+
use objectstore_types::range::ContentRange;
1213
use serde::Serialize;
1314

1415
use crate::auth::AuthAwareService;
15-
use crate::endpoints::common::{ApiError, ApiResult};
16+
use crate::endpoints::common::{ApiError, ApiResult, insert_accept_ranges};
17+
use crate::extractors::byte_range::OptionalByteRange;
1618
use crate::extractors::{Xt, body::MeteredBody};
1719
use crate::state::ServiceState;
1820

@@ -64,15 +66,55 @@ async fn object_get(
6466
service: AuthAwareService,
6567
State(state): State<ServiceState>,
6668
Xt(id): Xt<ObjectId>,
69+
OptionalByteRange(byte_range): OptionalByteRange,
70+
_headers: HeaderMap,
6771
) -> ApiResult<Response> {
6872
let context = id.context().clone();
69-
let Some((metadata, stream)) = service.get_object(id).await? else {
70-
return Ok(StatusCode::NOT_FOUND.into_response());
73+
let result = service.get_object(id, byte_range).await;
74+
75+
let (metadata, content_range, stream) = match result {
76+
Ok(Some(result)) => result,
77+
Ok(None) => return Ok(StatusCode::NOT_FOUND.into_response()),
78+
Err(ApiError::Service(ServiceError::RangeNotSatisfiable { total })) => {
79+
let mut response = (
80+
StatusCode::RANGE_NOT_SATISFIABLE,
81+
[(
82+
http::header::CONTENT_RANGE,
83+
ContentRange::unsatisfiable_total_to_header_value(total),
84+
)],
85+
)
86+
.into_response();
87+
insert_accept_ranges(&mut response);
88+
return Ok(response);
89+
}
90+
Err(e) => return Err(e),
7191
};
92+
7293
let stream = state.meter_stream(stream, &context);
94+
let metadata_headers = metadata.to_headers("").map_err(ServiceError::from)?;
95+
96+
let mut response = match content_range {
97+
Some(ref content_range) => {
98+
let mut resp = (
99+
StatusCode::PARTIAL_CONTENT,
100+
metadata_headers,
101+
Body::from_stream(stream),
102+
)
103+
.into_response();
104+
let headers = resp.headers_mut();
105+
headers.insert(
106+
http::header::CONTENT_LENGTH,
107+
content_range.len_to_header_value(),
108+
);
109+
headers.insert(http::header::CONTENT_RANGE, content_range.to_header_value());
110+
resp
111+
}
112+
None => (StatusCode::OK, metadata_headers, Body::from_stream(stream)).into_response(),
113+
};
73114

74-
let headers = metadata.to_headers("").map_err(ServiceError::from)?;
75-
Ok((headers, Body::from_stream(stream)).into_response())
115+
insert_accept_ranges(&mut response);
116+
117+
Ok(response)
76118
}
77119

78120
async fn object_head(service: AuthAwareService, Xt(id): Xt<ObjectId>) -> ApiResult<Response> {
@@ -82,7 +124,9 @@ async fn object_head(service: AuthAwareService, Xt(id): Xt<ObjectId>) -> ApiResu
82124

83125
let headers = metadata.to_headers("").map_err(ServiceError::from)?;
84126

85-
Ok((StatusCode::NO_CONTENT, headers).into_response())
127+
let mut response = (StatusCode::NO_CONTENT, headers).into_response();
128+
insert_accept_ranges(&mut response);
129+
Ok(response)
86130
}
87131

88132
async fn object_put(
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
//! Axum extractor for range requests.
2+
3+
use axum::extract::FromRequestParts;
4+
use http::request::Parts;
5+
use objectstore_types::range::{ByteRange, RangeError};
6+
7+
use crate::{endpoints::common::ApiError, state::ServiceState};
8+
9+
/// Extractor that parses the `Range` request header into an optional [`ByteRange`].
10+
#[derive(Debug, Clone)]
11+
pub struct OptionalByteRange(pub Option<ByteRange>);
12+
13+
impl FromRequestParts<ServiceState> for OptionalByteRange {
14+
type Rejection = ApiError;
15+
16+
async fn from_request_parts(
17+
parts: &mut Parts,
18+
_state: &ServiceState,
19+
) -> Result<Self, Self::Rejection> {
20+
let headers = &parts.headers;
21+
let Some(range) = headers.get(http::header::RANGE) else {
22+
return Ok(Self(None));
23+
};
24+
let range = range
25+
.to_str()
26+
.map_err(|_| ApiError::Client("invalid Range header".into()))?;
27+
28+
match range.parse::<ByteRange>() {
29+
Ok(range) => Ok(Self(Some(range))),
30+
// Per RFC 9110:
31+
// > A server that supports range requests MAY ignore or reject a Range header
32+
// field that contains an invalid ranges-specifier [...]
33+
//
34+
// If the client wants multiple ranges, fall back to returning the whole object.
35+
// We might support multiple ranges in the future, so log a warning to let us know
36+
// clients are trying to do this.
37+
Err(RangeError::MultiRange) => {
38+
objectstore_log::warn!(
39+
"received range request with multiple range specifiers, ignoring"
40+
);
41+
Ok(Self(None))
42+
}
43+
// The client requested an invalid unit or sent a malformed header.
44+
// We could fall back, but better fail hard and let them know they sent something
45+
// invalid.
46+
Err(err) => Err(ApiError::Client(format!("invalid Range header: {err}"))),
47+
}
48+
}
49+
}

objectstore-server/src/extractors/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
pub mod batch;
44
pub mod body;
5+
pub mod byte_range;
56
pub mod downstream_service;
67
mod id;
78
mod service;

0 commit comments

Comments
 (0)