use failsafe::{
backoff::{self, Constant},
failure_policy::{self, ConsecutiveFailures},
futures::CircuitBreaker,
};
use reqwest::{Client, StatusCode, Url};
use std::time::Duration;
use tokio::sync::RwLock;
use tokio_retry::RetryIf;
use tokio_retry::strategy::{ExponentialBackoff, jitter};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Login expired during operation")]
LoginExpired,
#[error("Wrong password")]
WrongPassword,
#[error("Query impossible")]
QueryImpossible,
#[error("Access denied")]
AccessDenied,
#[error("Request failed: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error("Request rejected by the circuit breaker")]
RequestRejected,
}
pub type Result<T> = std::result::Result<T, Error>;
pub struct HttpClient {
client: Client,
password: String,
base_url: Url,
token: RwLock<Option<String>>,
circuit_breaker: failsafe::StateMachine<ConsecutiveFailures<Constant>, ()>,
}
impl HttpClient {
pub fn new(url: &Url, password: &str) -> Self {
let client = Client::builder()
.timeout(Duration::from_millis(500)) .build()
.expect("Failed to create HTTP client");
HttpClient {
client,
password: password.to_string(),
base_url: url.clone(),
token: RwLock::new(None),
circuit_breaker: circuit_breaker(),
}
}
pub async fn is_logged_in(&self) -> bool {
let token_read = self.token.read().await;
token_read.is_some()
}
pub async fn login(&self, force: bool) -> Result<()> {
RetryIf::spawn(
retry_strategy(),
|| async {
self.circuit_breaker
.call_with(is_recorded_error, self.do_login(force))
.await
.map_err(|err| match err {
failsafe::Error::Inner(e) => e,
failsafe::Error::Rejected => Error::RequestRejected,
})
},
is_retryable_error,
)
.await?;
Ok(())
}
pub async fn logout(&self) {
let mut token_lock = self.token.write().await;
*token_lock = None;
}
pub async fn query(&self, query: &str) -> Result<String> {
let text = RetryIf::spawn(
retry_strategy(),
|| async {
self.circuit_breaker
.call_with(is_recorded_error, self.do_query(query))
.await
.map_err(|err| match err {
failsafe::Error::Inner(e) => e,
failsafe::Error::Rejected => Error::RequestRejected,
})
},
is_retryable_error,
)
.await?;
Ok(text)
}
async fn do_login(&self, force: bool) -> Result<()> {
if !force && self.is_logged_in().await {
return Ok(());
}
self.refresh_token(force).await?;
Ok(())
}
async fn do_query(&self, query: &str) -> Result<String> {
self.do_login(false).await?;
let token_read = self.token.read().await;
let token = match *token_read {
Some(ref token) => token,
None => return Err(Error::LoginExpired),
};
let result = self.request_getjp(token, query).await;
if let Err(Error::AccessDenied) = result {
let token = token.clone();
drop(token_read); self.clear_token(&token).await;
return Err(Error::AccessDenied);
}
result
}
async fn refresh_token(&self, force: bool) -> Result<()> {
let mut token_write = self.token.write().await;
if !force && token_write.is_some() {
return Ok(());
}
let token = self.request_login().await?;
let result = match token {
Some(_) => Ok(()),
None => Err(Error::WrongPassword),
};
*token_write = token;
result
}
async fn clear_token(&self, token: &str) {
let mut token_write = self.token.write().await;
if let Some(ref current_token) = *token_write {
log::debug!("Clearing token");
if current_token == token {
*token_write = None;
}
}
}
async fn request_login(&self) -> Result<Option<String>> {
log::debug!("Send login request");
let url = self
.base_url
.join("/login")
.expect("cannot build login URL");
let params = [("u", "user"), ("p", &self.password)];
let response = self.client.post(url).form(¶ms).send().await?;
let token = response
.cookies()
.find(|c| c.name() == "SolarLog")
.map(|c| c.value().to_string());
if token.is_some() {
log::debug!("Login successful, token received");
} else {
log::debug!("Login failed, no token received");
}
Ok(token)
}
async fn request_getjp(&self, token: &str, query: &str) -> Result<String> {
log::debug!("Send query request: {}", query);
let url = self
.base_url
.join("/getjp")
.expect("cannot build query URL");
let body = format!("token={};{}", token, query);
let response = self
.client
.post(url)
.header("Cookie", format!("SolarLog={}", token))
.body(body)
.send()
.await?
.error_for_status()
.map_err(Error::RequestFailed)?;
let text = response.text().await?;
if text.contains("QUERY IMPOSSIBLE") {
return Err(Error::QueryImpossible);
}
if text.contains("ACCESS DENIED") {
return Err(Error::AccessDenied);
}
log::debug!("Query response: {}", text);
Ok(text)
}
}
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::WrongPassword => false,
Error::QueryImpossible => false,
Error::AccessDenied => true, Error::RequestRejected => false, Error::LoginExpired => true, }
}
fn is_recorded_error(error: &Error) -> bool {
match error {
Error::RequestFailed(err) => !is_client_error(err), Error::WrongPassword => false, Error::QueryImpossible => false, Error::AccessDenied => false, Error::RequestRejected => false, Error::LoginExpired => false, }
}