Skip to content
Open
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
88 changes: 88 additions & 0 deletions objectstore-server/examples/capture_service_error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! Sends a production-shaped service error to Sentry and waits for delivery.
//!
//! Configure this example with the same `OS__SENTRY__*` environment variables as the server. In
//! particular, `OS__SENTRY__DSN` must be set. An optional first argument selects the normal server
//! YAML configuration file.

use std::error::Error as _;
use std::net::TcpListener;
use std::path::Path;
use std::time::Duration;

use anyhow::ensure;
use objectstore_server::config::Config;
use objectstore_service::concurrency::spawn_metered;
use objectstore_service::error::{ErrorKind, ResultExt as _};

const FLUSH_TIMEOUT: Duration = Duration::from_secs(10);

fn main() -> anyhow::Result<()> {
let config_path = std::env::args_os().nth(1);
let config = Config::load(config_path.as_deref().map(Path::new))?;

rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| anyhow::anyhow!("failed to install rustls crypto provider"))?;

// Keep the guard alive until after the explicit flush below. This is the same Sentry
// initialization used by the production server, including release, sampling, logs, tags,
// environment, and server name.
let _sentry_guard = objectstore_server::observability::init_sentry(&config)
.ok_or_else(|| anyhow::anyhow!("OS__SENTRY__DSN must be configured"))?;

let runtime = tokio::runtime::Builder::new_multi_thread()
.thread_name("sentry-test-rt")
.enable_all()
.worker_threads(config.runtime.worker_threads)
.build()?;
let _runtime_guard = runtime.enter();

// This installs the same tracing-to-Sentry layer that reports service task failures in
// production.
objectstore_log::init(&config.logging);

let endpoint = unused_local_endpoint()?;
let error = runtime.block_on(async move {
let result: objectstore_service::error::Result<()> =
spawn_metered("sentry_test_backend_request", (), async move {
reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(2))
.build()
.context(ErrorKind::BackendFailure, "building the Sentry test client")?
.get(endpoint)
.send()
.await
.context(ErrorKind::BackendFailure, "sending the Sentry test request")?;
Ok(())
})
.await;

result.expect_err("request to a closed local port unexpectedly succeeded")
});

ensure!(error.kind() == ErrorKind::BackendFailure);
ensure!(
error.source().is_some(),
"service error lost its reqwest source"
);

let client = sentry::Hub::current()
.client()
.ok_or_else(|| anyhow::anyhow!("Sentry client was not initialized"))?;
ensure!(
client.flush(Some(FLUSH_TIMEOUT)),
"Sentry did not flush within {FLUSH_TIMEOUT:?}"
);

eprintln!("Sentry service-error event submitted and flushed");
Ok(())
}

/// Reserves and releases a loopback port so the HTTP request produces a real connection error.
fn unused_local_endpoint() -> anyhow::Result<String> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let address = listener.local_addr()?;
drop(listener);
Ok(format!("http://{address}/objectstore-sentry-test"))
}
25 changes: 15 additions & 10 deletions objectstore-server/src/endpoints/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use http::HeaderValue;
use objectstore_service::error::Error as ServiceError;
use objectstore_service::error::{Error as ServiceError, ErrorKind as ServiceErrorKind};
use serde::{Deserialize, Serialize};
use thiserror::Error;

