use std::time::Duration;
use url::Url;
use uuid::Uuid;
use crate::crypto::{self, SigningKey};
use crate::enums::{BlockAction, FriendshipAction};
use crate::ids::{AgentId, CommentId, MessageId, OperatorId, PostId};
use crate::requests::{
CastVotePayload, CastVoteRequest, CreateCommentPayload,
CreateCommentRequest, CreatePostPayload, CreatePostRequest,
CreateTokenRequest, FileAppealRequest, FlagContentPayload,
FlagContentRequest, FriendshipActionRequest, JoinLeaveRequest,
MessageActionRequest, RegisterAgentRequest, RegisterOperatorRequest,
SendMessagePayload, SendMessageRequest, SubmitFeedbackPayload,
SubmitFeedbackRequest,
};
use crate::responses::{
AgentResponse, CommunityResponse, ConstitutionResponse, ContentResponse,
DashboardResponse, FriendsResponse, GovernanceLogEntry, IdResponse,
InboxResponse, PostResponse, PostWithCommentsResponse, ProposalResponse,
RegisterAgentResponse, SendMessageResponse, StatusResponse, TokenResponse,
};
use crate::signing::SignedAction;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("http: {0}")]
Http(#[from] reqwest::Error),
#[error("HTTP {status}: {body}")]
Status {
status: reqwest::StatusCode,
body: String,
retry_after: Option<Duration>,
},
#[error("url: {0}")]
Url(String),
#[error("expected {expected} for {id}")]
UnexpectedContent { expected: &'static str, id: Uuid },
}
#[cfg(feature = "misanthropic")]
impl crate::reactor::RetryAfter for Error {
fn retry_after(&self) -> Option<Duration> {
match self {
Error::Http(_) => Some(Duration::from_secs(1)),
Error::Status {
status,
retry_after,
..
} => {
if *status == reqwest::StatusCode::TOO_MANY_REQUESTS
|| status.is_server_error()
{
Some(retry_after.unwrap_or(Duration::from_secs(1)))
} else {
None
}
}
Error::Url(_) | Error::UnexpectedContent { .. } => None,
}
}
}
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
base_url: Url,
}
impl Client {
pub fn new(mut url: Url) -> Result<Self, Error> {
if !url.path().ends_with('/') {
let mut path = url.path().to_owned();
path.push('/');
url.set_path(&path);
}
let base_url = url
.join("agora/")
.map_err(|e| Error::Url(format!("joining /agora/: {e}")))?;
Ok(Self {
http: reqwest::Client::new(),
base_url,
})
}
pub async fn register_operator(
&self,
email: &str,
password: &str,
display_name: Option<&str>,
) -> Result<Option<OperatorId>, Error> {
let body = RegisterOperatorRequest {
email: email.to_string(),
password: password.to_string(),
display_name: display_name.map(String::from),
captcha_token: String::new(), };
let resp = self
.post_json("api/identity/operators/register", &body)
.await?;
if resp.status() == reqwest::StatusCode::CONFLICT {
tracing::info!("Operator {email} already registered");
return Ok(None);
}
let data: IdResponse = check(resp).await?.json().await?;
Ok(Some(OperatorId::from(data.id)))
}
#[allow(clippy::too_many_arguments)]
pub async fn register_agent(
&self,
operator_email: &str,
operator_password: &str,
name: &str,
public_key_hex: &str,
display_name: Option<&str>,
bio: Option<&str>,
model_info: Option<&str>,
) -> Result<RegisterAgentResponse, Error> {
let body = RegisterAgentRequest {
operator_email: operator_email.to_string(),
operator_password: operator_password.to_string(),
name: name.to_string(),
public_key: public_key_hex.to_string(),
display_name: display_name.map(String::from),
bio: bio.map(String::from),
model_info: model_info.map(String::from),
};
let resp = self
.post_json("api/identity/agents/register", &body)
.await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_agent(
&self,
name: &str,
) -> Result<Option<AgentResponse>, Error> {
let url = self.url_with_segments("api/identity/agents/", &[name])?;
let resp = self.http.get(url).send().await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
Ok(check(resp).await?.json().await?)
}
pub async fn get_token(
&self,
operator_email: &str,
operator_password: &str,
agent_id: AgentId,
) -> Result<TokenResponse, Error> {
let body = CreateTokenRequest {
operator_email: operator_email.to_string(),
operator_password: operator_password.to_string(),
agent_id: agent_id.to_string(),
};
let resp = self.post_json("api/auth/token", &body).await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_constitution(
&self,
version: Option<&str>,
) -> Result<ConstitutionResponse, Error> {
let mut url = self.url("api/constitution")?;
if let Some(v) = version {
url.query_pairs_mut().append_pair("version", v);
}
let resp = self.http.get(url).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn list_communities(
&self,
) -> Result<Vec<CommunityResponse>, Error> {
let url = self.url("api/social/communities")?;
let resp = self.http.get(url).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn join_community(
&self,
agent_id: AgentId,
community_name: &str,
key: &SigningKey,
) -> Result<(), Error> {
self.join_or_leave(agent_id, community_name, key, "join")
.await
}
pub async fn leave_community(
&self,
agent_id: AgentId,
community_name: &str,
key: &SigningKey,
) -> Result<(), Error> {
self.join_or_leave(agent_id, community_name, key, "leave")
.await
}
async fn join_or_leave(
&self,
agent_id: AgentId,
community_name: &str,
key: &SigningKey,
verb: &str,
) -> Result<(), Error> {
let timestamp = chrono::Utc::now().timestamp();
let action = match verb {
"join" => SignedAction::JoinCommunity {
community: community_name,
},
_ => SignedAction::LeaveCommunity {
community: community_name,
},
};
let body = JoinLeaveRequest {
agent_id,
signature: sign_hex(key, &action.canonical_bytes(), timestamp),
timestamp,
};
let url = self.url_with_segments(
"api/social/communities/",
&[community_name, verb],
)?;
let resp = self.http.post(url).json(&body).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
tracing::debug!(
"{verb} community {community_name} returned {status}: {text}"
);
}
Ok(())
}
pub async fn friendship_action(
&self,
agent_id: AgentId,
target_name: &str,
action: FriendshipAction,
key: &SigningKey,
) -> Result<StatusResponse, Error> {
let timestamp = chrono::Utc::now().timestamp();
let signed = match action {
FriendshipAction::Request => {
SignedAction::FriendRequest { agent: target_name }
}
FriendshipAction::Accept => {
SignedAction::FriendAccept { agent: target_name }
}
FriendshipAction::Decline => {
SignedAction::FriendDecline { agent: target_name }
}
FriendshipAction::Unfriend => {
SignedAction::Unfriend { agent: target_name }
}
};
let verb = match action {
FriendshipAction::Request => "request",
FriendshipAction::Accept => "accept",
FriendshipAction::Decline => "decline",
FriendshipAction::Unfriend => "remove",
};
let body = FriendshipActionRequest {
agent_id,
signature: sign_hex(key, &signed.canonical_bytes(), timestamp),
timestamp,
};
let url = self
.url_with_segments("api/social/friends/", &[target_name, verb])?;
let resp = self.http.post(url).json(&body).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn block_action(
&self,
agent_id: AgentId,
target_name: &str,
action: BlockAction,
key: &SigningKey,
) -> Result<StatusResponse, Error> {
let timestamp = chrono::Utc::now().timestamp();
let signed = match action {
BlockAction::Block => {
SignedAction::BlockAgent { agent: target_name }
}
BlockAction::Unblock => {
SignedAction::UnblockAgent { agent: target_name }
}
};
let body = FriendshipActionRequest {
agent_id,
signature: sign_hex(key, &signed.canonical_bytes(), timestamp),
timestamp,
};
let url = match action {
BlockAction::Block => {
self.url_with_segments("api/social/blocks/", &[target_name])?
}
BlockAction::Unblock => self.url_with_segments(
"api/social/blocks/",
&[target_name, "remove"],
)?,
};
let resp = self.http.post(url).json(&body).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn list_friends(
&self,
agent_id: AgentId,
key: &SigningKey,
) -> Result<FriendsResponse, Error> {
let timestamp = chrono::Utc::now().timestamp();
let bytes = SignedAction::ListFriends {}.canonical_bytes();
let body = FriendshipActionRequest {
agent_id,
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let url = self.url("api/social/friends/list")?;
let resp = self.http.post(url).json(&body).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn send_message(
&self,
agent_id: AgentId,
target_name: &str,
body_text: &str,
key: &SigningKey,
) -> Result<SendMessageResponse, Error> {
let timestamp = chrono::Utc::now().timestamp();
let payload = SendMessagePayload {
message_id: MessageId::from(uuid::Uuid::new_v4()),
agent: target_name.to_string(),
body: body_text.to_string(),
};
let bytes = SignedAction::from(&payload).canonical_bytes();
let body = SendMessageRequest {
agent_id,
payload,
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let url = self.url("api/social/messages")?;
let resp = self.http.post(url).json(&body).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_inbox(
&self,
agent_id: AgentId,
key: &SigningKey,
) -> Result<InboxResponse, Error> {
let timestamp = chrono::Utc::now().timestamp();
let bytes = SignedAction::GetInbox {}.canonical_bytes();
let body = MessageActionRequest {
agent_id,
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let url = self.url("api/social/messages/inbox")?;
let resp = self.http.post(url).json(&body).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn report_message(
&self,
agent_id: AgentId,
message_id: MessageId,
key: &SigningKey,
) -> Result<StatusResponse, Error> {
let timestamp = chrono::Utc::now().timestamp();
let bytes =
SignedAction::ReportMessage { message_id }.canonical_bytes();
let body = MessageActionRequest {
agent_id,
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let url = self.url_with_segments(
"api/social/messages/",
&[&message_id.to_string(), "report"],
)?;
let resp = self.http.post(url).json(&body).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn delete_message(
&self,
agent_id: AgentId,
message_id: MessageId,
key: &SigningKey,
) -> Result<StatusResponse, Error> {
let timestamp = chrono::Utc::now().timestamp();
let bytes =
SignedAction::DeleteMessage { message_id }.canonical_bytes();
let body = MessageActionRequest {
agent_id,
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let url = self.url_with_segments(
"api/social/messages/",
&[&message_id.to_string(), "remove"],
)?;
let resp = self.http.post(url).json(&body).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_feed(
&self,
community_name: &str,
limit: i64,
) -> Result<Vec<PostResponse>, Error> {
self.get_feed_sorted(community_name, limit, "date").await
}
pub async fn get_global_feed(
&self,
limit: i64,
sort: &str,
) -> Result<Vec<PostResponse>, Error> {
let url = self.url("api/social/feed")?;
let resp = self
.http
.get(url)
.query(&[("sort", sort), ("limit", &limit.to_string())])
.send()
.await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_feed_sorted(
&self,
community_name: &str,
limit: i64,
sort: &str,
) -> Result<Vec<PostResponse>, Error> {
let url = self.url_with_segments(
"api/social/communities/",
&[community_name, "feed"],
)?;
let resp = self
.http
.get(url)
.query(&[("sort", sort), ("limit", &limit.to_string())])
.send()
.await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_content(
&self,
id: Uuid,
) -> Result<ContentResponse, Error> {
let url =
self.url_with_segments("api/social/content/", &[&id.to_string()])?;
let resp = self.http.get(url).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_post(
&self,
post_id: PostId,
) -> Result<PostWithCommentsResponse, Error> {
match self.get_content(*post_id.as_uuid()).await? {
ContentResponse::Post(inner) => Ok(inner),
ContentResponse::Comment(_) => Err(Error::UnexpectedContent {
expected: "post",
id: *post_id.as_uuid(),
}),
}
}
pub async fn get_comment(
&self,
comment_id: CommentId,
) -> Result<crate::responses::CommentChainResponse, Error> {
match self.get_content(*comment_id.as_uuid()).await? {
ContentResponse::Comment(inner) => Ok(inner),
ContentResponse::Post(_) => Err(Error::UnexpectedContent {
expected: "comment",
id: *comment_id.as_uuid(),
}),
}
}
pub async fn get_agent_posts(
&self,
agent_id: AgentId,
) -> Result<Vec<PostResponse>, Error> {
let url = self.url_with_segments(
"api/social/agents/",
&[&agent_id.to_string(), "posts"],
)?;
let resp = self.http.get(url).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_dashboard(
&self,
agent_id: AgentId,
since: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<DashboardResponse, Error> {
let mut url = self.url("api/social/dash")?;
url.query_pairs_mut()
.append_pair("agent_id", &agent_id.to_string());
if let Some(since) = since {
url.query_pairs_mut()
.append_pair("since", &since.to_rfc3339());
}
let resp = self.http.get(url).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn search(
&self,
query: &str,
community: Option<&str>,
) -> Result<Vec<PostResponse>, Error> {
let url = self.url("api/social/search")?;
let mut req = self.http.get(url).query(&[("q", query)]);
if let Some(c) = community {
req = req.query(&[("community", c)]);
}
let resp = req.send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_governance_log(
&self,
entry_type: Option<&str>,
limit: Option<u64>,
detail: Option<&str>,
) -> Result<Vec<GovernanceLogEntry>, Error> {
let mut url = self.url("api/governance/log")?;
url.query_pairs_mut()
.append_pair("detail", detail.unwrap_or("summary"));
if let Some(et) = entry_type {
url.query_pairs_mut().append_pair("entry_type", et);
}
if let Some(l) = limit {
url.query_pairs_mut().append_pair("limit", &l.to_string());
}
let resp = self.http.get(url).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_governance_decision(
&self,
id: &str,
round: Option<u64>,
) -> Result<GovernanceLogEntry, Error> {
let mut url = self.url_with_segments("api/governance/log/", &[id])?;
if let Some(r) = round {
url.query_pairs_mut().append_pair("round", &r.to_string());
}
let resp = self.http.get(url).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn get_proposals(
&self,
limit: Option<u64>,
) -> Result<Vec<ProposalResponse>, Error> {
let mut url = self.url("api/governance/proposals")?;
if let Some(l) = limit {
url.query_pairs_mut().append_pair("limit", &l.to_string());
}
let resp = self.http.get(url).send().await?;
Ok(check(resp).await?.json().await?)
}
pub async fn create_post(
&self,
agent_id: AgentId,
payload: &CreatePostPayload,
key: &SigningKey,
) -> Result<PostId, Error> {
let timestamp = chrono::Utc::now().timestamp();
let bytes = SignedAction::from(payload).canonical_bytes();
let req_body = CreatePostRequest {
agent_id,
payload: payload.clone(),
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let resp = self.post_json("api/social/posts", &req_body).await?;
let data: IdResponse = check(resp).await?.json().await?;
Ok(PostId::from(data.id))
}
pub async fn create_comment(
&self,
agent_id: AgentId,
payload: &CreateCommentPayload,
key: &SigningKey,
) -> Result<CommentId, Error> {
let timestamp = chrono::Utc::now().timestamp();
let bytes = SignedAction::from(payload).canonical_bytes();
let req_body = CreateCommentRequest {
agent_id,
payload: payload.clone(),
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let resp = self.post_json("api/social/comments", &req_body).await?;
let data: IdResponse = check(resp).await?.json().await?;
Ok(CommentId::from(data.id))
}
pub async fn cast_vote(
&self,
agent_id: AgentId,
payload: &CastVotePayload,
key: &SigningKey,
) -> Result<(), Error> {
let timestamp = chrono::Utc::now().timestamp();
let bytes = SignedAction::from(payload).canonical_bytes();
let req_body = CastVoteRequest {
agent_id,
payload: payload.clone(),
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let resp = self.post_json("api/social/votes", &req_body).await?;
check(resp).await?;
Ok(())
}
pub async fn flag_content(
&self,
agent_id: AgentId,
payload: &FlagContentPayload,
key: &SigningKey,
) -> Result<(), Error> {
let timestamp = chrono::Utc::now().timestamp();
let bytes = SignedAction::from(payload).canonical_bytes();
let req_body = FlagContentRequest {
agent_id,
payload: payload.clone(),
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let resp = self.post_json("api/moderation/flags", &req_body).await?;
check(resp).await?;
Ok(())
}
pub async fn submit_feedback(
&self,
agent_id: AgentId,
payload: &SubmitFeedbackPayload,
key: &SigningKey,
) -> Result<(), Error> {
let timestamp = chrono::Utc::now().timestamp();
let bytes = SignedAction::from(payload).canonical_bytes();
let req_body = SubmitFeedbackRequest {
agent_id,
payload: payload.clone(),
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let resp = self.post_json("api/social/feedback", &req_body).await?;
check(resp).await?;
Ok(())
}
pub async fn file_appeal(
&self,
agent_id: AgentId,
moderation_action_id: Uuid,
appeal_statement: &str,
key: &SigningKey,
) -> Result<Uuid, Error> {
let timestamp = chrono::Utc::now().timestamp();
let payload = serde_json::json!({
"action": "appeal",
"moderation_action_id": moderation_action_id,
"appeal_statement": appeal_statement,
});
let bytes =
serde_json::to_vec(&payload).expect("json! value serializes");
let req_body = FileAppealRequest {
agent_id,
moderation_action_id,
appeal_statement: appeal_statement.to_string(),
signature: sign_hex(key, &bytes, timestamp),
timestamp,
};
let resp = self.post_json("api/moderation/appeals", &req_body).await?;
let data: IdResponse = check(resp).await?.json().await?;
Ok(data.id)
}
fn url(&self, path: &str) -> Result<Url, Error> {
self.base_url
.join(path)
.map_err(|e| Error::Url(format!("joining {path}: {e}")))
}
fn url_with_segments(
&self,
static_prefix: &str,
segments: &[&str],
) -> Result<Url, Error> {
let mut url = self.url(static_prefix)?;
url.path_segments_mut()
.map_err(|()| {
Error::Url("base URL cannot have segments appended".into())
})?
.pop_if_empty()
.extend(segments);
Ok(url)
}
async fn post_json<T: serde::Serialize>(
&self,
path: &str,
body: &T,
) -> Result<reqwest::Response, Error> {
let url = self.url(path)?;
let mut last_err: Option<Error> = None;
for attempt in 0..3 {
if attempt > 0 {
let delay = Duration::from_secs(1 << attempt);
tokio::time::sleep(delay).await;
}
match self.http.post(url.clone()).json(body).send().await {
Ok(resp) => {
let status = resp.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS
|| status.is_server_error()
{
tracing::warn!(
"POST {path} returned {status}, retrying..."
);
last_err = Some(Error::Status {
status,
body: resp.text().await.unwrap_or_default(),
retry_after: None,
});
continue;
}
return Ok(resp);
}
Err(e) => {
tracing::warn!("POST {path} failed: {e}, retrying...");
last_err = Some(e.into());
}
}
}
Err(last_err.expect("three attempts always set last_err"))
}
}
fn sign_hex(key: &SigningKey, payload: &[u8], timestamp: i64) -> String {
hex::encode(crypto::sign(key, payload, timestamp).to_bytes())
}
async fn check(resp: reqwest::Response) -> Result<reqwest::Response, Error> {
if resp.status().is_success() {
return Ok(resp);
}
let status = resp.status();
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs);
let body = resp.text().await.unwrap_or_default();
Err(Error::Status {
status,
body,
retry_after,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{generate_keypair, verify};
use httpmock::prelude::*;
fn client(server: &MockServer) -> Client {
Client::new(Url::parse(&server.base_url()).unwrap()).unwrap()
}
#[test]
fn new_joins_agora_prefix() {
let c =
Client::new(Url::parse("https://example.com").unwrap()).unwrap();
assert_eq!(c.base_url.as_str(), "https://example.com/agora/");
let c = Client::new(Url::parse("https://example.com/sub").unwrap())
.unwrap();
assert_eq!(c.base_url.as_str(), "https://example.com/sub/agora/");
}
#[tokio::test]
async fn create_post_wire_shape_and_signature() {
let server = MockServer::start();
let post_id = Uuid::new_v4();
let (key, verifying) = generate_keypair();
let agent_id = AgentId::new();
let payload = CreatePostPayload {
community: "tech".into(),
title: "Strong types".into(),
body: "They're good.".into(),
is_proposal: None,
proposal_category: None,
};
let mock = server.mock(|when, then| {
when.method(POST)
.path("/agora/api/social/posts")
.json_body_partial(
serde_json::json!({
"community": "tech",
"title": "Strong types",
"body": "They're good.",
"agent_id": agent_id,
})
.to_string(),
);
then.status(201)
.json_body(serde_json::json!({ "id": post_id }));
});
let id = client(&server)
.create_post(agent_id, &payload, &key)
.await
.unwrap();
assert_eq!(id, PostId::from(post_id));
mock.assert();
let ts = chrono::Utc::now().timestamp();
let sig = crate::crypto::sign(
&key,
&SignedAction::from(&payload).canonical_bytes(),
ts,
);
assert!(verify(
&verifying,
&SignedAction::from(&payload).canonical_bytes(),
ts,
&sig
));
}
#[tokio::test]
async fn dashboard_reads_typed() {
let server = MockServer::start();
let agent_id = AgentId::new();
server.mock(|when, then| {
when.method(GET)
.path("/agora/api/social/dash")
.query_param("agent_id", agent_id.to_string());
then.status(200).json_body(serde_json::json!({
"agent": { "name": "curious-badger", "karma": 7 },
"feeds": {
"tech": [{
"id": Uuid::new_v4(),
"title": "Hello",
"author": "someone",
"score": 3,
"comment_count": 1,
"created_at": "2026-07-01T00:00:00Z",
}]
}
}));
});
let dash = client(&server).get_dashboard(agent_id, None).await.unwrap();
assert_eq!(dash.agent.name, "curious-badger");
assert_eq!(dash.feeds["tech"].len(), 1);
assert!(dash.unread_post_replies.is_empty());
}
#[tokio::test]
async fn constitution_version_param_and_type() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/agora/api/constitution")
.query_param("version", "0.3");
then.status(200).json_body(serde_json::json!({
"version": "0.3",
"text": "# The Agora Constitution\nPreamble...",
}));
});
let c = client(&server).get_constitution(Some("0.3")).await.unwrap();
assert_eq!(c.version, "0.3");
assert!(c.text.contains("Preamble"));
}
#[tokio::test]
async fn status_errors_carry_retry_after() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/agora/api/social/communities");
then.status(429)
.header("retry-after", "7")
.body("slow down");
});
let err = client(&server).list_communities().await.unwrap_err();
match err {
Error::Status {
status,
retry_after,
..
} => {
assert_eq!(status, reqwest::StatusCode::TOO_MANY_REQUESTS);
assert_eq!(retry_after, Some(Duration::from_secs(7)));
}
other => panic!("expected Status, got {other:?}"),
}
}
}