atproto Thingiverse but good
10

Configure Feed

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

PM-61: typed error classification

Orual (Jun 29, 2026, 7:06 PM EDT) bf58a463 f9e95dab

+171 -16
+171 -16
src/appview/writes.rs
··· 19 19 use jacquard_common::deps::smol_str::SmolStr; 20 20 use jacquard_common::types::blob::{BlobRef, MimeType}; 21 21 use jacquard_common::types::collection::Collection; 22 + use jacquard_common::types::collection::RecordError; 22 23 use jacquard_common::types::ident::AtIdentifier; 23 24 use jacquard_common::types::recordkey::{RecordKey, Rkey}; 24 25 use jacquard_common::types::string::{AtUri, Cid, Datetime, Did}; 25 26 use jacquard_common::types::value::to_data; 27 + use jacquard_common::xrpc::XrpcError; 28 + use jacquard_common::xrpc::atproto::CreateRecordError; 26 29 use polymodel_api::app_bsky::actor::profile::Profile as BskyProfile; 30 + use polymodel_api::com_atproto::repo::get_record::GetRecordError; 27 31 use polymodel_api::com_atproto::repo::strong_ref::StrongRef; 28 32 use polymodel_api::space_polymodel::actor::ProfileView; 29 33 use polymodel_api::space_polymodel::actor::bootstrap_profile::{ ··· 530 534 let existing_remote = match agent.get_record::<PolymodelProfile, _>(&profile_uri).await { 531 535 Ok(resp) => match resp.into_output() { 532 536 Ok(output) => Some(output), 533 - Err(e) if display_is_not_found(&e) => None, 537 + Err(e) if into_output_is_not_found(&e) => None, 534 538 Err(e) => return Err(internal(format!("profile record decode failed: {e}"))), 535 539 }, 536 540 Err(e) if client_error_is_not_found(&e) => None, ··· 568 572 let bsky = match agent.get_record::<BskyProfile, _>(&bsky_uri).await { 569 573 Ok(resp) => match resp.into_output() { 570 574 Ok(output) => output.value, 571 - Err(e) if display_is_not_found(&e) => empty_bsky_profile(), 575 + Err(e) if into_output_is_not_found(&e) => empty_bsky_profile(), 572 576 Err(e) => return Err(internal(format!("bsky profile decode failed: {e}"))), 573 577 }, 574 578 Err(e) if client_error_is_not_found(&e) => empty_bsky_profile(), ··· 1674 1678 } 1675 1679 1676 1680 fn client_error(operation: &str, err: jacquard_common::error::ClientError) -> AppError { 1677 - let text = format!("{err:?}"); 1678 - if text.contains("Auth") || text.contains("Unauthorized") || text.contains("401") { 1681 + if err.is_auth() { 1679 1682 tracing::warn!(operation, error = %err, "authenticated PDS read failed authorization"); 1680 1683 return unauthorized("authenticated PDS read was not authorized"); 1681 1684 } ··· 1684 1687 } 1685 1688 1686 1689 fn client_error_is_not_found(err: &jacquard_common::error::ClientError) -> bool { 1687 - let text = format!("{err:?}"); 1688 - text.contains("RecordNotFound") || text.contains("not found") || text.contains("404") 1690 + err.is_not_found() 1691 + } 1692 + 1693 + /// Trait for XRPC error enums that carry a `RecordNotFound` variant. 1694 + trait XrpcNotFound: core::error::Error { 1695 + fn is_record_not_found(&self) -> bool; 1696 + } 1697 + 1698 + impl XrpcNotFound for GetRecordError { 1699 + fn is_record_not_found(&self) -> bool { 1700 + matches!(self, GetRecordError::RecordNotFound(_)) 1701 + } 1702 + } 1703 + 1704 + impl XrpcNotFound for RecordError { 1705 + fn is_record_not_found(&self) -> bool { 1706 + matches!(self, RecordError::RecordNotFound(_)) 1707 + } 1689 1708 } 1690 1709 1691 1710 /// A `getRecord` for a missing record can come back as a successful HTTP 1692 1711 /// response whose body decodes (via `into_output()`) to an XRPC `RecordNotFound` 1693 - /// error rather than a transport-level `Err`. Detect that from the error's 1694 - /// `Display` so the caller can treat the record as absent instead of failing. 1695 - fn display_is_not_found<E: std::fmt::Display>(err: &E) -> bool { 1696 - let text = err.to_string(); 1697 - text.contains("RecordNotFound") || text.contains("not found") || text.contains("404") 1712 + /// error rather than a transport-level `Err`. Detect that via the typed 1713 + /// `RecordNotFound` variant, with a `Generic` fallback for non-conforming 1714 + /// envelopes, so the caller can treat the record as absent. 1715 + fn into_output_is_not_found<E: XrpcNotFound>(err: &XrpcError<E>) -> bool { 1716 + match err { 1717 + XrpcError::Xrpc(e) => e.is_record_not_found(), 1718 + XrpcError::Generic(g) => g.error.as_str() == "RecordNotFound", 1719 + _ => false, 1720 + } 1698 1721 } 1699 1722 1700 1723 /// An empty `app.bsky.actor.profile` used when an actor has no Bluesky profile ··· 1716 1739 } 1717 1740 1718 1741 fn agent_error_is_auth(err: &jacquard::client::AgentError) -> bool { 1719 - format!("{err:?}").contains("Auth") 1742 + err.is_auth() 1720 1743 } 1721 1744 1722 1745 fn agent_error_is_not_found(err: &jacquard::client::AgentError) -> bool { 1723 - let text = format!("{err:?}"); 1724 - text.contains("RecordNotFound") || text.contains("not found") || text.contains("404") 1746 + err.client_error().is_some_and(|c| c.is_not_found()) 1725 1747 } 1726 1748 1727 1749 fn agent_error_is_conflict(err: &jacquard::client::AgentError) -> bool { 1728 - let text = format!("{err:?}"); 1729 - text.contains("AlreadyExists") || text.contains("InvalidSwap") || text.contains("409") 1750 + err.source_downcast::<CreateRecordError>().is_some_and(|e| { 1751 + matches!( 1752 + e, 1753 + CreateRecordError::AlreadyExists(_) | CreateRecordError::InvalidSwap(_) 1754 + ) 1755 + }) || err.client_error().is_some_and(|c| c.is_conflict()) 1730 1756 } 1731 1757 1732 1758 #[cfg(test)] ··· 2080 2106 .unwrap(), 2081 2107 2 2082 2108 ); 2109 + } 2110 + 2111 + // --- PM-61 typed error classification helpers --- 2112 + 2113 + #[test] 2114 + fn client_error_auth_maps_to_unauthorized() { 2115 + let err = jacquard_common::error::ClientError::auth( 2116 + jacquard_common::error::AuthError::TokenExpired, 2117 + ); 2118 + let result = client_error("test op", err); 2119 + assert!(matches!(result, AppError::Unauthorized(_))); 2120 + } 2121 + 2122 + #[test] 2123 + fn client_error_http_401_maps_to_unauthorized() { 2124 + let err = jacquard_common::error::ClientError::http(http::StatusCode::UNAUTHORIZED, None); 2125 + let result = client_error("test op", err); 2126 + assert!(matches!(result, AppError::Unauthorized(_))); 2127 + } 2128 + 2129 + #[test] 2130 + fn client_error_other_maps_to_internal() { 2131 + let err = jacquard_common::error::ClientError::http( 2132 + http::StatusCode::INTERNAL_SERVER_ERROR, 2133 + None, 2134 + ); 2135 + let result = client_error("test op", err); 2136 + assert!(!matches!(result, AppError::Unauthorized(_))); 2137 + } 2138 + 2139 + #[test] 2140 + fn client_error_is_not_found_typed() { 2141 + let err = jacquard_common::error::ClientError::http(http::StatusCode::NOT_FOUND, None); 2142 + assert!(client_error_is_not_found(&err)); 2143 + 2144 + let err = jacquard_common::error::ClientError::http(http::StatusCode::OK, None); 2145 + assert!(!client_error_is_not_found(&err)); 2146 + } 2147 + 2148 + #[test] 2149 + fn into_output_not_found_via_typed_get_record_error() { 2150 + let err: XrpcError<GetRecordError> = XrpcError::Xrpc(GetRecordError::RecordNotFound(None)); 2151 + assert!(into_output_is_not_found(&err)); 2152 + } 2153 + 2154 + #[test] 2155 + fn into_output_not_found_via_generic_code() { 2156 + let generic = jacquard_common::xrpc::GenericXrpcError { 2157 + error: "RecordNotFound".into(), 2158 + message: None, 2159 + nsid: "com.atproto.repo.getRecord", 2160 + method: "GET", 2161 + http_status: http::StatusCode::BAD_REQUEST, 2162 + }; 2163 + let err: XrpcError<GetRecordError> = XrpcError::Generic(generic); 2164 + assert!(into_output_is_not_found(&err)); 2165 + } 2166 + 2167 + #[test] 2168 + fn into_output_not_found_via_typed_record_error() { 2169 + let err: XrpcError<RecordError> = XrpcError::Xrpc(RecordError::RecordNotFound(None)); 2170 + assert!(into_output_is_not_found(&err)); 2171 + } 2172 + 2173 + #[test] 2174 + fn into_output_not_found_false_for_auth_error() { 2175 + let err: XrpcError<GetRecordError> = 2176 + XrpcError::Auth(jacquard_common::error::AuthError::TokenExpired); 2177 + assert!(!into_output_is_not_found(&err)); 2178 + } 2179 + 2180 + #[test] 2181 + fn agent_error_auth_typed() { 2182 + let err = 2183 + jacquard::client::AgentError::auth(jacquard_common::error::AuthError::TokenExpired); 2184 + assert!(agent_error_is_auth(&err)); 2185 + } 2186 + 2187 + #[test] 2188 + fn agent_error_auth_from_wrapped_client_401() { 2189 + let err = jacquard::client::AgentError::from(jacquard_common::error::ClientError::auth( 2190 + jacquard_common::error::AuthError::TokenExpired, 2191 + )); 2192 + assert!(agent_error_is_auth(&err)); 2193 + } 2194 + 2195 + #[test] 2196 + fn agent_error_not_found_from_client_404() { 2197 + let err = jacquard::client::AgentError::from(jacquard_common::error::ClientError::http( 2198 + http::StatusCode::NOT_FOUND, 2199 + None, 2200 + )); 2201 + assert!(agent_error_is_not_found(&err)); 2202 + } 2203 + 2204 + #[test] 2205 + fn agent_error_conflict_from_already_exists() { 2206 + let err = jacquard::client::AgentError::sub_operation( 2207 + "create record", 2208 + CreateRecordError::AlreadyExists(None), 2209 + ); 2210 + assert!(agent_error_is_conflict(&err)); 2211 + } 2212 + 2213 + #[test] 2214 + fn agent_error_conflict_from_invalid_swap() { 2215 + let err = jacquard::client::AgentError::sub_operation( 2216 + "create record", 2217 + CreateRecordError::InvalidSwap(None), 2218 + ); 2219 + assert!(agent_error_is_conflict(&err)); 2220 + } 2221 + 2222 + #[test] 2223 + fn agent_error_conflict_from_client_409() { 2224 + let err = jacquard::client::AgentError::from(jacquard_common::error::ClientError::http( 2225 + http::StatusCode::CONFLICT, 2226 + None, 2227 + )); 2228 + assert!(agent_error_is_conflict(&err)); 2229 + } 2230 + 2231 + #[test] 2232 + fn agent_error_not_conflict_for_internal_error() { 2233 + let err = jacquard::client::AgentError::from(jacquard_common::error::ClientError::http( 2234 + http::StatusCode::INTERNAL_SERVER_ERROR, 2235 + None, 2236 + )); 2237 + assert!(!agent_error_is_conflict(&err)); 2083 2238 } 2084 2239 }