Skip to main content

linear_api/
client.rs

1//! [`LinearClient`]: connection pool, auth, the retry/rate-limit-aware
2//! execute loop, and the rate-limit budget snapshot.
3
4use 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"));
19/// Exponential backoff is capped here; rate-limit waits use the
20/// header-derived wait instead.
21const MAX_BACKOFF: Duration = Duration::from_secs(8);
22
23/// Async client for the Linear GraphQL API.
24///
25/// Cheap to clone (an [`Arc`] around the connection pool); build one per
26/// process and clone it across tasks.
27#[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/// Retry policy. Defaults are safe for non-idempotent mutations: Linear has
42/// no idempotency keys, so mutations are **not** retried on post-send
43/// transport errors or 5xx unless
44/// [`retry_mutations_on_transient`](RetryConfig::retry_mutations_on_transient)
45/// is opted into. Rate-limit rejections happen before execution and are
46/// always retried (within [`max_rate_limit_wait`](RetryConfig::max_rate_limit_wait)).
47#[derive(Debug, Clone)]
48pub struct RetryConfig {
49    /// Total attempts including the first (default 3 = 1 initial + 2 retries).
50    pub max_attempts: u32,
51    /// Base for exponential backoff with full jitter (default 250ms,
52    /// doubling per attempt, capped at 8s).
53    pub base_backoff: Duration,
54    /// Longest rate-limit wait to sit out in-process (default 30s). Longer
55    /// waits fail fast with [`Error::RateLimited`] instead of parking the
56    /// caller.
57    pub max_rate_limit_wait: Duration,
58    /// Also retry mutations on post-send transport errors / 5xx (default
59    /// `false`; a timed-out `issueCreate` may have landed — blind retries
60    /// can double-create).
61    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/// Snapshot of Linear's rate-limit budget headers, parsed from every
76/// response. The reset headers are UTC **epoch milliseconds** on the wire.
77///
78/// Never hardcode budgets — Linear's documented numbers are inconsistent;
79/// these headers are the source of truth.
80#[derive(Debug, Clone)]
81#[non_exhaustive]
82pub struct RateLimitInfo {
83    /// Requests allowed per window (`X-RateLimit-Requests-Limit`).
84    pub requests_limit: Option<u64>,
85    /// Requests remaining in the window (`X-RateLimit-Requests-Remaining`).
86    pub requests_remaining: Option<u64>,
87    /// When the request budget resets (`X-RateLimit-Requests-Reset`).
88    pub requests_reset: Option<time::OffsetDateTime>,
89    /// Complexity consumed by the last query (`X-Complexity`).
90    pub complexity_last_query: Option<u64>,
91    /// Complexity allowed per window (`X-RateLimit-Complexity-Limit`).
92    pub complexity_limit: Option<u64>,
93    /// Complexity remaining in the window (`X-RateLimit-Complexity-Remaining`).
94    pub complexity_remaining: Option<u64>,
95    /// When the complexity budget resets (`X-RateLimit-Complexity-Reset`).
96    pub complexity_reset: Option<time::OffsetDateTime>,
97    /// Endpoint-specific limit name (`X-RateLimit-Endpoint-Name`), when present.
98    pub endpoint_name: Option<String>,
99    /// Endpoint-specific requests remaining
100    /// (`X-RateLimit-Endpoint-Requests-Remaining`), when present.
101    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/// Builder for [`LinearClient`]. Obtain via [`LinearClient::builder`].
148#[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    /// Sets the Linear API key (required). Keys look like `lin_api_…` and
160    /// are sent as a raw `Authorization` header — no `Bearer` prefix.
161    pub fn api_key(mut self, key: impl Into<SecretString>) -> Self {
162        self.api_key = Some(key.into());
163        self
164    }
165
166    /// Overrides the GraphQL endpoint (default
167    /// `https://api.linear.app/graphql`). Useful for mock servers.
168    pub fn endpoint(mut self, url: impl Into<String>) -> Self {
169        self.endpoint = Some(url.into());
170        self
171    }
172
173    /// Total per-request timeout (default 30s).
174    pub fn timeout(mut self, d: Duration) -> Self {
175        self.timeout = Some(d);
176        self
177    }
178
179    /// Connection timeout (default 10s).
180    pub fn connect_timeout(mut self, d: Duration) -> Self {
181        self.connect_timeout = Some(d);
182        self
183    }
184
185    /// `User-Agent` header (default `linear-api-rs/{version}`).
186    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
187        self.user_agent = Some(ua.into());
188        self
189    }
190
191    /// Retry policy (default [`RetryConfig::default`]).
192    pub fn retry(mut self, cfg: RetryConfig) -> Self {
193        self.retry = Some(cfg);
194        self
195    }
196
197    /// Builds the client. Fails with [`Error::Config`] when the API key is
198    /// missing, the endpoint URL is invalid, or the key contains characters
199    /// not permitted in an HTTP header.
200    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    /// Starts building a client.
239    pub fn builder() -> LinearClientBuilder {
240        LinearClientBuilder::default()
241    }
242
243    /// Builds a client with all defaults and the given API key.
244    pub fn new(api_key: impl Into<SecretString>) -> Result<Self> {
245        Self::builder().api_key(api_key).build()
246    }
247
248    /// Builds a client from the `LINEAR_API_KEY` environment variable.
249    /// Fails with [`Error::Config`] when unset.
250    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    /// The most recent rate-limit budget snapshot observed on any response.
257    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    /// Executes one GraphQL **query** with retry (transient failures and
266    /// rate limits) and returns the deserialized `data`.
267    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    /// Executes one GraphQL **mutation**. Identical to [`Self::query`]
277    /// except for retry classification: mutations are never retried on
278    /// post-send transport errors / 5xx unless
279    /// [`RetryConfig::retry_mutations_on_transient`] is set.
280    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    /// Escape hatch: executes an arbitrary GraphQL document and returns the
291    /// raw `data` value. Uses the same error classification as typed calls
292    /// and is treated as a **query** for retry purposes — do not send
293    /// non-idempotent mutations through it unless you can dedupe.
294    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                    // Connect errors mean nothing executed: safe to retry
349                    // even mutations. Anything after send may have landed.
350                    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            // Rate limited = HTTP 429 OR a GraphQL error with
386            // extensions.code == "RATELIMITED" (Linear sends HTTP 400 for
387            // this). The server rejected before executing — always safe to
388            // retry, mutations included.
389            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            // Fail-closed: GraphQL errors surface even when partial data is
442            // present.
443            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        // Linear API keys are sent raw — no "Bearer" prefix.
479        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    /// Exponential backoff with full jitter: uniform in
488    /// `[0, min(base × 2^(attempt-1), 8s)]`.
489    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
508/// Wait before retrying a rate-limited request: `Retry-After` seconds when
509/// present, else `X-RateLimit-Requests-Reset − now`, floored at 1s.
510fn 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}