Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(api): always give xrpc error messages

+196 -129
+20 -20
Cargo.lock
··· 6094 6094 6095 6095 [[package]] 6096 6096 name = "tranquil-api" 6097 - version = "0.4.3" 6097 + version = "0.4.4" 6098 6098 dependencies = [ 6099 6099 "anyhow", 6100 6100 "axum", ··· 6142 6142 6143 6143 [[package]] 6144 6144 name = "tranquil-auth" 6145 - version = "0.4.3" 6145 + version = "0.4.4" 6146 6146 dependencies = [ 6147 6147 "anyhow", 6148 6148 "base32", ··· 6165 6165 6166 6166 [[package]] 6167 6167 name = "tranquil-cache" 6168 - version = "0.4.3" 6168 + version = "0.4.4" 6169 6169 dependencies = [ 6170 6170 "async-trait", 6171 6171 "base64 0.22.1", ··· 6179 6179 6180 6180 [[package]] 6181 6181 name = "tranquil-comms" 6182 - version = "0.4.3" 6182 + version = "0.4.4" 6183 6183 dependencies = [ 6184 6184 "async-trait", 6185 6185 "base64 0.22.1", ··· 6194 6194 6195 6195 [[package]] 6196 6196 name = "tranquil-config" 6197 - version = "0.4.3" 6197 + version = "0.4.4" 6198 6198 dependencies = [ 6199 6199 "confique", 6200 6200 "serde", ··· 6202 6202 6203 6203 [[package]] 6204 6204 name = "tranquil-crypto" 6205 - version = "0.4.3" 6205 + version = "0.4.4" 6206 6206 dependencies = [ 6207 6207 "aes-gcm", 6208 6208 "base64 0.22.1", ··· 6218 6218 6219 6219 [[package]] 6220 6220 name = "tranquil-db" 6221 - version = "0.4.3" 6221 + version = "0.4.4" 6222 6222 dependencies = [ 6223 6223 "async-trait", 6224 6224 "chrono", ··· 6235 6235 6236 6236 [[package]] 6237 6237 name = "tranquil-db-traits" 6238 - version = "0.4.3" 6238 + version = "0.4.4" 6239 6239 dependencies = [ 6240 6240 "async-trait", 6241 6241 "base64 0.22.1", ··· 6251 6251 6252 6252 [[package]] 6253 6253 name = "tranquil-infra" 6254 - version = "0.4.3" 6254 + version = "0.4.4" 6255 6255 dependencies = [ 6256 6256 "async-trait", 6257 6257 "bytes", ··· 6262 6262 6263 6263 [[package]] 6264 6264 name = "tranquil-lexicon" 6265 - version = "0.4.3" 6265 + version = "0.4.4" 6266 6266 dependencies = [ 6267 6267 "chrono", 6268 6268 "hickory-resolver", ··· 6280 6280 6281 6281 [[package]] 6282 6282 name = "tranquil-oauth" 6283 - version = "0.4.3" 6283 + version = "0.4.4" 6284 6284 dependencies = [ 6285 6285 "anyhow", 6286 6286 "axum", ··· 6303 6303 6304 6304 [[package]] 6305 6305 name = "tranquil-oauth-server" 6306 - version = "0.4.3" 6306 + version = "0.4.4" 6307 6307 dependencies = [ 6308 6308 "axum", 6309 6309 "base64 0.22.1", ··· 6336 6336 6337 6337 [[package]] 6338 6338 name = "tranquil-pds" 6339 - version = "0.4.3" 6339 + version = "0.4.4" 6340 6340 dependencies = [ 6341 6341 "aes-gcm", 6342 6342 "anyhow", ··· 6424 6424 6425 6425 [[package]] 6426 6426 name = "tranquil-repo" 6427 - version = "0.4.3" 6427 + version = "0.4.4" 6428 6428 dependencies = [ 6429 6429 "bytes", 6430 6430 "cid", ··· 6436 6436 6437 6437 [[package]] 6438 6438 name = "tranquil-ripple" 6439 - version = "0.4.3" 6439 + version = "0.4.4" 6440 6440 dependencies = [ 6441 6441 "async-trait", 6442 6442 "backon", ··· 6461 6461 6462 6462 [[package]] 6463 6463 name = "tranquil-scopes" 6464 - version = "0.4.3" 6464 + version = "0.4.4" 6465 6465 dependencies = [ 6466 6466 "axum", 6467 6467 "futures", ··· 6477 6477 6478 6478 [[package]] 6479 6479 name = "tranquil-server" 6480 - version = "0.4.3" 6480 + version = "0.4.4" 6481 6481 dependencies = [ 6482 6482 "axum", 6483 6483 "clap", ··· 6497 6497 6498 6498 [[package]] 6499 6499 name = "tranquil-storage" 6500 - version = "0.4.3" 6500 + version = "0.4.4" 6501 6501 dependencies = [ 6502 6502 "async-trait", 6503 6503 "aws-config", ··· 6514 6514 6515 6515 [[package]] 6516 6516 name = "tranquil-sync" 6517 - version = "0.4.3" 6517 + version = "0.4.4" 6518 6518 dependencies = [ 6519 6519 "anyhow", 6520 6520 "axum", ··· 6536 6536 6537 6537 [[package]] 6538 6538 name = "tranquil-types" 6539 - version = "0.4.3" 6539 + version = "0.4.4" 6540 6540 dependencies = [ 6541 6541 "chrono", 6542 6542 "cid",
+128 -96
crates/tranquil-pds/src/api/error.rs
··· 9 9 #[derive(Debug, Serialize)] 10 10 struct ErrorBody<'a> { 11 11 error: Cow<'a, str>, 12 - #[serde(skip_serializing_if = "Option::is_none")] 13 - message: Option<String>, 12 + message: String, 14 13 } 15 14 16 15 #[derive(Debug)] ··· 214 213 } 215 214 fn error_name(&self) -> Cow<'static, str> { 216 215 match self { 217 - Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalError"), 216 + Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalServerError"), 218 217 Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => { 219 218 Cow::Borrowed("UpstreamError") 220 219 } ··· 313 312 Self::LegacyLoginBlocked => Cow::Borrowed("MfaRequired"), 314 313 } 315 314 } 316 - fn message(&self) -> Option<String> { 315 + fn message(&self) -> String { 317 316 match self { 318 - Self::InternalError(msg) 319 - | Self::AuthenticationFailed(msg) 320 - | Self::InvalidToken(msg) 321 - | Self::ExpiredToken(msg) 322 - | Self::OAuthExpiredToken(msg) 323 - | Self::RepoNotFound(msg) 324 - | Self::BlobNotFound(msg) 325 - | Self::InvalidHandle(msg) 326 - | Self::HandleNotAvailable(msg) 327 - | Self::InvalidSwap(msg) 328 - | Self::InsufficientScope(msg) 329 - | Self::InvalidCode(msg) 330 - | Self::RateLimitExceeded(msg) 331 - | Self::ServiceUnavailable(msg) => msg.clone(), 317 + Self::InternalError(msg) => msg 318 + .clone() 319 + .unwrap_or_else(|| "Internal Server Error".into()), 320 + Self::AuthenticationFailed(msg) => msg 321 + .clone() 322 + .unwrap_or_else(|| "Authentication failed".into()), 323 + Self::InvalidToken(msg) => { 324 + msg.clone().unwrap_or_else(|| "Invalid token".into()) 325 + } 326 + Self::ExpiredToken(msg) | Self::OAuthExpiredToken(msg) => { 327 + msg.clone().unwrap_or_else(|| "Token has expired".into()) 328 + } 329 + Self::RepoNotFound(msg) => msg 330 + .clone() 331 + .unwrap_or_else(|| "Repository not found".into()), 332 + Self::BlobNotFound(msg) => { 333 + msg.clone().unwrap_or_else(|| "Blob not found".into()) 334 + } 335 + Self::InvalidHandle(msg) => { 336 + msg.clone().unwrap_or_else(|| "Invalid handle".into()) 337 + } 338 + Self::HandleNotAvailable(msg) => msg 339 + .clone() 340 + .unwrap_or_else(|| "Handle not available".into()), 341 + Self::InvalidSwap(msg) => { 342 + msg.clone().unwrap_or_else(|| "Invalid swap".into()) 343 + } 344 + Self::InsufficientScope(msg) => msg 345 + .clone() 346 + .unwrap_or_else(|| "Insufficient scope".into()), 347 + Self::InvalidCode(msg) => { 348 + msg.clone().unwrap_or_else(|| "Invalid code".into()) 349 + } 350 + Self::RateLimitExceeded(msg) => msg 351 + .clone() 352 + .unwrap_or_else(|| "Rate limit exceeded".into()), 353 + Self::ServiceUnavailable(msg) => msg 354 + .clone() 355 + .unwrap_or_else(|| "Service temporarily unavailable".into()), 332 356 Self::InvalidRequest(msg) 333 357 | Self::UpstreamUnavailable(msg) 334 358 | Self::InvalidPassword(msg) ··· 336 360 | Self::InvalidRecord(msg) 337 361 | Self::NotFoundMsg(msg) 338 362 | Self::UpstreamErrorMsg(msg) 339 - | Self::PayloadTooLarge(msg) => Some(msg.clone()), 340 - Self::AccountMigrated => Some( 341 - "Account has been migrated to another PDS. Repo operations are not allowed." 342 - .to_string(), 343 - ), 344 - Self::AccountNotVerified => Some( 345 - "You must verify at least one notification channel before creating records" 346 - .to_string(), 347 - ), 348 - Self::NoPasskeys => { 349 - Some("No passkeys registered for this account".to_string()) 363 + | Self::PayloadTooLarge(msg) 364 + | Self::InvalidScopes(msg) 365 + | Self::InvalidDelegation(msg) 366 + | Self::AuthorizationError(msg) 367 + | Self::InvalidDid(msg) => msg.clone(), 368 + Self::UpstreamError { message, .. } => message 369 + .clone() 370 + .unwrap_or_else(|| "Upstream error".into()), 371 + Self::DatabaseError => "Internal Server Error".into(), 372 + Self::AuthenticationRequired => "Authentication required".into(), 373 + Self::TokenRequired => "Authentication token required".into(), 374 + Self::AccountDeactivated => "Account is deactivated".into(), 375 + Self::AccountTakedown => "Account has been taken down".into(), 376 + Self::AccountNotFound => "Account not found".into(), 377 + Self::RecordNotFound => "Record not found".into(), 378 + Self::Forbidden => "Forbidden".into(), 379 + Self::InvitesDisabled => "Invite codes are disabled on this server".into(), 380 + Self::InvalidCollection => "Invalid collection".into(), 381 + Self::InvalidChannel => "Invalid notification channel".into(), 382 + Self::TotpAlreadyEnabled => "TOTP is already enabled".into(), 383 + Self::TotpNotEnabled => "TOTP is not enabled".into(), 384 + Self::DuplicateAppPassword => "An app password with this name already exists".into(), 385 + Self::AppPasswordNotFound => "App password not found".into(), 386 + Self::SessionNotFound => "Session not found".into(), 387 + Self::UpstreamFailure => "Upstream service failed".into(), 388 + Self::RepoTakendown => "Repository has been taken down".into(), 389 + Self::RepoDeactivated => "Repository is deactivated".into(), 390 + Self::AccountMigrated => { 391 + "Account has been migrated to another PDS. Repo operations are not allowed.".into() 350 392 } 351 - Self::NoChallengeInProgress => Some( 352 - "No passkey authentication in progress or challenge expired".to_string(), 353 - ), 354 - Self::InvalidCredential => Some("Failed to parse credential response".to_string()), 355 - Self::NoRegistrationInProgress => Some( 356 - "No registration in progress. Call startPasskeyRegistration first.".to_string(), 357 - ), 358 - Self::RegistrationFailed => { 359 - Some("Failed to verify passkey registration".to_string()) 393 + Self::AccountNotVerified => { 394 + "You must verify at least one notification channel before creating records".into() 360 395 } 361 - Self::PasskeyNotFound => Some("Passkey not found".to_string()), 362 - Self::InvalidId => Some("Invalid ID format".to_string()), 363 - Self::InvalidScopes(msg) | Self::InvalidDelegation(msg) => Some(msg.clone()), 364 - Self::ControllerNotFound => Some("Controller account not found".to_string()), 396 + Self::NoPasskeys => "No passkeys registered for this account".into(), 397 + Self::NoChallengeInProgress => { 398 + "No passkey authentication in progress or challenge expired".into() 399 + } 400 + Self::InvalidCredential => "Failed to parse credential response".into(), 401 + Self::NoRegistrationInProgress => { 402 + "No registration in progress. Call startPasskeyRegistration first.".into() 403 + } 404 + Self::RegistrationFailed => "Failed to verify passkey registration".into(), 405 + Self::PasskeyNotFound => "Passkey not found".into(), 406 + Self::InvalidId => "Invalid ID format".into(), 407 + Self::ControllerNotFound => "Controller account not found".into(), 365 408 Self::DelegationNotFound => { 366 - Some("No active delegation found for this controller".to_string()) 409 + "No active delegation found for this controller".into() 367 410 } 368 411 Self::InviteCodeRequired => { 369 - Some("An invite code is required to create an account".to_string()) 370 - } 371 - Self::RepoNotReady => Some("Repository not ready".to_string()), 372 - Self::PasskeyCounterAnomaly => Some( 373 - "Authentication failed: security key counter anomaly detected. This may indicate a cloned key.".to_string(), 374 - ), 375 - Self::MfaVerificationRequired => Some( 376 - "This sensitive operation requires MFA verification".to_string(), 377 - ), 378 - Self::DeviceNotFound => Some("Device not found".to_string()), 379 - Self::NoEmail => Some("Recipient has no email address".to_string()), 380 - Self::AuthorizationError(msg) | Self::InvalidDid(msg) => Some(msg.clone()), 381 - Self::InvalidSigningKey => { 382 - Some("Signing key not found, already used, or expired".to_string()) 412 + "An invite code is required to create an account".into() 383 413 } 384 - Self::SetupExpired => { 385 - Some("Setup has already been completed or expired".to_string()) 414 + Self::RepoNotReady => "Repository not ready".into(), 415 + Self::PasskeyCounterAnomaly => { 416 + "Authentication failed: security key counter anomaly detected. This may indicate a cloned key.".into() 386 417 } 387 - Self::InvalidAccount => { 388 - Some("This account is not a passkey-only account".to_string()) 418 + Self::MfaVerificationRequired => { 419 + "This sensitive operation requires MFA verification".into() 389 420 } 390 - Self::InvalidRecoveryLink => Some("Invalid recovery link".to_string()), 391 - Self::RecoveryLinkExpired => Some("Recovery link has expired".to_string()), 392 - Self::MissingEmail => { 393 - Some("Email is required when using email verification".to_string()) 421 + Self::DeviceNotFound => "Device not found".into(), 422 + Self::NoEmail => "Recipient has no email address".into(), 423 + Self::InvalidSigningKey => { 424 + "Signing key not found, already used, or expired".into() 394 425 } 426 + Self::SetupExpired => "Setup has already been completed or expired".into(), 427 + Self::InvalidAccount => "This account is not a passkey-only account".into(), 428 + Self::InvalidRecoveryLink => "Invalid recovery link".into(), 429 + Self::RecoveryLinkExpired => "Recovery link has expired".into(), 430 + Self::MissingEmail => "Email is required when using email verification".into(), 395 431 Self::MissingDiscordId => { 396 - Some("Discord ID is required when using Discord verification".to_string()) 432 + "Discord ID is required when using Discord verification".into() 397 433 } 398 434 Self::MissingTelegramUsername => { 399 - Some("Telegram username is required when using Telegram verification".to_string()) 435 + "Telegram username is required when using Telegram verification".into() 400 436 } 401 437 Self::MissingSignalNumber => { 402 - Some("Signal username is required when using Signal verification".to_string()) 438 + "Signal username is required when using Signal verification".into() 403 439 } 404 - Self::InvalidVerificationChannel => Some("Invalid verification channel".to_string()), 440 + Self::InvalidVerificationChannel => "Invalid verification channel".into(), 405 441 Self::SelfHostedDidWebDisabled => { 406 - Some("Self-hosted did:web accounts are disabled on this server".to_string()) 407 - } 408 - Self::AccountAlreadyExists => Some("Account already exists".to_string()), 409 - Self::HandleNotFound => Some("Unable to resolve handle".to_string()), 410 - Self::SubjectNotFound => Some("Subject not found".to_string()), 411 - Self::SsoProviderNotFound => Some("Unknown SSO provider".to_string()), 412 - Self::SsoProviderNotEnabled => Some("SSO provider is not enabled".to_string()), 413 - Self::SsoInvalidAction => { 414 - Some("Action must be login, link, or register".to_string()) 442 + "Self-hosted did:web accounts are disabled on this server".into() 415 443 } 444 + Self::AccountAlreadyExists => "Account already exists".into(), 445 + Self::HandleNotFound => "Unable to resolve handle".into(), 446 + Self::SubjectNotFound => "Subject not found".into(), 447 + Self::SsoProviderNotFound => "Unknown SSO provider".into(), 448 + Self::SsoProviderNotEnabled => "SSO provider is not enabled".into(), 449 + Self::SsoInvalidAction => "Action must be login, link, or register".into(), 416 450 Self::SsoNotAuthenticated => { 417 - Some("Must be authenticated to link SSO account".to_string()) 451 + "Must be authenticated to link SSO account".into() 418 452 } 419 - Self::SsoSessionExpired => Some("SSO session expired or invalid".to_string()), 453 + Self::SsoSessionExpired => "SSO session expired or invalid".into(), 420 454 Self::SsoAlreadyLinked => { 421 - Some("This SSO account is already linked to a different user".to_string()) 455 + "This SSO account is already linked to a different user".into() 422 456 } 423 - Self::SsoLinkNotFound => Some("Linked account not found".to_string()), 457 + Self::SsoLinkNotFound => "Linked account not found".into(), 424 458 Self::IdentifierMismatch => { 425 - Some("The identifier does not match the verification token".to_string()) 459 + "The identifier does not match the verification token".into() 426 460 } 427 - Self::UpstreamError { message, .. } => message.clone(), 428 - Self::UpstreamTimeout => Some("Upstream service timed out".to_string()), 429 - Self::AdminRequired => Some("This action requires admin privileges".to_string()), 430 - Self::EmailTaken => Some("This email address is already registered".to_string()), 431 - Self::HandleTaken => Some("This handle is already taken".to_string()), 432 - Self::InvalidEmail => Some("Please provide a valid email address".to_string()), 433 - Self::InvalidInviteCode => Some("The invite code provided is invalid".to_string()), 434 - Self::DuplicateCreate => Some("Account creation failed: duplicate request".to_string()), 435 - Self::LegacyLoginBlocked => Some( 436 - "This account requires MFA. Please use an OAuth client that supports TOTP verification.".to_string(), 437 - ), 461 + Self::UpstreamTimeout => "Upstream service timed out".into(), 462 + Self::AdminRequired => "This action requires admin privileges".into(), 463 + Self::EmailTaken => "This email address is already registered".into(), 464 + Self::HandleTaken => "This handle is already taken".into(), 465 + Self::InvalidEmail => "Please provide a valid email address".into(), 466 + Self::InvalidInviteCode => "The invite code provided is invalid".into(), 467 + Self::DuplicateCreate => "Account creation failed: duplicate request".into(), 468 + Self::LegacyLoginBlocked => { 469 + "This account requires MFA. Please use an OAuth client that supports TOTP verification.".into() 470 + } 438 471 Self::AuthFactorTokenRequired => { 439 - Some("A sign in code has been sent to your email address".to_string()) 472 + "A sign-in code has been sent to your email address".into() 440 473 } 441 - _ => None, 442 474 } 443 475 } 444 476 pub fn from_upstream_response(status: StatusCode, body: &[u8]) -> Self {
+5 -5
crates/tranquil-pds/src/api/proxy.rs
··· 10 10 body::Bytes, 11 11 extract::{RawQuery, Request, State}, 12 12 handler::Handler, 13 - http::{HeaderMap, Method, StatusCode}, 13 + http::{HeaderMap, Method}, 14 14 response::{IntoResponse, Response}, 15 15 }; 16 16 use futures_util::future::Either; ··· 335 335 Ok(b) => b, 336 336 Err(e) => { 337 337 error!("Error reading proxy response body: {:?}", e); 338 - return (StatusCode::BAD_GATEWAY, "Error reading upstream response") 338 + return ApiError::UpstreamUnavailable("Error reading upstream response".into()) 339 339 .into_response(); 340 340 } 341 341 }; ··· 350 350 Ok(r) => r, 351 351 Err(e) => { 352 352 error!("Error building proxy response: {:?}", e); 353 - (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response() 353 + ApiError::InternalError(None).into_response() 354 354 } 355 355 } 356 356 } 357 357 Err(e) => { 358 358 error!("Error sending proxy request: {:?}", e); 359 359 if e.is_timeout() { 360 - (StatusCode::GATEWAY_TIMEOUT, "Upstream Timeout").into_response() 360 + ApiError::UpstreamTimeout.into_response() 361 361 } else { 362 - (StatusCode::BAD_GATEWAY, "Upstream Error").into_response() 362 + ApiError::UpstreamFailure.into_response() 363 363 } 364 364 } 365 365 }
+30 -7
crates/tranquil-pds/src/lib.rs
··· 100 100 .layer(DefaultBodyLimit::max( 101 101 tranquil_config::get().server.max_blob_size as usize, 102 102 )) 103 - .layer(axum::middleware::map_response(rewrite_422_to_400)) 103 + .layer(axum::middleware::map_response(rewrite_extractor_errors)) 104 104 .layer(middleware::from_fn(metrics::metrics_middleware)) 105 105 .layer( 106 106 CorsLayer::new() ··· 150 150 router 151 151 } 152 152 153 - async fn rewrite_422_to_400(response: axum::response::Response) -> axum::response::Response { 154 - if response.status() != StatusCode::UNPROCESSABLE_ENTITY { 153 + fn is_plain_text(headers: &http::HeaderMap) -> bool { 154 + headers 155 + .get(http::header::CONTENT_TYPE) 156 + .and_then(|v| v.to_str().ok()) 157 + .is_some_and(|ct| ct.starts_with("text/plain")) 158 + } 159 + 160 + fn should_rewrite_to_xrpc_error(response: &axum::response::Response) -> bool { 161 + match response.status() { 162 + StatusCode::UNPROCESSABLE_ENTITY => true, 163 + StatusCode::BAD_REQUEST => is_plain_text(response.headers()), 164 + StatusCode::UNSUPPORTED_MEDIA_TYPE => is_plain_text(response.headers()), 165 + _ => false, 166 + } 167 + } 168 + 169 + async fn rewrite_extractor_errors(response: axum::response::Response) -> axum::response::Response { 170 + if !should_rewrite_to_xrpc_error(&response) { 155 171 return response; 156 172 } 157 173 let (mut parts, body) = response.into_parts(); ··· 173 189 .unwrap_or_else(|| { 174 190 String::from_utf8(bytes.to_vec()).unwrap_or_else(|_| "Invalid request body".into()) 175 191 }); 176 - let message = humanize_json_error(&raw); 192 + let message = humanize_extraction_error(&raw); 177 193 178 194 parts.status = StatusCode::BAD_REQUEST; 179 195 parts.headers.remove(http::header::CONTENT_LENGTH); 180 - let error_name = classify_deserialization_error(&raw); 196 + let error_name = classify_extraction_error(&raw); 181 197 let new_body = json!({ 182 198 "error": error_name, 183 199 "message": message ··· 188 204 ) 189 205 } 190 206 191 - fn humanize_json_error(raw: &str) -> String { 207 + fn humanize_extraction_error(raw: &str) -> String { 192 208 if raw.contains("missing field") { 193 209 raw.split("missing field `") 194 210 .nth(1) ··· 201 217 "Invalid JSON syntax".to_string() 202 218 } else if raw.contains("Content-Type") || raw.contains("content type") { 203 219 "Content-Type must be application/json".to_string() 220 + } else if raw.contains("Failed to parse") || raw.contains("expected ident") { 221 + "Invalid JSON in request body".to_string() 222 + } else if raw.contains("Failed to deserialize query string") { 223 + raw.strip_prefix("Failed to deserialize query string: ") 224 + .map(|rest| format!("Invalid query parameter: {}", rest)) 225 + .unwrap_or_else(|| "Invalid query parameters".into()) 204 226 } else { 205 227 raw.to_string() 206 228 } 207 229 } 208 230 209 - fn classify_deserialization_error(raw: &str) -> &'static str { 231 + fn classify_extraction_error(raw: &str) -> &'static str { 210 232 match raw { 211 233 s if s.contains("invalid handle") => "InvalidHandle", 234 + s if s.contains("invalid CID") || s.contains("invalid cid") => "InvalidRequest", 212 235 _ => "InvalidRequest", 213 236 } 214 237 }
+13 -1
frontend/src/lib/api.ts
··· 86 86 87 87 const API_BASE = "/xrpc"; 88 88 89 + const STATUS_FALLBACK_MESSAGE: Record<number, string> = { 90 + 400: "Bad request", 91 + 401: "Authentication required", 92 + 403: "Forbidden", 93 + 404: "Not found", 94 + 429: "Rate limit exceeded", 95 + 500: "Internal server error", 96 + 502: "Bad gateway", 97 + 503: "Service unavailable", 98 + 504: "Gateway timeout", 99 + }; 100 + 89 101 export class ApiError extends Error { 90 102 public did?: Did; 91 103 public reauthMethods?: string[]; ··· 96 108 did?: string, 97 109 reauthMethods?: string[], 98 110 ) { 99 - super(message); 111 + super(message ?? STATUS_FALLBACK_MESSAGE[status] ?? "Request failed"); 100 112 this.name = "ApiError"; 101 113 this.did = did ? unsafeAsDid(did) : undefined; 102 114 this.reauthMethods = reauthMethods;