Skip to content

Commit dc773dc

Browse files
committed
wip
1 parent 0773368 commit dc773dc

20 files changed

Lines changed: 656 additions & 490 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

objectstore-server/src/endpoints/common.rs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use axum::http::StatusCode;
77
use axum::response::{IntoResponse, Response};
88
use http::HeaderValue;
99
use objectstore_service::error::Error as ServiceError;
10+
use objectstore_service::error::ErrorKind as ServiceErrorKind;
1011
use serde::{Deserialize, Serialize};
1112
use thiserror::Error;
1213

@@ -92,18 +93,21 @@ impl ApiError {
9293
StatusCode::INTERNAL_SERVER_ERROR
9394
}
9495

95-
ApiError::Service(ServiceError::Client(_)) => StatusCode::BAD_REQUEST,
96-
ApiError::Service(ServiceError::Metadata(_)) => StatusCode::BAD_REQUEST,
97-
ApiError::Service(ServiceError::RangeNotSatisfiable { .. }) => {
98-
StatusCode::RANGE_NOT_SATISFIABLE
99-
}
100-
ApiError::Service(ServiceError::InvalidUploadId(_)) => StatusCode::BAD_REQUEST,
101-
ApiError::Service(ServiceError::AtCapacity) => StatusCode::TOO_MANY_REQUESTS,
102-
ApiError::Service(ServiceError::NotImplemented) => StatusCode::NOT_IMPLEMENTED,
103-
ApiError::Service(_) => {
104-
objectstore_log::error!(!!self, "error handling request");
105-
StatusCode::INTERNAL_SERVER_ERROR
106-
}
96+
ApiError::Service(error) => match error.kind() {
97+
ServiceErrorKind::ClientStream | ServiceErrorKind::InvalidInput => {
98+
StatusCode::BAD_REQUEST
99+
}
100+
ServiceErrorKind::RangeNotSatisfiable => StatusCode::RANGE_NOT_SATISFIABLE,
101+
ServiceErrorKind::BackendRateLimited => StatusCode::TOO_MANY_REQUESTS,
102+
ServiceErrorKind::AtCapacity
103+
| ServiceErrorKind::BackendTimeout
104+
| ServiceErrorKind::BackendUnavailable => StatusCode::SERVICE_UNAVAILABLE,
105+
ServiceErrorKind::NotImplemented => StatusCode::NOT_IMPLEMENTED,
106+
ServiceErrorKind::Internal => {
107+
objectstore_log::error!(!!self, "error handling request");
108+
StatusCode::INTERNAL_SERVER_ERROR
109+
}
110+
},
107111

