1use std::sync::{Arc, PoisonError, RwLock};
5use std::time::Duration;
6
7use reqwest::StatusCode;
8use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, RETRY_AFTER};
9use secrecy::{ExposeSecret, SecretString};
10use serde::Serialize;
11use serde::de::DeserializeOwned;
12
13use crate::error::{Error, GraphQlError, Result};
14
15const DEFAULT_ENDPOINT: &str = "https://api.linear.app/graphql";
16const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
17const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
18const DEFAULT_USER_AGENT: &str = concat!("linear-api-rs/", env!("CARGO_PKG_VERSION"));
19const MAX_BACKOFF: Duration = Duration::from_secs(8);
22
23#[derive(Debug, Clone)]
28pub struct LinearClient {
29 inner: Arc<ClientInner>,
30}
31
32#[derive(Debug)]
33struct ClientInner {
34 http: reqwest::Client,
35 endpoint: String,
36 api_key: SecretString,
37 retry: RetryConfig,
38 last_rate_limit: RwLock<Option<RateLimitInfo>>,
39}
40
41#[derive(Debug, Clone)]
48pub struct RetryConfig {
49 pub max_attempts: u32,
51 pub base_backoff: Duration,
54 pub max_rate_limit_wait: Duration,
58 pub retry_mutations_on_transient: bool,
62}
63
64impl Default for RetryConfig {
65 fn default() -> Self {
66 Self {
67 max_attempts: 3,
68 base_backoff: Duration::from_millis(250),
69 max_rate_limit_wait: Duration::from_secs(30),
70 retry_mutations_on_transient: false,
71 }
72 }
73}
74
75#[derive(Debug, Clone)]
81#[non_exhaustive]
82pub struct RateLimitInfo {
83 pub requests_limit: Option<u64>,
85 pub requests_remaining: Option<u64>,
87 pub requests_reset: Option<time::OffsetDateTime>,
89 pub complexity_last_query: Option<u64>,
91 pub complexity_limit: Option<u64>,
93 pub complexity_remaining: Option<u64>,
95 pub complexity_reset: Option<time::OffsetDateTime>,
97 pub endpoint_name: Option<String>,
99 pub endpoint_requests_remaining: Option<u64>,
102}
103
104impl RateLimitInfo {
105 fn from_headers(headers: &HeaderMap) -> Self {
106 Self {
107 requests_limit: header_u64(headers, "x-ratelimit-requests-limit"),
108 requests_remaining: header_u64(headers, "x-ratelimit-requests-remaining"),
109 requests_reset: header_epoch_ms(headers, "x-ratelimit-requests-reset"),
110 complexity_last_query: header_u64(headers, "x-complexity"),
111 complexity_limit: header_u64(headers, "x-ratelimit-complexity-limit"),
112 complexity_remaining: header_u64(headers, "x-ratelimit-complexity-remaining"),
113 complexity_reset: header_epoch_ms(headers, "x-ratelimit-complexity-reset"),
114 endpoint_name: headers
115 .get("x-ratelimit-endpoint-name")
116 .and_then(|v| v.to_str().ok())
117 .map(str::to_owned),
118 endpoint_requests_remaining: header_u64(
119 headers,
120 "x-ratelimit-endpoint-requests-remaining",
121 ),
122 }
123 }
124
125 fn is_empty(&self) -> bool {
126 self.requests_limit.is_none()
127 && self.requests_remaining.is_none()
128 && self.requests_reset.is_none()
129 && self.complexity_last_query.is_none()
130 && self.complexity_limit.is_none()
131 && self.complexity_remaining.is_none()
132 && self.complexity_reset.is_none()
133 && self.endpoint_name.is_none()
134 && self.endpoint_requests_remaining.is_none()
135 }
136}
137
138fn header_u64(headers: &HeaderMap, name: &str) -> Option<u64> {
139 headers.get(name)?.to_str().ok()?.trim().parse().ok()
140}
141
142fn header_epoch_ms(headers: &HeaderMap, name: &str) -> Option<time::OffsetDateTime> {
143 let ms: i128 = headers.get(name)?.to_str().ok()?.trim().parse().ok()?;
144 time::OffsetDateTime::from_unix_timestamp_nanos(ms.checked_mul(1_000_000)?).ok()
145}
146
147#[derive(Debug, Default)]
149pub struct LinearClientBuilder {
150 api_key: Option<SecretString>,
151 endpoint: Option<String>,
152 timeout: Option<Duration>,
153 connect_timeout: Option<Duration>,
154 user_agent: Option<String>,
155 retry: Option<RetryConfig>,
156}
157
158impl LinearClientBuilder {
159 pub fn api_key(mut self, key: impl Into<SecretString>) -> Self {
162 self.api_key = Some(key.into());
163 self
164 }
165
166 pub fn endpoint(mut self, url: impl Into<String>) -> Self {
169 self.endpoint = Some(url.into());
170 self
171 }
172
173 pub fn timeout(mut self, d: Duration) -> Self {
175 self.timeout = Some(d);
176 self
177 }
178
179 pub fn connect_timeout(mut self, d: Duration) -> Self {
181 self.connect_timeout = Some(d);
182 self
183 }
184
185 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
187 self.user_agent = Some(ua.into());
188 self
189 }
190
191 pub fn retry(mut self, cfg: RetryConfig) -> Self {
193 self.retry = Some(cfg);
194 self
195 }
196
197 pub fn build(self) -> Result<LinearClient> {
201 let api_key = self.api_key.ok_or_else(|| {
202 Error::Config("no API key provided; set one with LinearClientBuilder::api_key".into())
203 })?;
204 HeaderValue::from_str(api_key.expose_secret()).map_err(|_| {
205 Error::Config("API key contains characters not permitted in an HTTP header".into())
206 })?;
207 let endpoint = self.endpoint.unwrap_or_else(|| DEFAULT_ENDPOINT.to_owned());
208 url::Url::parse(&endpoint)
209 .map_err(|e| Error::Config(format!("invalid endpoint URL {endpoint:?}: {e}")))?;
210 let http = reqwest::Client::builder()
211 .connect_timeout(self.connect_timeout.unwrap_or(DEFAULT_CONNECT_TIMEOUT))
212 .timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
213 .user_agent(
214 self.user_agent
215 .unwrap_or_else(|| DEFAULT_USER_AGENT.to_owned()),
216 )
217 .build()
218 .map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?;
219 Ok(LinearClient {
220 inner: Arc::new(ClientInner {
221 http,
222 endpoint,
223 api_key,
224 retry: self.retry.unwrap_or_default(),
225 last_rate_limit: RwLock::new(None),
226 }),
227 })
228 }
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232enum OpKind {
233 Query,
234 Mutation,
235}
236
237impl LinearClient {
238 pub fn builder() -> LinearClientBuilder {
240 LinearClientBuilder::default()
241 }
242
243 pub fn new(api_key: impl Into<SecretString>) -> Result<Self> {
245 Self::builder().api_key(api_key).build()
246 }
247
248 pub fn from_env() -> Result<Self> {
251 let key = std::env::var("LINEAR_API_KEY")
252 .map_err(|_| Error::Config("LINEAR_API_KEY environment variable is not set".into()))?;
253 Self::new(key)
254 }
255
256 pub fn last_rate_limit(&self) -> Option<RateLimitInfo> {
258 self.inner
259 .last_rate_limit
260 .read()
261 .unwrap_or_else(PoisonError::into_inner)
262 .clone()
263 }
264
265 pub(crate) async fn query<V: Serialize, D: DeserializeOwned>(
268 &self,
269 op_name: &'static str,
270 document: &'static str,
271 variables: V,
272 ) -> Result<D> {
273 self.run(OpKind::Query, op_name, document, variables).await
274 }
275
276 pub(crate) async fn mutation<V: Serialize, D: DeserializeOwned>(
281 &self,
282 op_name: &'static str,
283 document: &'static str,
284 variables: V,
285 ) -> Result<D> {
286 self.run(OpKind::Mutation, op_name, document, variables)
287 .await
288 }
289
290 pub async fn execute_raw(
295 &self,
296 document: &str,
297 variables: serde_json::Value,
298 ) -> Result<serde_json::Value> {
299 self.execute(OpKind::Query, "execute_raw", document, variables)
300 .await
301 }
302
303 async fn run<V: Serialize, D: DeserializeOwned>(
304 &self,
305 kind: OpKind,
306 op_name: &'static str,
307 document: &str,
308 variables: V,
309 ) -> Result<D> {
310 let variables = serde_json::to_value(variables).map_err(|e| {
311 Error::Config(format!("failed to serialize variables for {op_name}: {e}"))
312 })?;
313 let data = self.execute(kind, op_name, document, variables).await?;
314 serde_json::from_value(data).map_err(|source| Error::Decode {
315 operation: op_name,
316 source,
317 })
318 }
319
320 async fn execute(
321 &self,
322 kind: OpKind,
323 op_name: &'static str,
324 document: &str,
325 variables: serde_json::Value,
326 ) -> Result<serde_json::Value> {
327 let retry = &self.inner.retry;
328 let body = serde_json::json!({ "query": document, "variables": variables });
329 let auth = self.auth_header()?;
330 let max_attempts = retry.max_attempts.max(1);
331 let mut attempt: u32 = 0;
332 loop {
333 attempt += 1;
334 let can_retry = attempt < max_attempts;
335 let _started = std::time::Instant::now();
336
337 let sent = self
338 .inner
339 .http
340 .post(&self.inner.endpoint)
341 .header(AUTHORIZATION, auth.clone())
342 .json(&body)
343 .send()
344 .await;
345 let response = match sent {
346 Ok(response) => response,
347 Err(e) => {
348 let retryable = e.is_connect() || self.transient_retry_allowed(kind);
351 if retryable && can_retry {
352 self.backoff(op_name, attempt).await;
353 continue;
354 }
355 return Err(Error::Transport(e));
356 }
357 };
358
359 let status = response.status();
360 let headers = response.headers().clone();
361 let info = RateLimitInfo::from_headers(&headers);
362 let info = (!info.is_empty()).then_some(info);
363 if let Some(info) = &info {
364 *self
365 .inner
366 .last_rate_limit
367 .write()
368 .unwrap_or_else(PoisonError::into_inner) = Some(info.clone());
369 }
370
371 let bytes = match response.bytes().await {
372 Ok(bytes) => bytes,
373 Err(e) => {
374 if self.transient_retry_allowed(kind) && can_retry {
375 self.backoff(op_name, attempt).await;
376 continue;
377 }
378 return Err(Error::Transport(e));
379 }
380 };
381
382 let raw: std::result::Result<RawResponse, serde_json::Error> =
383 serde_json::from_slice(&bytes);
384
385 let rate_limited = status == StatusCode::TOO_MANY_REQUESTS
390 || raw.as_ref().is_ok_and(|raw| {
391 raw.errors.as_deref().unwrap_or_default().iter().any(|e| {
392 e.extensions.as_ref().and_then(|x| x.code.as_deref()) == Some("RATELIMITED")
393 })
394 });
395 if rate_limited {
396 let wait = rate_limit_wait(&headers, info.as_ref());
397 if wait > retry.max_rate_limit_wait || !can_retry {
398 return Err(Error::RateLimited {
399 retry_after: Some(wait),
400 info,
401 });
402 }
403 #[cfg(feature = "tracing")]
404 tracing::warn!(
405 operation = op_name,
406 attempt,
407 wait_ms = wait.as_millis() as u64,
408 "rate limited; waiting for budget reset"
409 );
410 tokio::time::sleep(wait).await;
411 continue;
412 }
413
414 if status.is_server_error() {
415 if self.transient_retry_allowed(kind) && can_retry {
416 self.backoff(op_name, attempt).await;
417 continue;
418 }
419 return Err(Error::Http {
420 status: status.as_u16(),
421 body: String::from_utf8_lossy(&bytes).into_owned(),
422 });
423 }
424
425 let raw = match raw {
426 Ok(raw) => raw,
427 Err(source) => {
428 if status.is_success() {
429 return Err(Error::Decode {
430 operation: op_name,
431 source,
432 });
433 }
434 return Err(Error::Http {
435 status: status.as_u16(),
436 body: String::from_utf8_lossy(&bytes).into_owned(),
437 });
438 }
439 };
440
441 if let Some(errors) = raw.errors.filter(|errors| !errors.is_empty()) {
444 return Err(Error::Api {
445 operation: op_name,
446 errors,
447 });
448 }
449
450 if !status.is_success() {
451 return Err(Error::Http {
452 status: status.as_u16(),
453 body: String::from_utf8_lossy(&bytes).into_owned(),
454 });
455 }
456
457 #[cfg(feature = "tracing")]
458 tracing::debug!(
459 operation = op_name,
460 elapsed_ms = _started.elapsed().as_millis() as u64,
461 complexity = info.as_ref().and_then(|i| i.complexity_last_query),
462 requests_remaining = info.as_ref().and_then(|i| i.requests_remaining),
463 "linear-api request"
464 );
465
466 return match raw.data {
467 Some(data) if !data.is_null() => Ok(data),
468 _ => Err(Error::MissingData { operation: op_name }),
469 };
470 }
471 }
472
473 fn transient_retry_allowed(&self, kind: OpKind) -> bool {
474 kind == OpKind::Query || self.inner.retry.retry_mutations_on_transient
475 }
476
477 fn auth_header(&self) -> Result<HeaderValue> {
478 let mut value =
480 HeaderValue::from_str(self.inner.api_key.expose_secret()).map_err(|_| {
481 Error::Config("API key contains characters not permitted in an HTTP header".into())
482 })?;
483 value.set_sensitive(true);
484 Ok(value)
485 }
486
487 async fn backoff(&self, _op_name: &str, attempt: u32) {
490 let exp = self
491 .inner
492 .retry
493 .base_backoff
494 .saturating_mul(2u32.saturating_pow(attempt.saturating_sub(1)))
495 .min(MAX_BACKOFF);
496 let wait = exp.mul_f64(fastrand::f64());
497 #[cfg(feature = "tracing")]
498 tracing::warn!(
499 operation = _op_name,
500 attempt,
501 backoff_ms = wait.as_millis() as u64,
502 "retrying after transient failure"
503 );
504 tokio::time::sleep(wait).await;
505 }
506}
507
508fn rate_limit_wait(headers: &HeaderMap, info: Option<&RateLimitInfo>) -> Duration {
511 let retry_after = headers
512 .get(RETRY_AFTER)
513 .and_then(|v| v.to_str().ok())
514 .and_then(|s| s.trim().parse::<u64>().ok())
515 .map(Duration::from_secs);
516 let reset_wait = info
517 .and_then(|info| info.requests_reset)
518 .and_then(|reset| Duration::try_from(reset - time::OffsetDateTime::now_utc()).ok());
519 retry_after
520 .or(reset_wait)
521 .unwrap_or(Duration::ZERO)
522 .max(Duration::from_secs(1))
523}
524
525#[derive(serde::Deserialize)]
526struct RawResponse {
527 #[serde(default)]
528 data: Option<serde_json::Value>,
529 #[serde(default)]
530 errors: Option<Vec<GraphQlError>>,
531}