/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ //! State machine helpers: error wrapper, target-state extension trait, and //! a `RetryingAccount` wrapper. //! //! Holding `&mut RetryingAccount` instead of `&mut FirefoxAccount` makes it //! structurally impossible to call into the network without retry. use crate::{ internal::FirefoxAccount, DeviceCapability, DeviceType, Error, FxaError, FxaRustAuthState, FxaState, LocalDevice, Result, }; use error_support::GetErrorHandling; /// Error returned from `transition()`. /// /// Causes are boxed because `Error` is ~120 bytes; keeping the variants small /// avoids inflating every transition's `Result`. #[derive(Debug)] pub enum StateMachineErr { /// Operational error (network, auth). Driver logs the cause and commits /// `target` as the new public state. Handled { cause: Box, target: FxaState }, /// Programming error. Driver returns `Err(*cause)`; public state unchanged. Fatal(Box), } impl StateMachineErr { /// Programmatic errors (logic / invalid-transition) become `Fatal` and /// ignore `target_if_handled`; everything else becomes `Handled`. pub fn new(cause: Error, target_if_handled: FxaState) -> Self { match cause { Error::StateMachineLogicError(_) | Error::InvalidStateTransition(_) => { Self::Fatal(Box::new(cause)) } other => Self::Handled { cause: Box::new(other), target: target_if_handled, }, } } } /// Attaches a target [`FxaState`] to a `Result` so transitions can /// land somewhere specific on failure with `?`. /// /// Programming errors (logic / invalid-transition) bypass `target` and are /// wrapped as [`StateMachineErr::Fatal`] so the driver propagates them. /// /// ```ignore /// account.complete_oauth_flow(&code, &state) /// .to_state_machine_err(|| initial_state.into())?; /// ``` pub trait ResultExt { fn to_state_machine_err( self, f: impl FnOnce() -> FxaState, ) -> std::result::Result; } impl ResultExt for Result { fn to_state_machine_err( self, f: impl FnOnce() -> FxaState, ) -> std::result::Result { self.map_err(|cause| match cause { Error::StateMachineLogicError(_) | Error::InvalidStateTransition(_) => { StateMachineErr::Fatal(Box::new(cause)) } other => StateMachineErr::Handled { cause: Box::new(other), target: f(), }, }) } } /// Retry policy applied by [`RetryingAccount`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct RetryPolicy { /// Number of times to retry a call after a transient network error. pub network_retries: u8, /// Whether an authentication error triggers a single recovery attempt /// (clear access token cache → check_authorization_status → retry on success). pub auth_retry_with_cache_clear: bool, } impl RetryPolicy { pub const DEFAULT: RetryPolicy = RetryPolicy { network_retries: 3, auth_retry_with_cache_clear: true, }; } /// Exposes only the [`FirefoxAccount`] methods the FSM uses, each with retry /// policy applied. Holding `&mut RetryingAccount` rather than `&mut FirefoxAccount` /// makes bypassing retry a compile error. pub struct RetryingAccount<'a> { inner: &'a mut FirefoxAccount, policy: RetryPolicy, } impl<'a> RetryingAccount<'a> { pub fn new(inner: &'a mut FirefoxAccount) -> Self { Self { inner, policy: RetryPolicy::DEFAULT, } } pub fn complete_oauth_flow(&mut self, code: &str, state: &str) -> Result<()> { self.with_auth_recovery(|a| a.complete_oauth_flow(code, state)) } pub fn handle_web_channel_password_change(&mut self, json_payload: &str) -> Result<()> { self.with_auth_recovery(|a| a.handle_web_channel_password_change(json_payload)) } /// Cancels any existing OAuth flow before starting a new one. pub fn begin_oauth_flow( &mut self, service: &str, scopes: &[&str], entrypoint: &str, ) -> Result { self.inner.cancel_existing_oauth_flows(); self.with_auth_recovery(|a| a.begin_oauth_flow(service, scopes, entrypoint)) } /// Cancels any existing OAuth flow before starting a new one. pub fn begin_pairing_flow( &mut self, pairing_url: &str, service: &str, scopes: &[&str], entrypoint: &str, ) -> Result { self.inner.cancel_existing_oauth_flows(); self.with_auth_recovery(|a| a.begin_pairing_flow(pairing_url, service, scopes, entrypoint)) } /// Auth errors land the FSM at `Disconnected`; no operation-level recovery. pub fn initialize_device( &mut self, name: &str, device_type: DeviceType, capabilities: &[DeviceCapability], ) -> Result { self.with_retry(|a| a.initialize_device(name, device_type, capabilities)) } /// Auth errors propagate so the FSM can drive its own recovery via /// `CheckAuthorizationStatus`. See /// . pub fn ensure_capabilities( &mut self, capabilities: &[DeviceCapability], ) -> Result { self.with_retry(|a| a.ensure_capabilities(capabilities)) } pub fn check_authorization_status(&mut self) -> Result { self.with_retry(|a| a.check_authorization_status()) .map(|info| info.active) } pub fn get_profile(&mut self) -> Result<()> { self.with_auth_recovery(|a| { a.get_profile(true)?; Ok(()) }) } pub fn get_auth_state(&mut self) -> FxaRustAuthState { self.inner.get_auth_state() } pub fn disconnect(&mut self) { self.inner.disconnect() } /// Panics if called before the driver has processed `Initialize`. pub fn device_config(&self) -> &crate::DeviceConfig { self.inner .device_config .as_ref() .expect("device_config must be set before transition runs (Initialize event seeds it)") } fn with_retry(&mut self, mut op: impl FnMut(&mut FirefoxAccount) -> Result) -> Result { let mut network_retries: u8 = 0; loop { match op(self.inner) { Ok(v) => return Ok(v), Err(e) => { if matches!(e, Error::StateMachineLogicError(_)) { return Err(e); } crate::warn!("handling error: {e}"); match e.get_error_handling().err { FxaError::Network if network_retries < self.policy.network_retries => { network_retries += 1; continue; } _ => return Err(e), } } } } } /// Like `with_retry`, plus a single auth-error recovery: clear the access /// token cache, check authorization, and retry if the account is still /// active server-side. fn with_auth_recovery( &mut self, mut op: impl FnMut(&mut FirefoxAccount) -> Result, ) -> Result { let mut network_retries: u8 = 0; let mut auth_retried = false; loop { match op(self.inner) { Ok(v) => return Ok(v), Err(e) => { if matches!(e, Error::StateMachineLogicError(_)) { return Err(e); } crate::warn!("handling error: {e}"); match e.get_error_handling().err { FxaError::Network if network_retries < self.policy.network_retries => { network_retries += 1; continue; } FxaError::Authentication if self.policy.auth_retry_with_cache_clear && !auth_retried => { self.inner.clear_access_token_cache(); match self.inner.check_authorization_status() { Ok(status) if status.active => { auth_retried = true; continue; } _ => return Err(e), } } _ => return Err(e), } } } } } } #[cfg(test)] mod tests { use super::*; use viaduct::ViaductError; fn network_error() -> Error { Error::RequestError(ViaductError::NetworkError("test".into())) } fn auth_error() -> Error { Error::NoRefreshToken } #[test] fn default_policy_pinned() { assert_eq!( RetryPolicy::DEFAULT, RetryPolicy { network_retries: 3, auth_retry_with_cache_clear: true, } ); } #[test] fn to_state_machine_err_attaches_target_state_for_operational_errors() { let res: Result<()> = Err(network_error()); let mapped = res.to_state_machine_err(|| FxaState::AuthIssues); match mapped { Err(StateMachineErr::Handled { target, .. }) => { assert_eq!(target, FxaState::AuthIssues) } Err(StateMachineErr::Fatal(_)) => panic!("expected Handled, got Fatal"), Ok(_) => panic!("expected Err"), } } #[test] fn to_state_machine_err_promotes_logic_errors_to_fatal() { let res: Result<()> = Err(Error::StateMachineLogicError("boom".into())); let mapped = res.to_state_machine_err(|| FxaState::Disconnected); match mapped { Err(StateMachineErr::Fatal(cause)) => { assert!(matches!(*cause, Error::StateMachineLogicError(_))) } Err(StateMachineErr::Handled { .. }) => panic!("expected Fatal, got Handled"), Ok(_) => panic!("expected Err"), } } #[test] fn to_state_machine_err_promotes_invalid_state_transition_to_fatal() { let res: Result<()> = Err(Error::InvalidStateTransition("nope".into())); let mapped = res.to_state_machine_err(|| FxaState::Disconnected); match mapped { Err(StateMachineErr::Fatal(cause)) => { assert!(matches!(*cause, Error::InvalidStateTransition(_))) } _ => panic!("expected Fatal"), } } #[test] fn to_state_machine_err_passes_ok_through() { let res: Result = Ok(42); let mapped = res.to_state_machine_err(|| FxaState::Disconnected); assert_eq!(mapped.unwrap(), 42); } // The retry tests construct a real `FirefoxAccount` because `RetryingAccount` // requires one structurally; the test closures don't actually touch it. fn mock_account() -> FirefoxAccount { use crate::internal::config::Config; FirefoxAccount::with_config(Config::new_with_mock_well_known_fxa_client_configuration( "https://mock-fxa.example.com", "12345678", "https://foo.bar", )) } #[test] fn with_retry_succeeds_first_try() { nss_as::ensure_initialized(); let mut account = mock_account(); let mut wrapper = RetryingAccount::new(&mut account); let mut calls = 0; let result = wrapper.with_retry(|_| { calls += 1; Ok::<_, Error>(42) }); assert_eq!(result.unwrap(), 42); assert_eq!(calls, 1); } #[test] fn with_retry_retries_network_errors_then_succeeds() { nss_as::ensure_initialized(); let mut account = mock_account(); let mut wrapper = RetryingAccount::new(&mut account); let mut calls = 0; let result = wrapper.with_retry(|_| { calls += 1; if calls <= 2 { Err(network_error()) } else { Ok(7) } }); assert_eq!(result.unwrap(), 7); assert_eq!(calls, 3); } #[test] fn with_retry_gives_up_after_network_retry_limit() { nss_as::ensure_initialized(); let mut account = mock_account(); let mut wrapper = RetryingAccount::new(&mut account); let mut calls = 0; let result: Result = wrapper.with_retry(|_| { calls += 1; Err(network_error()) }); assert!(result.is_err()); assert_eq!(calls, 4); // 1 attempt + 3 retries } #[test] fn with_retry_does_not_retry_auth_errors() { nss_as::ensure_initialized(); let mut account = mock_account(); let mut wrapper = RetryingAccount::new(&mut account); let mut calls = 0; let result: Result = wrapper.with_retry(|_| { calls += 1; Err(auth_error()) }); assert!(result.is_err()); assert_eq!(calls, 1); } #[test] fn with_retry_propagates_logic_errors_immediately() { nss_as::ensure_initialized(); let mut account = mock_account(); let mut wrapper = RetryingAccount::new(&mut account); let mut calls = 0; let result: Result = wrapper.with_retry(|_| { calls += 1; Err(Error::StateMachineLogicError("boom".into())) }); assert!(matches!(result, Err(Error::StateMachineLogicError(_)))); assert_eq!(calls, 1); } // The auth-recovery path itself can't be exercised here without a real // backend (it calls `check_authorization_status` against the mock); these // tests cover the paths that don't trigger recovery. #[test] fn with_auth_recovery_succeeds_first_try() { nss_as::ensure_initialized(); let mut account = mock_account(); let mut wrapper = RetryingAccount::new(&mut account); let mut calls = 0; let result = wrapper.with_auth_recovery(|_| { calls += 1; Ok::<_, Error>("ok") }); assert_eq!(result.unwrap(), "ok"); assert_eq!(calls, 1); } #[test] fn with_auth_recovery_retries_network_errors() { nss_as::ensure_initialized(); let mut account = mock_account(); let mut wrapper = RetryingAccount::new(&mut account); let mut calls = 0; let result = wrapper.with_auth_recovery(|_| { calls += 1; if calls <= 1 { Err(network_error()) } else { Ok("ok") } }); assert_eq!(result.unwrap(), "ok"); assert_eq!(calls, 2); } #[test] fn with_auth_recovery_propagates_logic_errors_immediately() { nss_as::ensure_initialized(); let mut account = mock_account(); let mut wrapper = RetryingAccount::new(&mut account); let mut calls = 0; let result: Result = wrapper.with_auth_recovery(|_| { calls += 1; Err(Error::StateMachineLogicError("boom".into())) }); assert!(matches!(result, Err(Error::StateMachineLogicError(_)))); assert_eq!(calls, 1); } }