108112
ApiError::Internal(_) => {
109113
objectstore_log::error!(!!self, "internal error");

objectstore-server/src/endpoints/objects.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ use axum::http::{HeaderMap, StatusCode};
44
use axum::response::{IntoResponse, Response};
55
use axum::routing;
66
use axum::{Json, Router};
7-
use objectstore_service::error::Error as ServiceError;
7+
use objectstore_service::error::{
8+
Error as ServiceError, ErrorKind as ServiceErrorKind, RangeNotSatisfiableError,
9+
};
810
use objectstore_service::id::{ObjectContext, ObjectId};
911
use objectstore_types::metadata::Metadata;
1012
use objectstore_types::range::ContentRange;
@@ -72,7 +74,11 @@ async fn object_get(
7274
let (metadata, content_range, stream) = match result {
7375
Ok(Some(result)) => result,
7476
Ok(None) => return Ok(StatusCode::NOT_FOUND.into_response()),
75-
Err(ApiError::Service(ServiceError::RangeNotSatisfiable { total })) => {
77+
Err(ApiError::Service(ref e)) if e.kind() == ServiceErrorKind::RangeNotSatisfiable => {
78+
let total = e
79+
.source()
80+
.and_then(|s| s.downcast_ref::<RangeNotSatisfiableError>())
81+
.map_or(0, |r| r.total);
7682
let mut response = (
7783
StatusCode::RANGE_NOT_SATISFIABLE,
7884
[(

objectstore-server/tests/limits.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -534,9 +534,10 @@ async fn test_bandwidth_scope_pct_limit() -> Result<()> {
534534
}
535535

536536
#[tokio::test]
537-
async fn test_batch_at_capacity_returns_429() -> Result<()> {
537+
async fn test_batch_at_capacity_returns_503() -> Result<()> {
538538
// With max_concurrency=0 the service has no permits available, so
539-
// BatchExecutor::new() returns AtCapacity and the endpoint responds 429.
539+
// BatchExecutor::new() returns AtCapacity and the endpoint responds 503:
540+
// local load-shedding is surfaced as a temporary Service Unavailable.
540541
let server = TestServer::with_config(Config {
541542
service: Service { max_concurrency: 0 },
542543
auth: AuthZ {
@@ -566,8 +567,8 @@ async fn test_batch_at_capacity_returns_429() -> Result<()> {
566567

567568
assert_eq!(
568569
response.status(),
569-
reqwest::StatusCode::TOO_MANY_REQUESTS,
570-
"expected 429 when service has no available permits"
570+
reqwest::StatusCode::SERVICE_UNAVAILABLE,
571+
"expected 503 when service has no available permits"
571572
);
572573

573574
Ok(())

objectstore-service/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ tokio = { workspace = true }
3434
tokio-util = { workspace = true, features = ["io", "rt"] }
3535
tonic = { workspace = true }
3636
tracing = { workspace = true }
37+
url = { workspace = true }
3738
uuid = { workspace = true, features = ["v7"] }
3839

3940
[dev-dependencies]

objectstore-service/docs/architecture.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ A semaphore caps the total number of in-flight backend operations across all
185185
callers. A permit is acquired before each operation is spawned and held until
186186
the task completes — including on panic — so the limit counts *running*
187187
operations, not queued ones. When no permits are available, the operation fails
188-
with [`Error::AtCapacity`](error::Error::AtCapacity).
188+
with an [`ErrorKind::AtCapacity`](error::ErrorKind::AtCapacity) error.
189189

190190
The default limit is [`DEFAULT_CONCURRENCY_LIMIT`](service::DEFAULT_CONCURRENCY_LIMIT). Callers can override it via
191191
[`StorageService::with_concurrency_limit`].
@@ -211,3 +211,30 @@ reservation, lazy pulling, memory bounds, and concurrency model.
211211

212212
More backpressure mechanisms (e.g. per-backend limits, adaptive throttling) may
213213
be added here in the future.
214+
215+
# Error Model
216+
217+
Service and backend failures use a single [`Error`](error::Error) type shaped
218+
like `anyhow::Error`: it carries an [`ErrorKind`](error::ErrorKind) (the
219+
*classification* of the failure), an optional boxed `source` error, and an
220+
optional `context` message. The [`ErrorKind`](error::ErrorKind) is deliberately
221+
independent of HTTP semantics — the server maps each kind onto a status code
222+
(see the server architecture docs), and [`Error::level`](error::Error::level)
223+
maps each kind onto a log level.
224+
225+
Construction follows two paths:
226+
227+
- **Default (`?`)**: a foreign error converts through one of the `From` impls
228+
into an [`ErrorKind::Internal`](error::ErrorKind::Internal) error that keeps
229+
the original as its `source`.
230+
- **Override**: the [`ResultExt`](error::ResultExt) extension trait's `.kind(…)`
231+
and `.context(…)` methods reclassify and annotate without a `map_err`.
232+
Chaining `.kind(k).context(c)` boxes the original error as `source` exactly
233+
once — an already-converted `Error` passes through unchanged.
234+
235+
Backends classify transport and HTTP failures into the `Backend*` kinds in
236+
[`check_error`](backend), so retryability is a simple check on
237+
[`kind`](error::Error::kind). The object's total size for a
238+
[`RangeNotSatisfiable`](error::ErrorKind::RangeNotSatisfiable) response is
239+
carried as a downcastable [`RangeNotSatisfiableError`](error::RangeNotSatisfiableError)
240+
`source` so the server can emit the `Content-Range` header.

objectstore-service/src/backend/bigtable.rs

Lines changed: 44 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ use crate::backend::common::{
4747
Backend, DeleteResponse, GetResponse, HighVolumeBackend, MetadataResponse, PutResponse,
4848
TieredGet, TieredMetadata, TieredWrite, Tombstone,
4949
};
50-
use crate::error::{Error, Result};
50+
use crate::error::{Error, ErrorKind, RangeNotSatisfiableError, Result, ResultExt};
5151
use crate::gcp_auth::PrefetchingTokenProvider;
5252
use crate::id::ObjectId;
5353
use crate::stream::{ChunkedBytes, ClientStream};
@@ -466,8 +466,7 @@ fn object_mutations(mut metadata: Metadata, payload: Vec<u8>) -> Result<[v2::Mut
466466
// Record the payload size in the metadata before persisting it.
467467
metadata.size = Some(payload.len());
468468

469-
let metadata_bytes = serde_json::to_vec(&metadata)
470-
.map_err(|cause| Error::serde("failed to serialize metadata", cause))?;
469+
let metadata_bytes = serde_json::to_vec(&metadata).context("failed to serialize metadata")?;
471470

472471
Ok([
473472
// NB: We explicitly delete the row to clear metadata on overwrite.
@@ -528,8 +527,7 @@ fn tombstone_mutations(tombstone: &Tombstone, now: SystemTime) -> Result<[v2::Mu
528527
family_name: family.to_owned(),
529528
column_qualifier: COLUMN_TOMBSTONE_META.to_owned(),
530529
timestamp_micros,
531-
value: serde_json::to_vec(&tombstone_meta)
532-
.map_err(|cause| Error::serde("failed to serialize tombstone", cause))?,
530+
value: serde_json::to_vec(&tombstone_meta).context("failed to serialize tombstone")?,
533531
})),
534532
])
535533
}
@@ -601,10 +599,10 @@ impl RowData {
601599
payload = cell.value;
602600
}
603601
COLUMN_TOMBSTONE_META => {
604-
tombstone_meta_opt =
605-
Some(serde_json::from_slice(&cell.value).map_err(|cause| {
606-
Error::serde("failed to deserialize tombstone meta", cause)
607-
})?);
602+
tombstone_meta_opt = Some(
603+
serde_json::from_slice(&cell.value)
604+
.context("failed to deserialize tombstone meta")?,
605+
);
608606
}
609607
COLUMN_METADATA => {
610608
if let Ok(legacy_meta) =
@@ -617,10 +615,10 @@ impl RowData {
617615
expiration_policy: legacy_meta.expiration_policy,
618616
});
619617
} else {
620-
metadata_opt =
621-
Some(serde_json::from_slice(&cell.value).map_err(|cause| {
622-
Error::serde("failed to deserialize metadata", cause)
623-
})?);
618+
metadata_opt = Some(
619+
serde_json::from_slice(&cell.value)
620+
.context("failed to deserialize metadata")?,
621+
);
624622
}
625623
}
626624
_ => {}
@@ -683,10 +681,11 @@ fn parse_redirect_target(redirect_path: &[u8], tombstone_id: &ObjectId) -> Resul
683681
objectstore_metrics::count!("bigtable.empty_redirect_read");
684682
Ok(tombstone_id.clone())
685683
} else {
686-
let redirect_str = std::str::from_utf8(redirect_path)
687-
.map_err(|_| Error::generic("invalid UTF-8 in redirect path"))?;
684+
let redirect_str = std::str::from_utf8(redirect_path).map_err(|_| {
685+
Error::new(ErrorKind::Internal).context("invalid UTF-8 in redirect path")
686+
})?;
688687
ObjectId::from_storage_path(redirect_str)
689-
.ok_or_else(|| Error::generic("corrupt redirect path"))
688+
.ok_or_else(|| Error::new(ErrorKind::Internal).context("corrupt redirect path"))
690689
}
691690
}
692691

@@ -930,7 +929,9 @@ impl Backend for BigTableBackend {
930929
TieredGet::Object(metadata, content_range, payload) => {
931930
Ok(Some((metadata, content_range, payload)))
932931
}
933-
TieredGet::Tombstone(_) => Err(Error::UnexpectedTombstone),
932+
TieredGet::Tombstone(_) => {
933+
Err(Error::new(ErrorKind::Internal).context("unexpected tombstone"))
934+
}
934935
TieredGet::NotFound => Ok(None),
935936
}
936937
}
@@ -939,7 +940,9 @@ impl Backend for BigTableBackend {
939940
async fn get_metadata(&self, id: &ObjectId) -> Result<MetadataResponse> {
940941
match self.get_tiered_metadata(id).await? {
941942
TieredMetadata::Object(metadata) => Ok(Some(metadata)),
942-
TieredMetadata::Tombstone(_) => Err(Error::UnexpectedTombstone),
943+
TieredMetadata::Tombstone(_) => {
944+
Err(Error::new(ErrorKind::Internal).context("unexpected tombstone"))
945+
}
943946
TieredMetadata::NotFound => Ok(None),
944947
}
945948
}
@@ -1002,7 +1005,7 @@ impl HighVolumeBackend for BigTableBackend {
10021005
}
10031006
}
10041007

1005-
Err(Error::generic("BigTable: race loop in put_non_tombstone"))
1008+
Err(Error::new(ErrorKind::Internal).context("BigTable: race loop in put_non_tombstone"))
10061009
}
10071010

10081011
#[tracing::instrument(level = "debug", skip(self))]
@@ -1110,9 +1113,7 @@ impl HighVolumeBackend for BigTableBackend {
11101113
}
11111114
}
11121115

1113-
Err(Error::generic(
1114-
"BigTable: race loop in delete_non_tombstone",
1115-
))
1116+
Err(Error::new(ErrorKind::Internal).context("BigTable: race loop in delete_non_tombstone"))
11161117
}
11171118

11181119
#[tracing::instrument(level = "debug", skip(self, write))]
@@ -1151,13 +1152,12 @@ impl HighVolumeBackend for BigTableBackend {
11511152
/// required by BigTable, the resulting timestamp has millisecond precision, with the last digits at
11521153
/// 0.
11531154
fn ttl_to_micros(ttl: Duration, from: SystemTime) -> Result<i64> {
1154-
let deadline = from.checked_add(ttl).ok_or_else(|| Error::Generic {
1155-
context: format!(
1155+
let deadline = from.checked_add(ttl).ok_or_else(|| {
1156+
Error::new(ErrorKind::Internal).context(format!(
11561157
"TTL duration overflow: {} plus {}s cannot be represented as SystemTime",
11571158
humantime::format_rfc3339_seconds(from),
11581159
ttl.as_secs()
1159-
),
1160-
cause: None,
1160+
))
11611161
})?;
11621162

11631163
system_time_to_micros(deadline)
@@ -1170,19 +1170,15 @@ fn ttl_to_micros(ttl: Duration, from: SystemTime) -> Result<i64> {
11701170
fn system_time_to_micros(deadline: SystemTime) -> Result<i64> {
11711171
let millis = deadline
11721172
.duration_since(SystemTime::UNIX_EPOCH)
1173-
.map_err(|e| Error::Generic {
1174-
context: format!(
1175-
"unable to get duration since UNIX_EPOCH for SystemTime {}",
1176-
humantime::format_rfc3339_seconds(deadline)
1177-
),
1178-
cause: Some(Box::new(e)),
1179-
})?
1173+
.context(format!(
1174+
"unable to get duration since UNIX_EPOCH for SystemTime {}",
1175+
humantime::format_rfc3339_seconds(deadline)
1176+
))?
11801177
.as_millis();
11811178

1182-
(millis * 1000).try_into().map_err(|e| Error::Generic {
1183-
context: format!("failed to convert {millis}ms to i64 microseconds"),
1184-
cause: Some(Box::new(e)),
1185-
})
1179+
(millis * 1000)
1180+
.try_into()
1181+
.context(format!("failed to convert {millis}ms to i64 microseconds"))
11861182
}
11871183

11881184
/// Converts a wall-clock time to Bigtable's microsecond timestamp, saturating at `i64::MAX`
@@ -1233,10 +1229,7 @@ where
12331229
Ok(res) => return Ok(res),
12341230
Err(e) if retry_count >= REQUEST_RETRY_COUNT || !is_retryable(&e) => {
12351231
objectstore_metrics::count!("bigtable.failures", action = context);
1236-
return Err(Error::Generic {
1237-
context: format!("Bigtable: `{context}` failed"),
1238-
cause: Some(Box::new(e)),
1239-
});
1232+
return Err(Error::from(e).context(format!("Bigtable: `{context}` failed")));
12401233
}
12411234
Err(e) => {
12421235
retry_count += 1;
@@ -1290,7 +1283,7 @@ fn apply_range(payload: Bytes, range: Option<ByteRange>) -> Result<(Option<Conte
12901283
let total = payload.len() as u64;
12911284
let content_range = byte_range
12921285
.resolve(total)
1293-
.ok_or(Error::RangeNotSatisfiable { total })?;
1286+
.ok_or_else(|| Error::from(RangeNotSatisfiableError { total }))?;
12941287

12951288
let sliced = payload.slice(content_range.start as usize..content_range.end as usize + 1);
12961289
Ok((Some(content_range), sliced))
@@ -1865,11 +1858,11 @@ mod tests {
18651858
// Legacy reads must error rather than leak tombstone data.
18661859
assert!(matches!(
18671860
backend.get_object(&hv_id, None).await,
1868-
Err(Error::UnexpectedTombstone)
1861+
Err(e) if e.kind() == ErrorKind::Internal
18691862
));
18701863
assert!(matches!(
18711864
backend.get_metadata(&hv_id).await,
1872-
Err(Error::UnexpectedTombstone)
1865+
Err(e) if e.kind() == ErrorKind::Internal
18731866
));
18741867

18751868
// Idempotent retry: retry with the same target succeeds
@@ -2345,7 +2338,13 @@ mod tests {
23452338
let id = put_range_test_object(&backend).await?;
23462339

23472340
match backend.get_object(&id, Some(ByteRange::From(100))).await {
2348-
Err(Error::RangeNotSatisfiable { total }) => assert_eq!(total, 22),
2341+
Err(err) if err.kind() == ErrorKind::RangeNotSatisfiable => {
2342+
let total = err
2343+
.source()
2344+
.and_then(|s| s.downcast_ref::<crate::error::RangeNotSatisfiableError>())
2345+
.map(|e| e.total);
2346+
assert_eq!(total, Some(22));
2347+
}
23492348
Ok(_) => panic!("expected RangeNotSatisfiable, got Ok"),
23502349
Err(e) => panic!("expected RangeNotSatisfiable, got {e:?}"),
23512350
}

objectstore-service/src/backend/common.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use objectstore_types::range::{ByteRange, ContentRange};
77

88
use bytes::Bytes;
99

10-
use crate::error::{Error, Result};
10+
use crate::error::{Error, ErrorKind, Result};
1111
use crate::id::ObjectId;
1212
use crate::multipart::{
1313
AbortMultipartResponse, CompleteMultipartResponse, CompletedPart, InitiateMultipartResponse,
@@ -67,10 +67,10 @@ pub trait Backend: fmt::Debug + Send + Sync + 'static {
6767

6868
/// Borrows this backend as a [`MultipartUploadBackend`] if supported.
6969
///
70-
/// The default returns [`Error::NotImplemented`]. Backends that implement
71-
/// [`MultipartUploadBackend`] should override this to return `Ok(self)`.
70+
/// The default returns an [`ErrorKind::NotImplemented`] error. Backends that
71+
/// implement [`MultipartUploadBackend`] should override this to return `Ok(self)`.
7272
fn as_multipart_upload_backend(&self) -> Result<&dyn MultipartUploadBackend> {
73-
Err(Error::NotImplemented)
73+
Err(Error::new(ErrorKind::NotImplemented))
7474
}
7575
}
7676

0 commit comments

Comments
 (0)