Skip to content
Clone

perf(errors): box the API error envelope

AppResponseData<NoData> is 128 bytes, so every `Result<T, ApiError>` in the
workspace carried a 128-byte error variant on the happy path - and that
enum is returned by essentially every handler. The payload is now boxed in
the shared `api_error!` macro and in the hand-written git/ci/auth enums,
plus ConfigError::Figment (a figment error keeps ~200 bytes of provenance).
Constructors go through Self::build/envelope helpers so call sites do not
write Box::new.
ZhenYi committed
parent db52eb2 commit 3363a1d Browse files

6 files changed

+97 −67

File tree 6
Modified lib/auth/src/errors.rs View lib/auth/src/errors.rs
@@ -85,7 +85,7 @@
8585 match error {
8686 AuthError::BadRequest(msg) => Self::bad_request(msg),
8787 AuthError::InvalidCredentials => {
88 Self::InvalidCredentials(Self::payload(400, "invalid credentials"))
88 Self::build(400, Self::payload(400, "invalid credentials"))
8989 }
9090 AuthError::Unauthorized => Self::unauthorized(),
9191 AuthError::Forbidden => Self::forbidden(),
Modified lib/ci/src/errors.rs View lib/ci/src/errors.rs
@@ -82,52 +82,59 @@
8282#[derive(Debug, IntoResponses)]
8383pub enum ApiError {
8484 #[response(status = 400, description = "request failed validation")]
85 BadRequest(#[to_schema] AppResponseData<NoData>),
85 BadRequest(#[to_schema] Box<AppResponseData<NoData>>),
8686 #[response(status = 401, description = "authentication required")]
87 Unauthorized(#[to_schema] AppResponseData<NoData>),
87 Unauthorized(#[to_schema] Box<AppResponseData<NoData>>),
8888 #[response(status = 403, description = "operation not allowed")]
89 Forbidden(#[to_schema] AppResponseData<NoData>),
89 Forbidden(#[to_schema] Box<AppResponseData<NoData>>),
9090 #[response(status = 404, description = "resource not found")]
91 NotFound(#[to_schema] AppResponseData<NoData>),
91 NotFound(#[to_schema] Box<AppResponseData<NoData>>),
9292 #[response(status = 409, description = "resource conflict")]
93 Conflict(#[to_schema] AppResponseData<NoData>),
93 Conflict(#[to_schema] Box<AppResponseData<NoData>>),
9494 #[response(status = 500, description = "internal server error")]
95 Internal(#[to_schema] AppResponseData<NoData>),
95 Internal(#[to_schema] Box<AppResponseData<NoData>>),
9696}
9797
9898impl ApiError {
99 /// Boxes a plain envelope into the enum's payload. The box keeps
100 /// `Result<T, ApiError>` small: an inline envelope is 128 bytes, which every
101 /// handler return type would otherwise carry on the happy path too.
102 fn envelope(code: i32, message: impl Into<String>) -> Box<AppResponseData<NoData>> {
103 Box::new(AppResponseData::err(code, message))
104 }
105
99106 pub fn bad_request(message: impl Into<String>) -> Self {
100 Self::BadRequest(AppResponseData::err(400, message))
107 Self::BadRequest(Self::envelope(400, message))
101108 }
102109
103110 pub fn unauthorized() -> Self {
104 Self::Unauthorized(AppResponseData::err(401, "unauthorized"))
111 Self::Unauthorized(Self::envelope(401, "unauthorized"))
105112 }
106113
107114 pub fn forbidden() -> Self {
108 Self::Forbidden(AppResponseData::err(403, "forbidden"))
115 Self::Forbidden(Self::envelope(403, "forbidden"))
109116 }
110117
111118 pub fn not_found() -> Self {
112 Self::NotFound(AppResponseData::err(404, "not found"))
119 Self::NotFound(Self::envelope(404, "not found"))
113120 }
114121
115122 pub fn conflict(message: impl Into<String>) -> Self {
116 Self::Conflict(AppResponseData::err(409, message))
123 Self::Conflict(Self::envelope(409, message))
117124 }
118125
119126 pub fn internal() -> Self {
120 Self::Internal(AppResponseData::err(500, "internal server error"))
127 Self::Internal(Self::envelope(500, "internal server error"))
121128 }
122129
123130 fn decompose(self) -> (u16, AppResponseData<NoData>) {
124131 match self {
125 Self::BadRequest(envelope) => (400, envelope),
126 Self::Unauthorized(envelope) => (401, envelope),
127 Self::Forbidden(envelope) => (403, envelope),
128 Self::NotFound(envelope) => (404, envelope),
129 Self::Conflict(envelope) => (409, envelope),
130 Self::Internal(envelope) => (500, envelope),
132 Self::BadRequest(envelope) => (400, *envelope),
133 Self::Unauthorized(envelope) => (401, *envelope),
134 Self::Forbidden(envelope) => (403, *envelope),
135 Self::NotFound(envelope) => (404, *envelope),
136 Self::Conflict(envelope) => (409, *envelope),
137 Self::Internal(envelope) => (500, *envelope),
131138 }
132139 }
133140}
Modified lib/config/src/error.rs View lib/config/src/error.rs
@@ -6,7 +6,7 @@
66 DotEnv(#[from] dotenvy::Error),
77
88 #[error("config parse/merge error: {0}")]
9 Figment(#[from] figment::Error),
9 Figment(Box<figment::Error>),
1010
1111 #[error("config I/O error: {0}")]
1212 Io(#[from] std::io::Error),
@@ -14,3 +14,11 @@
1414 #[error("config validation failed: {0}")]
1515 Validation(String),
1616}
17impl From<figment::Error> for ConfigError {
18 fn from(error: figment::Error) -> Self {
19 // Boxed because a figment error carries ~200 bytes of provenance; an
20 // inline variant would make every `Result<_, ConfigError>` in the
21 // workspace pay for it on the success path too.
22 Self::Figment(Box::new(error))
23 }
24}
Modified lib/export/src/api_error.rs View lib/export/src/api_error.rs
@@ -134,7 +134,7 @@
134134 $vis enum $name {
135135 $(
136136 #[response(status = $status, description = $desc)]
137 $variant(#[to_schema] $crate::rest::AppResponseData<$crate::rest::NoData>),
137 $variant(#[to_schema] ::std::boxed::Box<$crate::rest::AppResponseData<$crate::rest::NoData>>),
138138 )*
139139 }
140140
@@ -147,10 +147,14 @@
147147 $crate::rest::AppResponseData::err(code, message)
148148 }
149149
150 /// Wraps a plain envelope into the enum's boxed payload. The box is
151 /// what keeps `Result<T, ApiError>` small: an unboxed envelope is
152 /// 128 bytes, which every handler return type would otherwise carry.
150153 pub fn build(
151154 status: u16,
152155 envelope: $crate::rest::AppResponseData<$crate::rest::NoData>,
153156 ) -> Self {
157 let envelope = ::std::boxed::Box::new(envelope);
154158 #[allow(unreachable_patterns)]
155159 match status {
156160 $( $status => Self::$variant(envelope), )*
@@ -241,7 +245,7 @@
241245 impl ::axum::response::IntoResponse for $name {
242246 fn into_response(self) -> ::axum::response::Response {
243247 let (status, envelope) = match self {
244 $( Self::$variant(envelope) => ($status, envelope), )*
248 $( Self::$variant(envelope) => ($status, *envelope), )*
245249 };
246250 let status = ::axum::http::StatusCode::from_u16(status)
247251 .unwrap_or(::axum::http::StatusCode::INTERNAL_SERVER_ERROR);
@@ -257,13 +261,14 @@
257261
258262 impl From<$crate::rest::AppResponseData<()>> for $name {
259263 fn from(_error: $crate::rest::AppResponseData<()>) -> Self {
260 Self::BadRequest(Self::payload(400, "bad request"))
264 Self::build(400, Self::payload(400, "bad request"))
261265 }
262266 }
263267
264268 impl From<::axum::extract::rejection::PathRejection> for $name {
265269 fn from(error: ::axum::extract::rejection::PathRejection) -> Self {
266 Self::BadRequest(
270 Self::build(
271 400,
267272 $crate::rest::AppResponseData::err(400, "invalid request path")
268273 .with_error(error.body_text()),
269274 )
@@ -272,7 +277,8 @@
272277
273278 impl From<::axum::extract::rejection::JsonRejection> for $name {
274279 fn from(error: ::axum::extract::rejection::JsonRejection) -> Self {
275 Self::BadRequest(
280 Self::build(
281 400,
276282 $crate::rest::AppResponseData::err(400, "invalid request body")
277283 .with_error(error.body_text())
278284 .with_error_code("invalid_request_body"),
@@ -282,7 +288,8 @@
282288
283289 impl From<::axum::extract::rejection::QueryRejection> for $name {
284290 fn from(error: ::axum::extract::rejection::QueryRejection) -> Self {
285 Self::BadRequest(
291 Self::build(
292 400,
286293 $crate::rest::AppResponseData::err(400, "invalid query parameters")
287294 .with_error(error.body_text()),
288295 )
@@ -291,7 +298,7 @@
291298
292299 impl From<::serde_json::Error> for $name {
293300 fn from(_error: ::serde_json::Error) -> Self {
294 Self::BadRequest(Self::payload(400, "bad request"))
301 Self::build(400, Self::payload(400, "bad request"))
295302 }
296303 }
297304
Modified lib/git/src/lfs/handle.rs View lib/git/src/lfs/handle.rs
@@ -345,27 +345,29 @@
345345}
346346
347347#[derive(Debug)]
348/// Boxed: `Response<Body>` is 128 bytes, which would otherwise ride along in
349/// every `Result` these LFS handlers return.
348350pub struct LfsHttpError {
349 response: Response,
351 response: Box<Response>,
350352}
351353
352354impl LfsHttpError {
353355 pub fn unauthorized() -> Self {
354356 Self {
355 response: challenge(),
357 response: Box::new(challenge()),
356358 }
357359 }
358360
359361 fn bad_request(message: &str) -> Self {
360362 Self {
361 response: status_json(StatusCode::BAD_REQUEST, message),
363 response: Box::new(status_json(StatusCode::BAD_REQUEST, message)),
362364 }
363365 }
364366}
365367
366368impl IntoResponse for LfsHttpError {
367369 fn into_response(self) -> Response {
368 self.response
370 *self.response
369371 }
370372}
371373
@@ -383,20 +385,22 @@
383385 };
384386 if status == StatusCode::UNAUTHORIZED {
385387 Self {
386 response: (
387 StatusCode::UNAUTHORIZED,
388 [(header::WWW_AUTHENTICATE, "Basic realm=\"gitdata\"")],
389 Json(LfsErrorBody {
390 message,
391 request_id: Uuid::now_v7().simple().to_string(),
392 documentation_url: DOCUMENTATION_URL,
393 }),
394 )
395 .into_response(),
388 response: Box::new(
389 (
390 StatusCode::UNAUTHORIZED,
391 [(header::WWW_AUTHENTICATE, "Basic realm=\"gitdata\"")],
392 Json(LfsErrorBody {
393 message,
394 request_id: Uuid::now_v7().simple().to_string(),
395 documentation_url: DOCUMENTATION_URL,
396 }),
397 )
398 .into_response(),
399 ),
396400 }
397401 } else {
398402 Self {
399 response: status_json(status, &message),
403 response: Box::new(status_json(status, &message)),
400404 }
401405 }
402406 }
Modified lib/git/src/errors.rs View lib/git/src/errors.rs
@@ -73,32 +73,35 @@
7373#[derive(Debug, IntoResponses)]
7474pub enum ApiError {
7575 #[response(status = 400, description = "request failed validation")]
76 BadRequest(#[to_schema] AppResponseData<NoData>),
76 BadRequest(#[to_schema] Box<AppResponseData<NoData>>),
7777 #[response(status = 401, description = "authentication required")]
78 Unauthorized(#[to_schema] AppResponseData<NoData>),
78 Unauthorized(#[to_schema] Box<AppResponseData<NoData>>),
7979 #[response(status = 403, description = "operation not allowed")]
80 Forbidden(#[to_schema] AppResponseData<NoData>),
80 Forbidden(#[to_schema] Box<AppResponseData<NoData>>),
8181 #[response(status = 404, description = "resource not found")]
82 NotFound(#[to_schema] AppResponseData<NoData>),
82 NotFound(#[to_schema] Box<AppResponseData<NoData>>),
8383 #[response(status = 409, description = "resource conflict")]
84 Conflict(#[to_schema] AppResponseData<NoData>),
84 Conflict(#[to_schema] Box<AppResponseData<NoData>>),
8585 #[response(status = 500, description = "internal server error")]
86 Internal(#[to_schema] AppResponseData<NoData>),
86 Internal(#[to_schema] Box<AppResponseData<NoData>>),
8787}
8888
8989impl ApiError {
90 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
91 AppResponseData::err(code, message)
90 /// Boxes a plain envelope into the enum's payload. The box keeps
91 /// `Result<T, ApiError>` small: an inline envelope is 128 bytes, which every
92 /// handler return type would otherwise carry on the happy path too.
93 fn payload(code: i32, message: &'static str) -> Box<AppResponseData<NoData>> {
94 Box::new(AppResponseData::err(code, message))
9295 }
9396
9497 fn decompose(self) -> (u16, AppResponseData<NoData>) {
9598 match self {
96 Self::BadRequest(envelope) => (400, envelope),
97 Self::Unauthorized(envelope) => (401, envelope),
98 Self::Forbidden(envelope) => (403, envelope),
99 Self::NotFound(envelope) => (404, envelope),
100 Self::Conflict(envelope) => (409, envelope),
101 Self::Internal(envelope) => (500, envelope),
99 Self::BadRequest(envelope) => (400, *envelope),
100 Self::Unauthorized(envelope) => (401, *envelope),
101 Self::Forbidden(envelope) => (403, *envelope),
102 Self::NotFound(envelope) => (404, *envelope),
103 Self::Conflict(envelope) => (409, *envelope),
104 Self::Internal(envelope) => (500, *envelope),
102105 }
103106 }
104107}
@@ -121,7 +124,7 @@
121124 }
122125
123126 pub fn conflict(message: impl Into<String>) -> Self {
124 Self::Conflict(AppResponseData::err(409, message))
127 Self::Conflict(Box::new(AppResponseData::err(409, message)))
125128 }
126129
127130 pub fn internal() -> Self {
@@ -132,8 +135,7 @@
132135impl IntoResponse for ApiError {
133136 fn into_response(self) -> Response {
134137 let (code, envelope) = self.decompose();
135 let status =
136 StatusCode::from_u16(code).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
138 let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
137139 (status, Json(envelope)).into_response()
138140 }
139141}
@@ -141,11 +143,13 @@
141143impl From<GitApiError> for ApiError {
142144 fn from(error: GitApiError) -> Self {
143145 match error {
144 GitApiError::BadRequest(msg) => Self::BadRequest(AppResponseData::err(400, msg)),
146 GitApiError::BadRequest(msg) => {
147 Self::BadRequest(Box::new(AppResponseData::err(400, msg)))
148 }
145149 GitApiError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized")),
146150 GitApiError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden")),
147151 GitApiError::NotFound => Self::NotFound(Self::payload(404, "not found")),
148 GitApiError::Conflict(msg) => Self::Conflict(AppResponseData::err(409, msg)),
152 GitApiError::Conflict(msg) => Self::Conflict(Box::new(AppResponseData::err(409, msg))),
149153 GitApiError::Internal(message) => {
150154 tracing::warn!(error = %message, "git handler failed");
151155 Self::Internal(Self::payload(500, "internal server error"))
@@ -156,26 +160,26 @@
156160
157161impl From<PathRejection> for ApiError {
158162 fn from(error: PathRejection) -> Self {
159 Self::BadRequest(
163 Self::BadRequest(Box::new(
160164 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
161 )
165 ))
162166 }
163167}
164168
165169impl From<JsonRejection> for ApiError {
166170 fn from(error: JsonRejection) -> Self {
167 Self::BadRequest(
171 Self::BadRequest(Box::new(
168172 AppResponseData::err(400, "invalid request body")
169173 .with_error(error.body_text())
170174 .with_error_code("invalid_request_body"),
171 )
175 ))
172176 }
173177}
174178
175179impl From<QueryRejection> for ApiError {
176180 fn from(error: QueryRejection) -> Self {
177 Self::BadRequest(
181 Self::BadRequest(Box::new(
178182 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
179 )
183 ))
180184 }
181185}