mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
226 lines
6.7 KiB
Rust
226 lines
6.7 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use crate::config::{RPC_AUTH_HEADER, RpcConfig};
|
|
use crate::metrics::{Metrics, RpcMethod, RpcOutcome, elapsed_ms};
|
|
use crate::subscription::Subscription;
|
|
use fluxer_common::secret::SecretString;
|
|
use fluxer_svc::metrics::now_ms;
|
|
use rand::RngExt as _;
|
|
use serde::Deserialize;
|
|
use serde::de::{DeserializeOwned, IgnoredAny};
|
|
use serde_json::{Value, json};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use thiserror::Error;
|
|
use tracing::warn;
|
|
|
|
const USER_BATCH_MAX: usize = 2_000;
|
|
const DELETION_BATCH_MAX: usize = 100;
|
|
|
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
|
const MAX_ATTEMPTS: u32 = 3;
|
|
const BASE_BACKOFF_MS: u64 = 200;
|
|
const MAX_BACKOFF_MS: u64 = 2_000;
|
|
const JITTER_MS: u64 = 200;
|
|
const ERROR_MESSAGE_MAX: usize = 512;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum RpcError {
|
|
#[error("internal rpc transport failed: {0}")]
|
|
Transport(#[from] reqwest::Error),
|
|
#[error("internal rpc returned {code}: {message}")]
|
|
Status { code: u16, message: String },
|
|
#[error("internal rpc response could not be decoded: {0}")]
|
|
Decode(#[from] serde_json::Error),
|
|
}
|
|
|
|
impl RpcError {
|
|
pub(crate) fn is_retryable(&self) -> bool {
|
|
match self {
|
|
Self::Transport(_) => true,
|
|
Self::Status { code, .. } => *code >= 500,
|
|
Self::Decode(_) => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct RpcClient {
|
|
http: reqwest::Client,
|
|
url: String,
|
|
auth: SecretString,
|
|
metrics: Arc<Metrics>,
|
|
}
|
|
|
|
impl RpcClient {
|
|
pub fn new(cfg: &RpcConfig, http: reqwest::Client, metrics: Arc<Metrics>) -> Self {
|
|
Self {
|
|
http,
|
|
url: cfg.url.clone(),
|
|
auth: cfg.auth_token.clone(),
|
|
metrics,
|
|
}
|
|
}
|
|
|
|
pub async fn push_service_delivery_config(&self) -> Result<Value, RpcError> {
|
|
let data: PushServiceDeliveryConfigData = self
|
|
.call(
|
|
RpcMethod::GetPushServiceDeliveryConfig,
|
|
&json!({"type": "get_push_service_delivery_config"}),
|
|
)
|
|
.await?;
|
|
Ok(data.config)
|
|
}
|
|
|
|
pub async fn badge_counts(
|
|
&self,
|
|
user_ids: &[String],
|
|
) -> Result<HashMap<String, u32>, RpcError> {
|
|
let mut counts = HashMap::new();
|
|
for batch in user_ids.chunks(USER_BATCH_MAX) {
|
|
let data: BadgeCountsData = self
|
|
.call(
|
|
RpcMethod::GetBadgeCounts,
|
|
&json!({"type": "get_badge_counts", "user_ids": batch}),
|
|
)
|
|
.await?;
|
|
counts.extend(data.badge_counts);
|
|
}
|
|
Ok(counts)
|
|
}
|
|
|
|
pub async fn push_subscriptions(
|
|
&self,
|
|
user_ids: &[String],
|
|
) -> Result<HashMap<String, Vec<Subscription>>, RpcError> {
|
|
let mut subscriptions = HashMap::new();
|
|
for batch in user_ids.chunks(USER_BATCH_MAX) {
|
|
let data: HashMap<String, Vec<Subscription>> = self
|
|
.call(
|
|
RpcMethod::GetPushSubscriptions,
|
|
&json!({"type": "get_push_subscriptions", "user_ids": batch}),
|
|
)
|
|
.await?;
|
|
subscriptions.extend(data);
|
|
}
|
|
Ok(subscriptions)
|
|
}
|
|
|
|
pub async fn delete_push_subscriptions(
|
|
&self,
|
|
subscriptions: &[(String, String)],
|
|
) -> Result<(), RpcError> {
|
|
for batch in subscriptions.chunks(DELETION_BATCH_MAX) {
|
|
let entries = batch
|
|
.iter()
|
|
.map(|(user_id, subscription_id)| {
|
|
json!({"user_id": user_id, "subscription_id": subscription_id})
|
|
})
|
|
.collect::<Vec<Value>>();
|
|
self.call::<IgnoredAny>(
|
|
RpcMethod::DeletePushSubscriptions,
|
|
&json!({"type": "delete_push_subscriptions", "subscriptions": entries}),
|
|
)
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn call<T: DeserializeOwned>(
|
|
&self,
|
|
method: RpcMethod,
|
|
body: &Value,
|
|
) -> Result<T, RpcError> {
|
|
let mut attempt = 1;
|
|
loop {
|
|
let error = match self.attempt(method, body).await {
|
|
Ok(data) => return Ok(data),
|
|
Err(error) => error,
|
|
};
|
|
if !error.is_retryable() || attempt >= MAX_ATTEMPTS {
|
|
return Err(error);
|
|
}
|
|
let delay_ms = backoff_delay_ms(attempt);
|
|
warn!(
|
|
method = method.label(),
|
|
attempt,
|
|
max_attempts = MAX_ATTEMPTS,
|
|
delay_ms,
|
|
error = %error,
|
|
"internal rpc retrying"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
|
|
attempt += 1;
|
|
}
|
|
}
|
|
|
|
async fn attempt<T: DeserializeOwned>(
|
|
&self,
|
|
method: RpcMethod,
|
|
body: &Value,
|
|
) -> Result<T, RpcError> {
|
|
let started_ms = now_ms();
|
|
let result = self.request(body).await;
|
|
let duration_ms = elapsed_ms(started_ms);
|
|
let outcome = if result.is_ok() {
|
|
RpcOutcome::Ok
|
|
} else {
|
|
RpcOutcome::Error
|
|
};
|
|
self.metrics.record_rpc(method, outcome, duration_ms);
|
|
result
|
|
}
|
|
|
|
async fn request<T: DeserializeOwned>(&self, body: &Value) -> Result<T, RpcError> {
|
|
let response = self
|
|
.http
|
|
.post(&self.url)
|
|
.header(RPC_AUTH_HEADER, self.auth.expose())
|
|
.timeout(REQUEST_TIMEOUT)
|
|
.json(body)
|
|
.send()
|
|
.await?;
|
|
let code = response.status().as_u16();
|
|
let payload = response.bytes().await?;
|
|
if !(200..300).contains(&code) {
|
|
return Err(RpcError::Status {
|
|
code,
|
|
message: error_message(&payload),
|
|
});
|
|
}
|
|
let mut envelope: HashMap<String, Value> = serde_json::from_slice(&payload)?;
|
|
let data = envelope.remove("data").unwrap_or_else(|| json!({}));
|
|
Ok(serde_json::from_value(data)?)
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PushServiceDeliveryConfigData {
|
|
config: Value,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct BadgeCountsData {
|
|
#[serde(default)]
|
|
badge_counts: HashMap<String, u32>,
|
|
}
|
|
|
|
fn backoff_delay_ms(attempt: u32) -> u64 {
|
|
let exponential = BASE_BACKOFF_MS.saturating_mul(1u64 << (attempt - 1).min(16));
|
|
exponential.min(MAX_BACKOFF_MS) + rand::rng().random_range(0..JITTER_MS)
|
|
}
|
|
|
|
fn error_message(payload: &[u8]) -> String {
|
|
serde_json::from_slice::<Value>(payload)
|
|
.ok()
|
|
.and_then(|body| {
|
|
body.get("message")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
})
|
|
.unwrap_or_else(|| String::from_utf8_lossy(payload).into_owned())
|
|
.chars()
|
|
.take(ERROR_MESSAGE_MAX)
|
|
.collect()
|
|
}
|