use std::collections::HashMap;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, OnceCell};
use tracing::{debug, warn};
const BASE_URL: &str = "https://www.pathofexile.com";
const USER_AGENT: &str = "OAuth poe2-agent/0.4.0 (contact: github.com/SFerenczy/poe2-agent)";
#[derive(Debug, thiserror::Error)]
pub enum TradeError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("rate limited — retry after {0:?}")]
RateLimited(Duration),
#[error("API error {code}: {message}")]
Api { code: u64, message: String },
#[error("failed to parse response JSON: {0}")]
Parse(#[from] serde_json::Error),
#[error("no results found")]
NoResults,
}
#[derive(Debug, Deserialize)]
pub struct SearchResponse {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub total: u64,
#[serde(default)]
pub result: Vec<String>,
#[serde(default)]
pub error: Option<ApiError>,
}
#[derive(Debug, Deserialize)]
pub struct FetchResponse {
#[serde(default)]
pub result: Vec<FetchedItem>,
}
#[derive(Debug, Deserialize)]
pub struct FetchedItem {
#[serde(default)]
pub listing: Listing,
#[serde(default)]
pub item: ItemInfo,
}
#[derive(Debug, Default, Deserialize)]
pub struct Listing {
#[serde(default)]
pub price: Option<Price>,
}
#[derive(Debug, Deserialize)]
pub struct Price {
#[serde(default)]
pub amount: f64,
#[serde(default)]
pub currency: String,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ItemInfo {
#[serde(default)]
pub name: String,
#[serde(default)]
pub type_line: String,
#[serde(default)]
pub base_type: String,
#[serde(default)]
pub ilvl: u32,
#[serde(default)]
pub frame_type: u8,
#[serde(default)]
pub explicit_mods: Vec<String>,
#[serde(default)]
pub implicit_mods: Vec<String>,
}
impl ItemInfo {
pub fn rarity(&self) -> &'static str {
match self.frame_type {
0 => "Normal",
1 => "Magic",
2 => "Rare",
3 => "Unique",
_ => "Unknown",
}
}
pub fn display_name(&self) -> String {
if self.name.is_empty() {
self.type_line.clone()
} else {
format!("{} {}", self.name, self.type_line)
}
}
}
#[derive(Debug, Deserialize)]
pub struct ExchangeResponse {
#[serde(default)]
pub result: HashMap<String, ExchangeEntry>,
#[serde(default)]
pub total: u64,
#[serde(default)]
pub error: Option<ApiError>,
}
#[derive(Debug, Deserialize)]
pub struct ExchangeEntry {
#[serde(default)]
pub listing: ExchangeListing,
}
#[derive(Debug, Default, Deserialize)]
pub struct ExchangeListing {
#[serde(default)]
pub offers: Vec<ExchangeOffer>,
}
#[derive(Debug, Deserialize)]
pub struct ExchangeOffer {
#[serde(default)]
pub exchange: ExchangeSide,
#[serde(default)]
pub item: ExchangeItemSide,
}
#[derive(Debug, Default, Deserialize)]
pub struct ExchangeSide {
#[serde(default)]
pub currency: String,
#[serde(default)]
pub amount: f64,
}
#[derive(Debug, Default, Deserialize)]
pub struct ExchangeItemSide {
#[serde(default)]
pub currency: String,
#[serde(default)]
pub amount: f64,
#[serde(default)]
pub stock: u64,
}
#[derive(Debug, Deserialize)]
pub struct ApiError {
#[serde(default)]
pub code: u64,
#[serde(default)]
pub message: String,
}
#[derive(Debug, Deserialize)]
pub struct LeagueEntry {
pub id: String,
#[serde(default)]
pub realm: String,
#[serde(default)]
pub text: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StatGroup {
#[serde(default)]
pub label: String,
#[serde(default)]
pub entries: Vec<StatEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StatEntry {
pub id: String,
pub text: String,
#[serde(rename = "type", default)]
pub stat_type: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatFilter {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<StatFilterValue>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatFilterValue {
#[serde(skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
}
#[derive(Debug)]
pub struct RateLimitTracker {
windows: Vec<RateLimitWindow>,
last_updated: Instant,
}
#[derive(Debug, Clone)]
struct RateLimitWindow {
max_hits: u64,
#[allow(dead_code)]
period_secs: u64,
#[allow(dead_code)]
penalty_secs: u64,
current_hits: u64,
current_period: u64,
penalty_remaining: u64,
}
impl RateLimitTracker {
fn new() -> Self {
Self {
windows: Vec::new(),
last_updated: Instant::now(),
}
}
fn parse_limits(header: &str) -> Vec<(u64, u64, u64)> {
header
.split(',')
.filter_map(|w| {
let parts: Vec<&str> = w.trim().split(':').collect();
if parts.len() == 3 {
Some((
parts[0].parse().ok()?,
parts[1].parse().ok()?,
parts[2].parse().ok()?,
))
} else {
None
}
})
.collect()
}
fn parse_state(header: &str) -> Vec<(u64, u64, u64)> {
Self::parse_limits(header)
}
fn update_from_headers(&mut self, limits_header: &str, state_header: &str) {
let limits = Self::parse_limits(limits_header);
let states = Self::parse_state(state_header);
self.windows.clear();
for (i, (max_hits, period, penalty)) in limits.iter().enumerate() {
let (current, current_period, penalty_remaining) =
states.get(i).copied().unwrap_or((0, *period, 0));
self.windows.push(RateLimitWindow {
max_hits: *max_hits,
period_secs: *period,
penalty_secs: *penalty,
current_hits: current,
current_period,
penalty_remaining,
});
}
self.last_updated = Instant::now();
}
fn check_wait(&self) -> Option<Duration> {
let mut max_wait = Duration::ZERO;
for w in &self.windows {
if w.penalty_remaining > 0 {
let penalty = Duration::from_secs(w.penalty_remaining);
if penalty > max_wait {
max_wait = penalty;
}
continue;
}
if w.current_hits >= w.max_hits {
let elapsed = self.last_updated.elapsed();
let window_duration = Duration::from_secs(w.current_period);
if elapsed < window_duration {
let remaining = window_duration - elapsed;
if remaining > max_wait {
max_wait = remaining;
}
}
}
}
if max_wait > Duration::ZERO {
Some(max_wait)
} else {
None
}
}
}
fn update_rate_limits(
rate_limiters: &mut HashMap<String, RateLimitTracker>,
headers: &reqwest::header::HeaderMap,
) {
let policy = match headers
.get("x-rate-limit-policy")
.and_then(|v| v.to_str().ok())
{
Some(p) => p.to_string(),
None => return,
};
let rules = match headers
.get("x-rate-limit-rules")
.and_then(|v| v.to_str().ok())
{
Some(r) => r.to_string(),
None => return,
};
let tracker = rate_limiters
.entry(policy.clone())
.or_insert_with(RateLimitTracker::new);
for rule in rules.split(',') {
let rule = rule.trim();
let limits_key = format!("x-rate-limit-{}", rule.to_lowercase());
let state_key = format!("x-rate-limit-{}-state", rule.to_lowercase());
let limits = headers
.get(limits_key.as_str())
.and_then(|v| v.to_str().ok());
let state = headers
.get(state_key.as_str())
.and_then(|v| v.to_str().ok());
if let (Some(l), Some(s)) = (limits, state) {
tracker.update_from_headers(l, s);
}
}
}
pub struct SearchParams {
pub name: Option<String>,
pub item_type: Option<String>,
pub category: Option<String>,
pub rarity: Option<String>,
pub stats: Vec<(String, Option<f64>, Option<f64>)>,
pub max_price: Option<(f64, String)>,
pub league: Option<String>,
}
pub struct TradeClient {
http: reqwest::Client,
base_url: String,
rate_limiters: Mutex<HashMap<String, RateLimitTracker>>,
stats_cache: OnceCell<Vec<StatGroup>>,
default_league: OnceCell<String>,
}
impl Default for TradeClient {
fn default() -> Self {
Self::new()
}
}
impl TradeClient {
pub fn new() -> Self {
Self::new_with_base_url(BASE_URL)
}
pub fn new_with_base_url(base_url: &str) -> Self {
let http = reqwest::Client::builder()
.user_agent(USER_AGENT)
.build()
.expect("failed to build HTTP client");
Self {
http,
base_url: base_url.trim_end_matches('/').to_string(),
rate_limiters: Mutex::new(HashMap::new()),
stats_cache: OnceCell::new(),
default_league: OnceCell::new(),
}
}
async fn wait_for_rate_limit(&self, policy: &str) {
let limiters = self.rate_limiters.lock().await;
if let Some(tracker) = limiters.get(policy) {
if let Some(wait) = tracker.check_wait() {
drop(limiters); warn!(policy, ?wait, "rate limit — sleeping before request");
tokio::time::sleep(wait).await;
}
}
}
async fn record_rate_limits(&self, headers: &reqwest::header::HeaderMap) {
let mut limiters = self.rate_limiters.lock().await;
update_rate_limits(&mut limiters, headers);
}
async fn parse_response<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
) -> Result<T, TradeError> {
let status = resp.status();
let body = resp.text().await?;
debug!(status = %status, body_len = body.len(), "trade API response");
serde_json::from_str(&body).map_err(|e| {
warn!(
status = %status,
body = %body.chars().take(2000).collect::<String>(),
"failed to parse trade API response"
);
TradeError::Parse(e)
})
}
async fn rate_limited_get(
&self,
url: &str,
policy: &str,
) -> Result<reqwest::Response, TradeError> {
self.wait_for_rate_limit(policy).await;
debug!(url, "GET");
let resp = self.http.get(url).send().await?;
self.record_rate_limits(resp.headers()).await;
if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_secs = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60);
return Err(TradeError::RateLimited(Duration::from_secs(retry_secs)));
}
Ok(resp)
}
async fn rate_limited_post(
&self,
url: &str,
body: &serde_json::Value,
policy: &str,
) -> Result<reqwest::Response, TradeError> {
self.wait_for_rate_limit(policy).await;
debug!(url, "POST");
let resp = self.http.post(url).json(body).send().await?;
self.record_rate_limits(resp.headers()).await;
if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_secs = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60);
return Err(TradeError::RateLimited(Duration::from_secs(retry_secs)));
}
Ok(resp)
}
async fn resolve_league(&self, league: Option<&str>) -> Result<String, TradeError> {
if let Some(l) = league {
return Ok(l.to_string());
}
let base_url = &self.base_url;
self.default_league
.get_or_try_init(|| async {
let url = format!("{base_url}/api/trade2/data/leagues");
debug!("fetching league list");
let resp = self.http.get(&url).send().await?;
let data: serde_json::Value = resp.json().await?;
let leagues: Vec<LeagueEntry> =
serde_json::from_value(data["result"].clone()).unwrap_or_default();
for league in &leagues {
if league.realm == "poe2"
&& !league.id.starts_with("HC")
&& league.id != "Standard"
&& league.id != "Hardcore"
{
debug!(league = %league.id, "resolved default league");
return Ok(league.id.clone());
}
}
debug!("no challenge league found, falling back to Standard");
Ok("Standard".to_string())
})
.await
.cloned()
}
async fn fetch_stats(&self) -> Result<&Vec<StatGroup>, TradeError> {
let base_url = &self.base_url;
self.stats_cache
.get_or_try_init(|| async {
let url = format!("{base_url}/api/trade2/data/stats");
debug!("fetching stat data");
let resp = self.http.get(&url).send().await?;
let data: serde_json::Value = resp.json().await?;
let groups: Vec<StatGroup> =
serde_json::from_value(data["result"].clone()).unwrap_or_default();
debug!(groups = groups.len(), "stat data loaded");
Ok(groups)
})
.await
}
async fn resolve_stat_ids(
&self,
stat_names: &[(String, Option<f64>, Option<f64>)],
) -> Result<Vec<StatFilter>, TradeError> {
let groups = self.fetch_stats().await?;
let mut filters = Vec::new();
for (name, min, max) in stat_names {
let needle = name.to_lowercase();
let mut best_match: Option<&StatEntry> = None;
let mut best_is_pseudo = false;
for group in groups {
for entry in &group.entries {
let haystack = entry.text.to_lowercase();
if haystack.contains(&needle) {
let is_pseudo = entry.id.starts_with("pseudo.");
if best_match.is_none()
|| (is_pseudo && !best_is_pseudo)
|| (is_pseudo == best_is_pseudo
&& haystack.len() < best_match.unwrap().text.len())
{
best_match = Some(entry);
best_is_pseudo = is_pseudo;
}
}
}
}
if let Some(entry) = best_match {
debug!(name, id = %entry.id, "resolved stat");
let value = if min.is_some() || max.is_some() {
Some(StatFilterValue {
min: *min,
max: *max,
})
} else {
None
};
filters.push(StatFilter {
id: entry.id.clone(),
value,
});
} else {
warn!(name, "could not resolve stat ID — skipping");
}
}
Ok(filters)
}
pub async fn search(&self, params: SearchParams) -> Result<serde_json::Value, TradeError> {
let base_url = &self.base_url;
let league = self.resolve_league(params.league.as_deref()).await?;
let stat_filters = if !params.stats.is_empty() {
self.resolve_stat_ids(¶ms.stats).await?
} else {
Vec::new()
};
let mut query = serde_json::json!({
"status": {"option": "available"}
});
if let Some(ref name) = params.name {
query["name"] = serde_json::json!(name);
}
if let Some(ref item_type) = params.item_type {
query["type"] = serde_json::json!(item_type);
}
let mut type_filters = serde_json::Map::new();
if let Some(ref category) = params.category {
type_filters.insert(
"category".to_string(),
serde_json::json!({"option": category}),
);
}
if let Some(ref rarity) = params.rarity {
type_filters.insert("rarity".to_string(), serde_json::json!({"option": rarity}));
}
if !type_filters.is_empty() {
query["filters"] = serde_json::json!({
"type_filters": {
"filters": type_filters
}
});
}
if let Some((amount, ref currency)) = params.max_price {
let trade_filter = serde_json::json!({
"filters": {
"price": {"max": amount, "option": currency}
}
});
if let Some(filters) = query.get_mut("filters").and_then(|v| v.as_object_mut()) {
filters.insert("trade_filters".to_string(), trade_filter);
} else {
query["filters"] = serde_json::json!({
"trade_filters": trade_filter
});
}
}
if !stat_filters.is_empty() {
query["stats"] = serde_json::json!([{
"type": "and",
"filters": stat_filters
}]);
}
let body = serde_json::json!({
"query": query,
"sort": {"price": "asc"}
});
debug!(league, "searching trade");
let url = format!("{base_url}/api/trade2/search/poe2/{league}");
let resp = self
.rate_limited_post(&url, &body, "trade-search-request-limit")
.await?;
let search: SearchResponse = Self::parse_response(resp).await?;
if let Some(err) = search.error {
return Err(TradeError::Api {
code: err.code,
message: err.message,
});
}
if search.result.is_empty() {
return Err(TradeError::NoResults);
}
let query_id = search.id.as_deref().unwrap_or("");
let hashes: Vec<&str> = search.result.iter().take(10).map(|s| s.as_str()).collect();
let hashes_str = hashes.join(",");
let fetch_url = format!("{base_url}/api/trade2/fetch/{hashes_str}?query={query_id}",);
let fetch_resp = self
.rate_limited_get(&fetch_url, "trade-fetch-request-limit")
.await?;
let fetched: FetchResponse = Self::parse_response(fetch_resp).await?;
let results: Vec<serde_json::Value> = fetched
.result
.iter()
.map(|item| {
let mut mods: Vec<String> = item.item.implicit_mods.clone();
mods.extend(item.item.explicit_mods.clone());
let price = item
.listing
.price
.as_ref()
.map(|p| format!("{} {}", p.amount, p.currency))
.unwrap_or_else(|| "unlisted".to_string());
serde_json::json!({
"name": item.item.display_name(),
"base_type": item.item.base_type,
"ilvl": item.item.ilvl,
"rarity": item.item.rarity(),
"price": price,
"mods": mods
})
})
.collect();
Ok(serde_json::json!({
"total": search.total,
"results": results
}))
}
pub async fn exchange(
&self,
have: &str,
want: &str,
league: Option<&str>,
) -> Result<serde_json::Value, TradeError> {
let base_url = &self.base_url;
let league = self.resolve_league(league).await?;
let body = serde_json::json!({
"query": {
"status": {"option": "online"},
"have": [have],
"want": [want]
},
"sort": {"have": "asc"},
"engine": "new"
});
debug!(league, have, want, "exchange query");
let url = format!("{base_url}/api/trade2/exchange/poe2/{league}");
let resp = self
.rate_limited_post(&url, &body, "trade-exchange-request-limit")
.await?;
let exchange: ExchangeResponse = Self::parse_response(resp).await?;
if let Some(err) = exchange.error {
return Err(TradeError::Api {
code: err.code,
message: err.message,
});
}
if exchange.result.is_empty() {
return Err(TradeError::NoResults);
}
let mut rates: Vec<serde_json::Value> = Vec::new();
for entry in exchange.result.values() {
for offer in &entry.listing.offers {
let give_amount = offer.exchange.amount;
let get_amount = offer.item.amount;
let ratio = if get_amount > 0.0 {
format!(
"{} {} \u{2192} {} {}",
give_amount, offer.exchange.currency, get_amount, offer.item.currency
)
} else {
"unknown ratio".to_string()
};
rates.push(serde_json::json!({
"ratio": ratio,
"stock": offer.item.stock
}));
}
}
rates.truncate(5);
Ok(serde_json::json!({
"have": have,
"want": want,
"rates": rates,
"total_sellers": exchange.total
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn test_client(server: &MockServer) -> TradeClient {
let client = TradeClient::new_with_base_url(&server.uri());
client.default_league.set("TestLeague".to_string()).unwrap();
client
}
fn search_params(name: &str) -> SearchParams {
SearchParams {
name: Some(name.to_string()),
item_type: None,
category: None,
rarity: None,
stats: Vec::new(),
max_price: None,
league: Some("TestLeague".to_string()),
}
}
#[tokio::test]
async fn search_api_error_returns_trade_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/api/trade2/search/.*"))
.respond_with(ResponseTemplate::new(400).set_body_json(
serde_json::json!({"error": {"code": 2, "message": "Unknown item base type"}}),
))
.mount(&server)
.await;
let client = test_client(&server).await;
let result = client.search(search_params("Nonexistent Item")).await;
let err = result.unwrap_err();
assert!(
matches!(err, TradeError::Api { code: 2, .. }),
"expected TradeError::Api, got: {err}"
);
}
#[tokio::test]
async fn search_success_returns_results() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/api/trade2/search/.*"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "abc123",
"total": 1,
"result": ["hash1"]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path_regex(r"/api/trade2/fetch/.*"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": [{
"listing": {"price": {"amount": 10.0, "currency": "chaos"}},
"item": {
"name": "Test Ring",
"typeLine": "Gold Ring",
"baseType": "Gold Ring",
"ilvl": 80,
"frameType": 3,
"explicitMods": ["+20 to Maximum Life"],
"implicitMods": []
}
}]
})))
.mount(&server)
.await;
let client = test_client(&server).await;
let result = client.search(search_params("Test Ring")).await;
let value = result.expect("search should succeed");
assert_eq!(value["total"], 1);
let results = value["results"].as_array().unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0]["name"], "Test Ring Gold Ring");
assert_eq!(results[0]["price"], "10 chaos");
}
#[tokio::test]
async fn search_empty_results_returns_no_results_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/api/trade2/search/.*"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "abc123",
"total": 0,
"result": []
})))
.mount(&server)
.await;
let client = test_client(&server).await;
let result = client.search(search_params("Nothing")).await;
assert!(matches!(result, Err(TradeError::NoResults)));
}
#[tokio::test]
async fn search_html_error_page_returns_parse_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/api/trade2/search/.*"))
.respond_with(
ResponseTemplate::new(503).set_body_string("<html>Service Unavailable</html>"),
)
.mount(&server)
.await;
let client = test_client(&server).await;
let result = client.search(search_params("Anything")).await;
assert!(
matches!(result, Err(TradeError::Parse(_))),
"expected TradeError::Parse, got: {result:?}"
);
}
#[tokio::test]
async fn exchange_api_error_returns_trade_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/api/trade2/exchange/.*"))
.respond_with(
ResponseTemplate::new(400).set_body_json(
serde_json::json!({"error": {"code": 1, "message": "bad request"}}),
),
)
.mount(&server)
.await;
let client = test_client(&server).await;
let result = client.exchange("chaos", "divine", Some("TestLeague")).await;
let err = result.unwrap_err();
assert!(
matches!(err, TradeError::Api { code: 1, .. }),
"expected TradeError::Api, got: {err}"
);
}
#[tokio::test]
async fn rate_limited_response_returns_rate_limit_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path_regex(r"/api/trade2/search/.*"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "30"))
.mount(&server)
.await;
let client = test_client(&server).await;
let result = client.search(search_params("Anything")).await;
assert!(
matches!(result, Err(TradeError::RateLimited(d)) if d == Duration::from_secs(30)),
"expected TradeError::RateLimited(30s), got: {result:?}"
);
}
}