Expand Down Expand Up @@ -90,15 +90,20 @@ impl ApiError {
ApiError::Auth(AuthError::NotPermitted) => StatusCode::FORBIDDEN,
ApiError::Auth(AuthError::InternalError(_)) => StatusCode::INTERNAL_SERVER_ERROR,

ApiError::Service(ServiceError::Client(_)) => StatusCode::BAD_REQUEST,
ApiError::Service(ServiceError::Metadata(_)) => StatusCode::BAD_REQUEST,
ApiError::Service(ServiceError::RangeNotSatisfiable { .. }) => {
StatusCode::RANGE_NOT_SATISFIABLE
}
ApiError::Service(ServiceError::InvalidUploadId(_)) => StatusCode::BAD_REQUEST,
ApiError::Service(ServiceError::AtCapacity) => StatusCode::TOO_MANY_REQUESTS,
ApiError::Service(ServiceError::NotImplemented) => StatusCode::NOT_IMPLEMENTED,
ApiError::Service(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Service(error) => match error.kind() {
ServiceErrorKind::InvalidMetadata
| ServiceErrorKind::InvalidUploadId
| ServiceErrorKind::ClientStream => StatusCode::BAD_REQUEST,
ServiceErrorKind::RangeNotSatisfiable { .. } => StatusCode::RANGE_NOT_SATISFIABLE,
ServiceErrorKind::AtCapacity => StatusCode::TOO_MANY_REQUESTS,
ServiceErrorKind::Unsupported => StatusCode::NOT_IMPLEMENTED,
ServiceErrorKind::BackendFailure
| ServiceErrorKind::BackendResponse(_)
| ServiceErrorKind::CorruptData
| ServiceErrorKind::Panic
| ServiceErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::INTERNAL_SERVER_ERROR,
},

ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
Expand Down
4 changes: 2 additions & 2 deletions objectstore-server/src/endpoints/multipart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ use bytes::Bytes;
use futures::StreamExt;
use http::HeaderValue;
use http::header;
use objectstore_service::error::Error as ServiceError;
use objectstore_service::id::{ObjectContext, ObjectId};
use objectstore_service::multipart::{CompletedPart, PartNumber, UploadId};
use objectstore_types::metadata::Metadata;
Expand Down Expand Up @@ -96,7 +95,8 @@ async fn initiate_inner(
headers: HeaderMap,
) -> ApiResult<Response> {
// TODO: Update time_created in `complete`, when we have a Service API to mutate metadata.
let metadata = Metadata::from_insert_headers(&headers, "").map_err(ServiceError::from)?;
let metadata = Metadata::from_insert_headers(&headers, "")
.map_err(|error| ApiError::Client(error.to_string()))?;

state
.config
Expand Down
43 changes: 26 additions & 17 deletions objectstore-server/src/endpoints/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing;
use axum::{Json, Router};
use objectstore_service::error::Error as ServiceError;
use objectstore_service::error::{ErrorKind, ResultExt as _};
use objectstore_service::id::{ObjectContext, ObjectId};
use objectstore_types::headers::ExtValue;
use objectstore_types::metadata::Metadata;
Expand Down Expand Up @@ -46,7 +46,8 @@ async fn objects_post(
headers: HeaderMap,
MeteredBody(body): MeteredBody,
) -> ApiResult<Response> {
let metadata = Metadata::from_insert_headers(&headers, "").map_err(ServiceError::from)?;
let metadata = Metadata::from_insert_headers(&headers, "")
.map_err(|error| ApiError::Client(error.to_string()))?;

state
.config
Expand Down Expand Up @@ -75,23 +76,28 @@ async fn object_get(
let (metadata, content_range, stream) = match result {
Ok(Some(result)) => result,
Ok(None) => return Ok(StatusCode::NOT_FOUND.into_response()),
Err(ApiError::Service(ServiceError::RangeNotSatisfiable { total })) => {
let mut response = (
StatusCode::RANGE_NOT_SATISFIABLE,
[(
http::header::CONTENT_RANGE,
ContentRange::unsatisfiable_total_to_header_value(total),
)],
)
.into_response();
insert_accept_ranges(&mut response);
return Ok(response);
}
Err(ApiError::Service(e)) => match e.kind() {
ErrorKind::RangeNotSatisfiable { total } => {
let mut response = (
StatusCode::RANGE_NOT_SATISFIABLE,
[(
http::header::CONTENT_RANGE,
ContentRange::unsatisfiable_total_to_header_value(total),
)],
)
.into_response();
insert_accept_ranges(&mut response);
return Ok(response);
}
_ => return Err(e.into()),
Comment on lines +79 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API error serialization exposes preserved backend diagnostics to callers

Service failures returned by API handlers are serialized with their complete error chain, allowing authenticated callers to receive backend operation context, HTTP status, backend-provided codes/messages, and transport diagnostics such as request URLs. Restrict client responses to stable semantic ErrorKind details and retain preserved sources for logging only.

Evidence
  • object_get calls service.get_object(id, byte_range).await and returns non-range ApiError::Service failures unchanged, while the request path supplies the object identifier used by the backend operation.
  • ApiError::IntoResponse and batch create_error_part call ApiErrorResponse::from_error, which walks error.source() and serializes every cause string into the public causes field.
  • ServiceError preserves its source chain; BackendResponseError formats the fixed backend operation context, HTTP status, and parsed backend code/message, while transport errors retain reqwest diagnostics.
  • ApiError::capture() only controls logging and does not remove or redact the source chain before serialization.

Identified by Warden · security-review · 6HA-968

},
Err(e) => return Err(e),
};

let stream = state.meter_stream(stream, &context);
let mut metadata_headers = metadata.to_headers("").map_err(ServiceError::from)?;
let mut metadata_headers = metadata
.to_headers("")
.context(ErrorKind::Internal, "encoding object response metadata")?;

let mut response = match content_range {
Some(ref content_range) => {
Expand Down Expand Up @@ -125,7 +131,9 @@ async fn object_head(service: AuthAwareService, Xt(id): Xt<ObjectId>) -> ApiResu
return Ok(StatusCode::NOT_FOUND.into_response());
};

let mut headers = metadata.to_headers("").map_err(ServiceError::from)?;
let mut headers = metadata
.to_headers("")
.context(ErrorKind::Internal, "encoding object response metadata")?;
insert_content_length(&mut headers, &metadata);

let mut response = (StatusCode::OK, headers).into_response();
Expand Down Expand Up @@ -202,7 +210,8 @@ async fn object_put(
headers: HeaderMap,
MeteredBody(body): MeteredBody,
) -> ApiResult<Response> {
let metadata = Metadata::from_insert_headers(&headers, "").map_err(ServiceError::from)?;
let metadata = Metadata::from_insert_headers(&headers, "")
.map_err(|error| ApiError::Client(error.to_string()))?;

let ObjectId { context, key } = id;

Expand Down
2 changes: 1 addition & 1 deletion objectstore-service/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ A concurrency limiter caps in-flight
backend operations. When all execution permits are held, new operations are
queued — adding latency instead of rejecting immediately. The queue itself is
bounded in both depth and time: operations that cannot be served within those
limits fail with [`Error::AtCapacity`](error::Error::AtCapacity).
limits fail with [`ErrorKind::AtCapacity`](error::ErrorKind::AtCapacity).

The default execution limit is
[`DEFAULT_CONCURRENCY_LIMIT`](service::DEFAULT_CONCURRENCY_LIMIT). See
Expand Down
Loading