refactor: labrinth ApiError and error reporting (#6981)

* refactor: labrinth `ApiError` and error reporting

* fix clippy

* fix ci
This commit is contained in:
aecsocket
2026-08-07 15:47:54 +00:00
committed by GitHub
parent d344cbcb7a
commit 3fa6905006
102 changed files with 6449 additions and 3791 deletions
+29 -10
View File
@@ -1,6 +1,8 @@
use crate::database::models::{DBUserId, users_compliance::FormType};
use crate::env::ENV;
use crate::routes::ApiError;
use crate::util::error::ApiContext as _;
use crate::util::error::Context as _;
use ariadne::ids::base62_impl::to_base62;
use chrono::Datelike;
use serde::{Deserialize, Serialize};
@@ -62,7 +64,8 @@ pub async fn request_form(
}
let (request_builder, company_id) =
team_request(reqwest::Method::POST, "/form_requests")?;
team_request(reqwest::Method::POST, "/form_requests")
.wrap_api_err("executing `team_request`")?;
let response = request_builder
.json(&DataWrapper {
@@ -84,12 +87,19 @@ pub async fn request_form(
},
})
.send()
.await?;
.await
.wrap_internal_err("deserializing HTTP response")?;
Ok(if response.status().is_success() {
Ok(response.json::<DataWrapper<FormResponse>>().await?)
Ok(response
.json::<DataWrapper<FormResponse>>()
.await
.wrap_internal_err("deserializing HTTP response")?)
} else {
Err(response.json().await?)
Err(response
.json()
.await
.wrap_internal_err("deserializing HTTP response")?)
})
}
@@ -104,12 +114,18 @@ pub async fn check_form(
&format!(
"/w9forms?filter[reference_id_eq]={reference_id}&page[number]=1&page[size]=1"
),
)?;
).wrap_api_err("executing `team_request`")?;
let response = request_builder.send().await?;
let response = request_builder
.send()
.await
.wrap_internal_err("sending HTTP request")?;
Ok(if response.status().is_success() {
let body = response.text().await?;
let body = response
.text()
.await
.wrap_internal_err("reading HTTP response body")?;
let serde_result =
serde_json::from_str::<ListWrapper<W9FormsResponse>>(&body);
@@ -118,13 +134,16 @@ pub async fn check_form(
Ok(list_wrapper.data.pop().map(|data| DataWrapper { data }))
}
Err(e) => {
return Err(ApiError::InvalidInput(format!(
return Err(ApiError::Request(eyre::eyre!(format!(
"Error parsing avalara1099 response: {e}. Actual response body: {body}"
)));
))));
}
}
} else {
Err(response.json().await?)
Err(response
.json()
.await
.wrap_internal_err("deserializing HTTP response")?)
})
}
+5 -3
View File
@@ -1,5 +1,6 @@
use crate::env::ENV;
use crate::routes::ApiError;
use crate::util::error::Context as _;
use actix_web::HttpRequest;
use serde::Deserialize;
use std::collections::HashMap;
@@ -26,7 +27,7 @@ pub async fn check_hcaptcha(
conn_info.peer_addr()
};
let ip_addr = ip_addr.ok_or(ApiError::Turnstile)?;
let ip_addr = ip_addr.wrap_request_err("captcha validation failed")?;
let client = reqwest::Client::new();
@@ -46,10 +47,11 @@ pub async fn check_hcaptcha(
.form(&form)
.send()
.await
.map_err(|_| ApiError::Turnstile)?
.wrap_request_err("captcha validation failed")?
.json()
.await
.map_err(|_| ApiError::Turnstile)?;
.map_err(|err| eyre::eyre!(err))
.wrap_request_err("captcha validation failed")?;
Ok(val.success)
}
+248
View File
@@ -5,6 +5,31 @@ use std::{
use crate::routes::ApiError;
/// Adds context to an [`ApiError`] while preserving its HTTP status variant.
pub trait ApiContext<T>: Sized {
/// Wraps the report held by the error variant with a lazily-created message.
fn wrap_api_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static;
/// Wraps the report held by the error variant with the given message.
fn wrap_api_err<D>(self, msg: D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_api_err_with(|| msg)
}
}
impl<T> ApiContext<T> for Result<T, ApiError> {
fn wrap_api_err_with<D>(self, f: impl FnOnce() -> D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.map_err(|error| error.wrap_err(f()))
}
}
/// Allows wrapping [`Result`]s and [`Option`]s into [`Result<T, ApiError>`]s.
#[allow(
clippy::missing_errors_doc,
@@ -85,6 +110,133 @@ pub trait Context<T, E>: Sized {
{
self.wrap_auth_err_with(|| msg)
}
/// Maps the error variant into an [`ApiError::NotFound`] using the closure to create the message.
#[inline]
fn wrap_not_found_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_err_with(f).map_err(ApiError::NotFound)
}
/// Maps the error variant into an [`ApiError::NotFound`] with the given message.
#[inline]
fn wrap_not_found_err<D>(self, msg: D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_not_found_err_with(|| msg)
}
/// Maps the error variant into an [`ApiError::Conflict`] using the closure to create the message.
#[inline]
fn wrap_conflict_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_err_with(f).map_err(ApiError::Conflict)
}
/// Maps the error variant into an [`ApiError::Conflict`] with the given message.
#[inline]
fn wrap_conflict_err<D>(self, msg: D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_conflict_err_with(|| msg)
}
/// Maps the error variant into an [`ApiError::FailedDependency`] using the closure to create the message.
#[inline]
fn wrap_failed_dependency_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_err_with(f).map_err(ApiError::FailedDependency)
}
/// Maps the error variant into an [`ApiError::FailedDependency`] with the given message.
#[inline]
fn wrap_failed_dependency_err<D>(self, msg: D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_failed_dependency_err_with(|| msg)
}
/// Maps the error variant into an [`ApiError::PreconditionRequired`] using the closure to create the message.
#[inline]
fn wrap_precondition_required_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_err_with(f)
.map_err(ApiError::PreconditionRequired)
}
/// Maps the error variant into an [`ApiError::PreconditionRequired`] with the given message.
#[inline]
fn wrap_precondition_required_err<D>(self, msg: D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_precondition_required_err_with(|| msg)
}
/// Maps the error variant into an [`ApiError::PreconditionFailed`] using the closure to create the message.
#[inline]
fn wrap_precondition_failed_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_err_with(f).map_err(ApiError::PreconditionFailed)
}
/// Maps the error variant into an [`ApiError::PreconditionFailed`] with the given message.
#[inline]
fn wrap_precondition_failed_err<D>(self, msg: D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_precondition_failed_err_with(|| msg)
}
/// Maps the error variant into an [`ApiError::RateLimit`] using the closure to create the message.
#[inline]
fn wrap_rate_limit_err_with<D>(
self,
f: impl FnOnce() -> D,
) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_err_with(f).map_err(ApiError::RateLimit)
}
/// Maps the error variant into an [`ApiError::RateLimit`] with the given message.
#[inline]
fn wrap_rate_limit_err<D>(self, msg: D) -> Result<T, ApiError>
where
D: Send + Sync + Debug + Display + 'static,
{
self.wrap_rate_limit_err_with(|| msg)
}
}
impl<T, E> Context<T, E> for Result<T, E>
@@ -146,6 +298,40 @@ mod tests {
let auth_error = ApiError::Auth(eyre::eyre!("auth error"));
assert_eq!(auth_error.status_code(), StatusCode::UNAUTHORIZED);
let not_found_error =
ApiError::NotFound(eyre::eyre!("not found error"));
assert_eq!(not_found_error.status_code(), StatusCode::NOT_FOUND);
let conflict_error = ApiError::Conflict(eyre::eyre!("conflict error"));
assert_eq!(conflict_error.status_code(), StatusCode::CONFLICT);
let dependency_error =
ApiError::FailedDependency(eyre::eyre!("dependency error"));
assert_eq!(
dependency_error.status_code(),
StatusCode::FAILED_DEPENDENCY
);
let required_error = ApiError::PreconditionRequired(eyre::eyre!(
"precondition required error"
));
assert_eq!(
required_error.status_code(),
StatusCode::PRECONDITION_REQUIRED
);
let failed_error = ApiError::PreconditionFailed(eyre::eyre!(
"precondition failed error"
));
assert_eq!(failed_error.status_code(), StatusCode::PRECONDITION_FAILED);
let rate_limit_error =
ApiError::RateLimit(eyre::eyre!("rate limit error"));
assert_eq!(
rate_limit_error.status_code(),
StatusCode::TOO_MANY_REQUESTS
);
}
#[test]
@@ -237,6 +423,68 @@ mod tests {
}
}
#[test]
fn test_context_trait_status_errors() {
let not_found: Option<i32> = None;
assert!(matches!(
not_found.wrap_not_found_err("missing value").unwrap_err(),
ApiError::NotFound(_)
));
let conflict: Option<i32> = None;
assert!(matches!(
conflict.wrap_conflict_err("conflicting value").unwrap_err(),
ApiError::Conflict(_)
));
let dependency: Option<i32> = None;
assert!(matches!(
dependency
.wrap_failed_dependency_err("dependency failed")
.unwrap_err(),
ApiError::FailedDependency(_)
));
let required: Option<i32> = None;
assert!(matches!(
required
.wrap_precondition_required_err("precondition required")
.unwrap_err(),
ApiError::PreconditionRequired(_)
));
let failed: Option<i32> = None;
assert!(matches!(
failed
.wrap_precondition_failed_err("precondition failed")
.unwrap_err(),
ApiError::PreconditionFailed(_)
));
let rate_limit: Option<i32> = None;
assert!(matches!(
rate_limit
.wrap_rate_limit_err("rate limit exceeded")
.unwrap_err(),
ApiError::RateLimit(_)
));
}
#[test]
fn test_api_context_preserves_status_variant() {
let result: Result<(), ApiError> =
Err(ApiError::NotFound(eyre::eyre!("missing value")));
let error = result.wrap_api_err("fetching test value").unwrap_err();
match error {
ApiError::NotFound(report) => {
assert_eq!(report.to_string(), "fetching test value");
assert!(format!("{report:#}").contains("missing value"));
}
_ => panic!("expected NotFound error"),
}
}
#[test]
fn test_context_trait_with_closure() {
let result: Result<i32, std::io::Error> = Err(std::io::Error::new(
+7 -4
View File
@@ -2,6 +2,7 @@ use crate::env::ENV;
use crate::models::ids::PayoutId;
use crate::routes::ApiError;
use crate::routes::internal::gotenberg::{GotenbergDocument, GotenbergError};
use crate::util::error::ApiContext as _;
use crate::util::error::Context;
use actix_web::http::header::HeaderName;
use chrono::{DateTime, Datelike, Utc};
@@ -166,7 +167,7 @@ impl GotenbergClient {
.await
.wrap_internal_err("failed to submit HTML to Gotenberg")?
.error_for_status()
.wrap_internal_err("Gotenberg returned an error status")?;
.wrap_internal_err("received an error status from Gotenberg")?;
Ok(())
}
@@ -190,7 +191,9 @@ impl GotenbergClient {
&self,
statement: &PaymentStatement,
) -> Result<GotenbergDocument, ApiError> {
self.generate_payment_statement(statement).await?;
self.generate_payment_statement(statement)
.await
.wrap_api_err("executing `generate_payment_statement`")?;
let timeout_ms = ENV.GOTENBERG_TIMEOUT;
let redis_timeout_ms =
@@ -205,7 +208,7 @@ impl GotenbergClient {
.brpop(&response_key, Duration::from_millis(redis_timeout_ms)),
)
.await
.wrap_internal_err("Gotenberg document generation timed out")?
.wrap_internal_err("timed out generating Gotenberg document")?
.wrap_internal_err("failed to get document over Redis")?
.wrap_internal_err("no document was returned from Redis")?;
@@ -213,7 +216,7 @@ impl GotenbergClient {
Result<GotenbergDocument, GotenbergError>,
>(&document)
.wrap_internal_err("failed to deserialize Redis document response")?
.wrap_internal_err("Gotenberg document generation failed")?;
.wrap_internal_err("failed to generate Gotenberg document")?;
Ok(document)
}
+27 -13
View File
@@ -4,6 +4,7 @@ use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::images::ImageContext;
use crate::routes::ApiError;
use crate::util::error::Context as _;
use color_thief::ColorFormat;
use hex::ToHex;
use image::imageops::FilterType;
@@ -54,10 +55,8 @@ pub async fn upload_image_optimized(
file_host: &dyn FileHost,
) -> Result<UploadImageResult, ApiError> {
let content_type = crate::util::ext::get_image_content_type(file_extension)
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Invalid format for image: {file_extension}"
))
.wrap_request_err_with(|| {
format!("invalid format for image: {file_extension}")
})?;
let cdn_url = &ENV.CDN_URL;
@@ -68,8 +67,10 @@ pub async fn upload_image_optimized(
content_type,
target_width,
min_aspect_ratio,
)?;
let color = get_color_from_img(&bytes)?;
)
.wrap_request_err("processing uploaded image")?;
let color = get_color_from_img(&bytes)
.wrap_request_err("extracting color from uploaded image")?;
// Only upload the processed image if it's smaller than the original
let processed_upload_data = if processed_image.len() < bytes.len() {
@@ -87,7 +88,8 @@ pub async fn upload_image_optimized(
publicity,
processed_image,
)
.await?,
.await
.wrap_internal_err("uploading file to file host")?,
)
} else {
None
@@ -100,7 +102,8 @@ pub async fn upload_image_optimized(
publicity,
bytes,
)
.await?;
.await
.wrap_internal_err("uploading file to file host")?;
let url = format!("{}/{}", cdn_url, upload_data.file_name);
Ok(UploadImageResult {
@@ -182,7 +185,10 @@ pub async fn delete_old_images(
let name = image_url.split(&cdn_url_start).nth(1);
if let Some(icon_path) = name {
file_host.delete_file(icon_path, publicity).await?;
file_host
.delete_file(icon_path, publicity)
.await
.wrap_internal_err("deleting file from file host")?;
}
}
@@ -190,7 +196,10 @@ pub async fn delete_old_images(
let name = raw_image_url.split(&cdn_url_start).nth(1);
if let Some(icon_path) = name {
file_host.delete_file(icon_path, publicity).await?;
file_host
.delete_file(icon_path, publicity)
.await
.wrap_internal_err("deleting file from file host")?;
}
}
@@ -208,7 +217,8 @@ pub async fn delete_unused_images(
) -> Result<(), ApiError> {
let uploaded_images =
database::models::DBImage::get_many_contexted(context, transaction)
.await?;
.await
.wrap_internal_err("fetching images from database")?;
for image in uploaded_images {
let mut should_delete = true;
@@ -220,8 +230,12 @@ pub async fn delete_unused_images(
}
if should_delete {
image_item::DBImage::remove(image.id, transaction, redis).await?;
image_item::DBImage::clear_cache(image.id, redis).await?;
image_item::DBImage::remove(image.id, transaction, redis)
.await
.wrap_internal_err("deleting image from database")?;
image_item::DBImage::clear_cache(image.id, redis)
.await
.wrap_internal_err("clearing cached data from Redis")?;
}
}
+7 -8
View File
@@ -178,10 +178,10 @@ pub async fn rate_limit_middleware(
Ok(service_response.map_into_left_body())
} else {
let mut response = ApiError::RateLimitError(
decision.retry_after_ms.unwrap_or(0) as u128,
decision.limit,
)
let retry_after_ms = decision.retry_after_ms.unwrap_or(0);
let mut response = ApiError::RateLimit(eyre::eyre!(
"rate limit exceeded; retry after {retry_after_ms} milliseconds"
))
.error_response();
// Add rate limit headers
@@ -220,10 +220,9 @@ pub async fn rate_limit_middleware(
Ok(req.into_response(response.map_into_right_body()))
}
} else {
let response = ApiError::CustomAuthentication(
"Unable to obtain user IP address!".to_string(),
)
.error_response();
let response =
ApiError::Auth(eyre::eyre!("Unable to obtain user IP address!",))
.error_response();
Ok(req.into_response(response.map_into_right_body()))
}
+18 -16
View File
@@ -1,6 +1,6 @@
use crate::routes::ApiError;
use crate::routes::v3::project_creation::CreateError;
use crate::util::validate::validation_errors_to_string;
use crate::util::error::Context as _;
use actix_multipart::Field;
use actix_web::web::Payload;
use bytes::BytesMut;
@@ -16,13 +16,13 @@ pub async fn read_limited_from_payload(
let mut bytes = BytesMut::new();
while let Some(item) = payload.next().await {
if bytes.len() >= cap {
return Err(ApiError::InvalidInput(String::from(err_msg)));
return Err(ApiError::Request(eyre::eyre!(String::from(err_msg))));
} else {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInput(
"Unable to parse bytes in payload sent!".to_string(),
)
})?);
bytes.extend_from_slice(
&item.map_err(|err| eyre::eyre!(err)).wrap_request_err(
"unable to parse bytes in payload sent!".to_string(),
)?,
);
}
}
Ok(bytes)
@@ -36,17 +36,19 @@ where
{
let mut bytes = BytesMut::new();
while let Some(item) = payload.next().await {
bytes.extend_from_slice(&item.map_err(|_| {
ApiError::InvalidInput(
"Unable to parse bytes in payload sent!".to_string(),
)
})?);
bytes.extend_from_slice(
&item.map_err(|err| eyre::eyre!(err)).wrap_request_err(
"unable to parse bytes in payload sent!".to_string(),
)?,
);
}
let parsed: T = serde_json::from_slice(&bytes)?;
parsed.validate().map_err(|err| {
ApiError::InvalidInput(validation_errors_to_string(err, None))
})?;
let parsed: T = serde_json::from_slice(&bytes)
.wrap_request_err("deserializing JSON data")?;
parsed
.validate()
.map_err(|err| eyre::eyre!(err))
.wrap_request_err("validating request")?;
Ok(parsed)
}
+28 -20
View File
@@ -1,6 +1,8 @@
use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
use crate::models::ids::ProjectId;
use crate::routes::ApiError;
use crate::util::error::ApiContext as _;
use crate::util::error::Context as _;
use crate::{database::PgPool, env::ENV};
use ariadne::ids::base62_impl::to_base62;
use chrono::{DateTime, Utc};
@@ -51,7 +53,8 @@ async fn get_webhook_metadata(
pool,
redis,
)
.await?;
.await
.wrap_api_err("fetching webhook project")?;
if let Some(mut project) = project {
let mut owner = None;
@@ -62,7 +65,7 @@ async fn get_webhook_metadata(
pool,
redis,
)
.await?;
.await.wrap_internal_err("fetching organization from database")?;
if let Some(organization) = organization {
owner = Some(WebhookAuthor {
@@ -81,7 +84,7 @@ async fn get_webhook_metadata(
pool,
redis,
)
.await?;
.await.wrap_internal_err("fetching team member from database")?;
if let Some(member) = team.into_iter().find(|x| x.is_owner) {
let user = crate::database::models::user_item::DBUser::get_id(
@@ -89,7 +92,8 @@ async fn get_webhook_metadata(
pool,
redis,
)
.await?;
.await
.wrap_internal_err("fetching user from database")?;
if let Some(user) = user {
owner = Some(WebhookAuthor {
@@ -106,7 +110,11 @@ async fn get_webhook_metadata(
};
let all_game_versions =
MinecraftGameVersion::list(None, None, pool, redis).await?;
MinecraftGameVersion::list(None, None, pool, redis)
.await
.wrap_internal_err(
"fetching minecraft game version from Redis",
)?;
let versions = project
.aggregate_version_fields
@@ -249,9 +257,7 @@ pub async fn send_slack_payout_source_alert_webhook(
}))
.send()
.await
.map_err(|_| {
ApiError::Slack("Error while sending projects webhook".to_string())
})?;
.map_err(|err| eyre::eyre!(err)).wrap_internal_err("error while sending projects webhook".to_string())?;
Ok(())
}
@@ -263,7 +269,9 @@ pub async fn send_slack_project_webhook(
webhook_url: &str,
message: Option<String>,
) -> Result<(), ApiError> {
let metadata = get_webhook_metadata(project_id, pool, redis).await?;
let metadata = get_webhook_metadata(project_id, pool, redis)
.await
.wrap_api_err("fetching webhook metadata")?;
if let Some(metadata) = metadata {
let mut blocks = vec![];
@@ -365,11 +373,10 @@ pub async fn send_slack_project_webhook(
}))
.send()
.await
.map_err(|_| {
ApiError::Slack(
"Error while sending projects webhook".to_string(),
)
})?;
.map_err(|err| eyre::eyre!(err))
.wrap_internal_err(
"error while sending projects webhook".to_string(),
)?;
}
Ok(())
@@ -434,7 +441,9 @@ pub async fn send_discord_webhook(
webhook_url: &str,
message: Option<String>,
) -> Result<(), ApiError> {
let metadata = get_webhook_metadata(project_id, pool, redis).await?;
let metadata = get_webhook_metadata(project_id, pool, redis)
.await
.wrap_api_err("fetching webhook metadata")?;
if let Some(project) = metadata {
let mut fields = vec![];
@@ -503,11 +512,10 @@ pub async fn send_discord_webhook(
})
.send()
.await
.map_err(|_| {
ApiError::Discord(
"Error while sending projects webhook".to_string(),
)
})?;
.map_err(|err| eyre::eyre!(err))
.wrap_failed_dependency_err(
"error while sending projects webhook".to_string(),
)?;
}
Ok(())