use reqwest::Method;
use reqwest::header::HeaderMap;
use crate::error::{Error, Result};
#[derive(Debug)]
pub(crate) struct RawResponse {
#[allow(dead_code)]
pub status: u16,
pub headers: HeaderMap,
pub body: bytes::Bytes,
}
pub(crate) fn decode_json<T: serde::de::DeserializeOwned>(body: &[u8]) -> Result<T> {
serde_json::from_slice(body).map_err(|e| Error::Decode(e.to_string()))
}
pub(crate) fn room_path(room: &str, suffix: &str) -> String {
format!("/chat/v4/rooms/{}{}", urlencoding::encode(room), suffix)
}
pub(crate) fn message_path(room: &str, serial: &str, suffix: &str) -> String {
format!(
"/chat/v4/rooms/{}/messages/{}{}",
urlencoding::encode(room),
urlencoding::encode(serial),
suffix
)
}
impl crate::client::Inner {
fn retry_eligible(method: &Method, has_idempotency_key: bool) -> bool {
matches!(*method, Method::GET | Method::DELETE) || has_idempotency_key
}
pub(crate) async fn send(
&self,
method: Method,
path: &str,
query: &[(&str, String)],
body: Option<serde_json::Value>,
has_idem: bool,
) -> Result<RawResponse> {
let url = format!("{}{}", self.base, path);
self.send_url(method, url, query, body, has_idem).await
}
pub(crate) async fn send_url(
&self,
method: Method,
url: String,
query: &[(&str, String)],
body: Option<serde_json::Value>,
has_idem: bool,
) -> Result<RawResponse> {
let eligible = Self::retry_eligible(&method, has_idem);
let mut attempt = 0;
let mut auth_refreshed = false;
loop {
let auth = self.auth_header(None).await?;
let mut req = self
.http
.request(method.clone(), &url)
.header("X-Ably-Version", "4")
.header(reqwest::header::AUTHORIZATION, &auth);
if !query.is_empty() {
req = req.query(query);
}
if let Some(b) = &body {
req = req.json(b);
}
match req.send().await {
Ok(r) => {
let status = r.status().as_u16();
if (status == 429 || (500..=599).contains(&status))
&& eligible
&& attempt < self.max_retries
{
attempt += 1;
continue;
}
let headers = r.headers().clone();
let bytes = r.bytes().await?;
if (200..300).contains(&status) {
return Ok(RawResponse {
status,
headers,
body: bytes,
});
}
let err = Error::from_api_body(status, &bytes);
if !auth_refreshed
&& err.is_token_error()
&& matches!(&self.auth, crate::client::AuthState::Provider { .. })
{
auth_refreshed = true;
self.auth_header(Some(auth.as_str())).await?;
continue;
}
return Err(err);
}
Err(e) => {
if e.is_timeout() && eligible && attempt < self.max_retries {
attempt += 1;
continue;
}
return Err(e.into());
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{Client, Inner};
use crate::config::Auth;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn path_helpers_url_encode_segments() {
assert_eq!(
room_path("my room", "/occupancy"),
"/chat/v4/rooms/my%20room/occupancy"
);
assert_eq!(
message_path("r", "01ts-001@abc:001", "/versions"),
"/chat/v4/rooms/r/messages/01ts-001%40abc%3A001/versions"
);
}
#[test]
fn retry_eligible_predicate() {
assert!(Inner::retry_eligible(&Method::GET, false));
assert!(Inner::retry_eligible(&Method::DELETE, false));
assert!(!Inner::retry_eligible(&Method::POST, false));
assert!(Inner::retry_eligible(&Method::POST, true));
assert!(!Inner::retry_eligible(&Method::PUT, false));
assert!(Inner::retry_eligible(&Method::PUT, true));
}
#[tokio::test]
async fn sends_version_and_auth_headers_and_maps_api_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/chat/v4/rooms/r/occupancy"))
.and(header("x-ably-version", "4"))
.and(header("authorization", "Basic YXBwLms6cw=="))
.respond_with(
ResponseTemplate::new(404)
.set_body_string(r#"{"error":{"code":40400,"message":"no","statusCode":404}}"#),
)
.expect(1)
.mount(&server)
.await;
let client = Client::builder(Auth::api_key("app.k:s"))
.host(server.uri())
.build();
let err = client
.inner
.send(Method::GET, "/chat/v4/rooms/r/occupancy", &[], None, false)
.await
.unwrap_err();
assert!(err.is_not_found());
assert_eq!(err.status(), Some(404));
}
#[tokio::test]
async fn success_returns_status_and_body_bytes() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/chat/v4/rooms/r/occupancy"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"connections":3,"presenceMembers":2}"#),
)
.mount(&server)
.await;
let client = Client::builder(Auth::api_key("k:s"))
.host(server.uri())
.build();
let r = client
.inner
.send(Method::GET, "/chat/v4/rooms/r/occupancy", &[], None, false)
.await
.unwrap();
assert_eq!(r.status, 200);
let occ: crate::types::Occupancy = serde_json::from_slice(&r.body).unwrap();
assert_eq!(occ.connections, 3);
assert_eq!(occ.presence_members, 2);
}
#[tokio::test]
async fn provider_refreshes_once_on_token_error_then_succeeds() {
use crate::config::{Auth, TokenProvider};
use futures::future::BoxFuture;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/chat/v4/rooms/r/occupancy"))
.and(header("authorization", "Bearer stale"))
.respond_with(ResponseTemplate::new(401).set_body_string(
r#"{"error":{"code":40142,"message":"expired","statusCode":401}}"#,
))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/chat/v4/rooms/r/occupancy"))
.and(header("authorization", "Bearer fresh"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"connections":1,"presenceMembers":0}"#),
)
.mount(&server)
.await;
struct Rotating(Arc<AtomicUsize>);
impl TokenProvider for Rotating {
fn token(&self) -> BoxFuture<'_, crate::error::Result<String>> {
let n = self.0.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { Ok(if n == 0 { "stale" } else { "fresh" }.to_string()) })
}
}
let client = Client::builder(Auth::provider(Arc::new(Rotating(Arc::new(
AtomicUsize::new(0),
)))))
.host(server.uri())
.build();
let r = client
.inner
.send(Method::GET, "/chat/v4/rooms/r/occupancy", &[], None, false)
.await;
assert!(r.is_ok(), "should succeed after one refresh: {r:?}");
}
#[tokio::test]
async fn provider_does_not_loop_on_persistent_token_error() {
use crate::config::{Auth, TokenProvider};
use futures::future::BoxFuture;
use std::sync::Arc;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/chat/v4/rooms/r/occupancy"))
.respond_with(ResponseTemplate::new(401).set_body_string(
r#"{"error":{"code":40142,"message":"expired","statusCode":401}}"#,
))
.expect(2) .mount(&server)
.await;
struct Always;
impl TokenProvider for Always {
fn token(&self) -> BoxFuture<'_, crate::error::Result<String>> {
Box::pin(async { Ok("t".to_string()) })
}
}
let client = Client::builder(Auth::provider(Arc::new(Always)))
.host(server.uri())
.build();
let err = client
.inner
.send(Method::GET, "/chat/v4/rooms/r/occupancy", &[], None, false)
.await
.unwrap_err();
assert_eq!(err.status(), Some(401));
assert!(err.is_token_error());
}
#[tokio::test]
async fn static_auth_does_not_retry_on_token_error() {
use crate::config::Auth;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/chat/v4/rooms/r/occupancy"))
.respond_with(ResponseTemplate::new(401).set_body_string(
r#"{"error":{"code":40142,"message":"expired","statusCode":401}}"#,
))
.expect(1) .mount(&server)
.await;
let client = Client::builder(Auth::api_key("app.k:s"))
.host(server.uri())
.build();
let err = client
.inner
.send(Method::GET, "/chat/v4/rooms/r/occupancy", &[], None, false)
.await
.unwrap_err();
assert_eq!(err.status(), Some(401));
assert!(err.is_token_error());
}
}