use failsafe::{
backoff::{self, Constant},
failure_policy::{self, ConsecutiveFailures},
futures::CircuitBreaker,
};
use reqwest::{Client, StatusCode, Url};
use serde_json::{self};
use std::time::Duration;
use tokio_retry::RetryIf;
use tokio_retry::strategy::{ExponentialBackoff, jitter};
use crate::home_assistant::schemas::StateCreateOrUpdate;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Request failed: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error("Request rejected by the circuit breaker")]
RequestRejected,
#[error("JSON serialization failed: {0}")]
JsonSerializationFailed(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
pub struct HttpClient {
client: Client,
token: String,
base_url: Url,
circuit_breaker: failsafe::StateMachine<ConsecutiveFailures<Constant>, ()>,
}
impl HttpClient {
pub fn new(url: &Url, token: &str) -> Self {
let client = Client::builder()
.timeout(Duration::from_millis(500)) .build()
.expect("Failed to create HTTP client");
HttpClient {
client,
token: token.to_string(),
base_url: url.clone(),
circuit_breaker: circuit_breaker(),
}
}
pub async fn set_state(&self, entity_id: &str, state: &StateCreateOrUpdate) -> Result<()> {
let body = serde_json::to_string(state)?;
RetryIf::spawn(
retry_strategy(),
|| async {
self.circuit_breaker
.call_with(is_recorded_error, self.request_post_state(entity_id, &body))
.await
.map_err(|err| match err {
failsafe::Error::Rejected => Error::RequestRejected,
failsafe::Error::Inner(e) => e,
})
},
is_retryable_error,
)
.await?;
Ok(())
}
pub async fn request_post_state(&self, entity_id: &str, body: &str) -> Result<()> {
log::debug!(
"Sending post state request for entity '{}': {}",
entity_id,
body
);
let url = self
.base_url
.join(&format!("api/states/{entity_id}"))
.expect("cannot post state URL");
self.client
.post(url)
.header("Authorization", format!("Bearer {}", self.token))
.header("Content-Type", "application/json")
.body(body.to_string())
.send()
.await?
.error_for_status()?;
Ok(())
}
}
fn circuit_breaker() -> failsafe::StateMachine<ConsecutiveFailures<Constant>, ()> {
let backoff = backoff::constant(Duration::from_secs(60));
let policy = failure_policy::consecutive_failures(3, backoff);
failsafe::Config::new().failure_policy(policy).build()
}
fn retry_strategy() -> impl Iterator<Item = Duration> {
ExponentialBackoff::from_millis(10).map(jitter).take(3)
}
fn is_client_error(error: &reqwest::Error) -> bool {
error
.status()
.map(|status_code| StatusCode::is_client_error(&status_code))
.unwrap_or(false)
}
fn is_retryable_error(error: &Error) -> bool {
match error {
Error::RequestFailed(err) => is_client_error(err),
Error::RequestRejected => false, Error::JsonSerializationFailed(_) => false, }
}
fn is_recorded_error(error: &Error) -> bool {
match error {
Error::RequestFailed(err) => !is_client_error(err), Error::RequestRejected => false, Error::JsonSerializationFailed(_) => false, }
}