Rust
[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
thiserror = "2" The client
use serde::Deserialize;
use std::time::Duration;
const BASE: &str = "https://api.unzoi.com";
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The key stops at its monthly credits, and this call could cost more
/// than are left. Shares a status code with the per-minute limit, and
/// unlike it, retrying does not help until the period resets.
#[error("out of monthly credits; resets in {0}s")]
OutOfCredits(u64),
#[error("unzoi: {0}")]
Status(reqwest::StatusCode),
#[error(transparent)]
Http(#[from] reqwest::Error),
}
pub struct Unzoi {
http: reqwest::Client,
key: String,
}
impl Unzoi {
pub fn new(key: impl Into<String>) -> Result<Self, Error> {
Ok(Self {
http: reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()?,
key: key.into(),
})
}
async fn get<T: serde::de::DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<T, Error> {
for attempt in 0..6u32 {
let response = self
.http
.get(format!("{BASE}{path}"))
.header("x-api-key", &self.key)
.query(params)
.send()
.await?;
let header = |name: &str| {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
match response.status() {
reqwest::StatusCode::TOO_MANY_REQUESTS => {
// Three conditions share this status, and the body's code says
// which. Out of credits for the month is the one not to retry.
let wait = header("retry-after")
.and_then(|v| v.parse().ok())
.unwrap_or(1);
let refusal: serde_json::Value = response.json().await.unwrap_or_default();
if refusal["code"] == "insufficient_credits" {
return Err(Error::OutOfCredits(wait));
}
// retry-after is when this call's credits refill, and it is
// accurate — exponential backoff here would idle on an
// allowance already paid for.
tokio::time::sleep(Duration::from_secs(wait)).await;
}
// No shard answered. Narrowing the range helps more than waiting.
reqwest::StatusCode::SERVICE_UNAVAILABLE if attempt < 3 => {
tokio::time::sleep(Duration::from_secs(1 << attempt)).await;
}
status if status.is_client_error() || status.is_server_error() => {
return Err(Error::Status(status));
}
_ => return Ok(response.json().await?),
}
}
Err(Error::Status(reqwest::StatusCode::TOO_MANY_REQUESTS))
}
pub async fn search(&self, params: &[(&str, &str)]) -> Result<SearchResponse, Error> {
self.get("/search", params).await
}
pub async fn stories(&self, params: &[(&str, &str)]) -> Result<StoriesResponse, Error> {
self.get("/stories", params).await
}
pub async fn account(&self) -> Result<Account, Error> {
self.get("/account", &[]).await
}
} Types
#[derive(Debug, Deserialize)]
pub struct Article {
pub id: String,
pub title: Option<String>,
pub url: Option<String>,
pub source: Option<String>,
pub language: Option<String>,
pub published_at: Option<String>,
pub story_id: Option<String>,
#[serde(default)]
pub topics: Vec<String>,
#[serde(default)]
pub organizations: Vec<String>,
#[serde(default)]
pub countries: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct Story {
pub story_id: String,
pub count: usize,
pub outlets: usize,
pub title: Option<String>,
pub url: Option<String>,
#[serde(default)]
pub sources: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct SearchResponse {
pub total: usize,
pub total_relation: String,
pub has_more: bool,
pub results: Vec<Article>,
/// How far back this plan may query; `None` = the full archive.
pub history_days: Option<u32>,
/// Whether a `from` you supplied was moved forward to that boundary.
pub from_clamped: bool,
}
#[derive(Debug, Deserialize)]
pub struct StoriesResponse {
pub total: usize,
pub has_more: bool,
pub stories: Vec<Story>,
pub history_days: Option<u32>,
pub from_clamped: bool,
}
#[derive(Debug, Deserialize)]
pub struct Account {
pub plan: String,
pub credits_per_minute: u32,
pub credits_limit: u64,
pub credits_remaining: Option<u64>,
pub history_days: Option<u32>,
} Using it
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let unzoi = Unzoi::new(std::env::var("UNZOI_KEY")?)?;
let result = unzoi
.stories(&[
("q", "semiconductor export controls"),
("from", "2026-08-01"),
("limit", "10"),
])
.await?;
// A plan boundary and a corpus with no coverage look identical without this.
if result.from_clamped {
eprintln!(
"window narrowed to the last {:?} days by your plan",
result.history_days
);
}
for story in &result.stories {
println!(
"{:>3} articles / {:>2} outlets {}",
story.count,
story.outlets,
story.title.as_deref().unwrap_or("(untitled)")
);
}
Ok(())
} Calling MCP instead
The official Rust SDK is rmcp — the same
crate this API's own MCP server is built on, so a client written with it is talking to a server that speaks exactly
its dialect.
Why thiserror instead of a boxed error
A library crate returning Box<dyn std::error::Error> forces every caller to downcast if they
want to branch on which failure happened, which is exactly the branch OutOfCredits exists
for — a caller that hits it wants to surface a distinct message and stop, not retry. thiserror derives
Display and std::error::Error from the enum variants directly, so
Error::OutOfCredits(_) is matchable with an ordinary match at the call site rather than
an is::<T>() downcast. anyhow is the better choice one layer up, in a binary that
only ever logs and exits — it is deliberately not used here because this is a library other crates depend on, and
a caller three crates away should not need to know this one uses anyhow internally to handle a specific variant.
Testing without the real API
wiremock or a hand-rolled hyper server bound to 127.0.0.1:0 stands in for
BASE in a #[tokio::test] — point a test-only constructor at the mock's address and assert
on the same two behaviours the Go and Python pages test for their clients: a 429 whose
body carries "code": "insufficient_credits" returns Error::OutOfCredits immediately rather
than looping, and a 429 with credits left returns successfully within roughly a second rather than backing off
exponentially. Assert on wall-clock time with tokio::time::pause() and
tokio::time::advance() rather than a real sleep in the test — otherwise the retry test
for the out-of-credits path is the slowest test in the suite for no reason, since that path should return without
ever sleeping at all.
Why multi-threaded Tokio, not current-thread
rt-multi-thread is the pragmatic default for anything that also does other work concurrently with a
call to this client — a web service handling other routes, an agent loop running other tools. A single-threaded,
current_thread runtime works fine for a script that does nothing but call Unzoi and print the result,
and starts up marginally faster, but the moment the same binary tokio::spawns a second task —
logging, a metrics exporter, another API client — a current-thread runtime serializes it behind whatever this
client is awaiting. The type signatures above do not change either way; only the #[tokio::main]
attribute's flavor does.
Why rustls, not native-tls
The Cargo.toml at the top disables reqwest's default features and opts back into only
json and rustls-tls, rather than taking the default OpenSSL-backed TLS. Two concrete
reasons, not a general preference: a pure-Rust TLS stack cross-compiles cleanly to a scratch or distroless
container without also vendoring OpenSSL headers for the target platform, and it removes an entire class of build
failure — a missing libssl-dev on a fresh CI image — that has nothing to do with this crate and
everything to do with whichever base image someone picked. The cost is that rustls does not read the
operating system's certificate store by default; if this client ever needs to trust a corporate proxy's internal
certificate authority, that certificate has to be added explicitly via rustls-native-certs rather than
picked up automatically the way OpenSSL would.