Skip to main content

github_bot_sdk/client/
installation.rs

1//! Installation Client Types and Operations
2//!
3//! **Specification**: `docs/spec/interfaces/installation-client.md`
4//!
5//! This module provides installation-scoped access to GitHub API operations.
6//! The `InstallationClient` is bound to a specific installation ID and uses
7//! installation tokens (not JWTs) for authentication.
8
9use crate::{
10    auth::InstallationId,
11    client::{calculate_rate_limit_delay, detect_secondary_rate_limit, GitHubClient},
12    error::ApiError,
13};
14use std::future::Future;
15use std::sync::Arc;
16
17#[cfg(test)]
18#[path = "installation_tests.rs"]
19mod tests;
20
21/// Calculate exponential backoff delay with optional jitter.
22///
23/// # Arguments
24///
25/// * `attempt` - Current retry attempt (0-indexed)
26/// * `initial_delay` - Initial delay for first retry
27/// * `max_delay` - Maximum delay cap
28/// * `multiplier` - Backoff multiplier (typically 2.0)
29/// * `use_jitter` - Whether to add random jitter (±25%)
30fn calculate_exponential_backoff(
31    attempt: u32,
32    initial_delay: std::time::Duration,
33    max_delay: std::time::Duration,
34    multiplier: f64,
35    use_jitter: bool,
36) -> std::time::Duration {
37    if attempt == 0 {
38        return initial_delay;
39    }
40
41    // Calculate exponential backoff
42    let exp_multiplier = multiplier.powi(attempt as i32);
43    let delay_ms = (initial_delay.as_millis() as f64 * exp_multiplier) as u64;
44    let mut delay = std::time::Duration::from_millis(delay_ms);
45
46    // Cap at max delay
47    if delay > max_delay {
48        delay = max_delay;
49    }
50
51    // Add jitter if requested (±25% randomization)
52    if use_jitter {
53        use rand::Rng;
54        let mut rng = rand::rng();
55        let jitter_factor = rng.random_range(0.75..=1.25);
56        delay = std::time::Duration::from_millis((delay.as_millis() as f64 * jitter_factor) as u64);
57    }
58
59    delay
60}
61
62/// Installation-scoped GitHub API client.
63///
64/// Holds a reference to the parent `GitHubClient` for shared HTTP client,
65/// auth provider, and rate limiter. All operations use installation tokens.
66#[derive(Debug, Clone)]
67pub struct InstallationClient {
68    /// Parent GitHub client (shared HTTP client, auth provider, rate limiter)
69    client: Arc<GitHubClient>,
70    /// Installation ID this client is bound to
71    installation_id: InstallationId,
72}
73
74impl InstallationClient {
75    /// Create a new installation client.
76    ///
77    /// # Arguments
78    ///
79    /// * `client` - Parent GitHubClient
80    /// * `installation_id` - Installation ID to bind to
81    pub fn new(client: Arc<GitHubClient>, installation_id: InstallationId) -> Self {
82        Self {
83            client,
84            installation_id,
85        }
86    }
87
88    /// Get the installation ID this client is bound to.
89    pub fn installation_id(&self) -> InstallationId {
90        self.installation_id
91    }
92
93    /// Execute an HTTP request with retry logic for transient errors.
94    ///
95    /// This method wraps request execution with:
96    /// - Exponential backoff retry on transient errors (5xx, 429, network failures)
97    /// - Retry-After header parsing for 429 responses
98    /// - Secondary rate limit detection for 403 responses
99    /// - Maximum retry limit from client configuration
100    ///
101    /// # Arguments
102    ///
103    /// * `operation_name` - Name of the operation for logging/debugging
104    /// * `request_fn` - Async function that executes the HTTP request
105    ///
106    /// # Returns
107    ///
108    /// Returns the successful response or the last error after exhausting retries.
109    ///
110    /// # Errors
111    ///
112    /// Returns `ApiError` if:
113    /// - All retry attempts fail
114    /// - A non-retryable error occurs (4xx except 429)
115    async fn execute_with_retry<F, Fut>(
116        &self,
117        operation_name: &str,
118        request_fn: F,
119    ) -> Result<reqwest::Response, ApiError>
120    where
121        F: Fn() -> Fut,
122        Fut: Future<Output = Result<reqwest::Response, ApiError>>,
123    {
124        let max_retries = self.client.config().max_retries;
125        let initial_delay = self.client.config().initial_retry_delay;
126        let max_delay = self.client.config().max_retry_delay;
127        let backoff_multiplier = 2.0;
128
129        let mut last_error: Option<ApiError> = None;
130
131        for attempt in 0..=max_retries {
132            // Execute the request
133            match request_fn().await {
134                Ok(response) => {
135                    let status = response.status().as_u16();
136
137                    // Check for rate limit (429)
138                    if status == 429 {
139                        if attempt < max_retries {
140                            let retry_after = response
141                                .headers()
142                                .get("Retry-After")
143                                .and_then(|v| v.to_str().ok());
144                            let rate_limit_reset = response
145                                .headers()
146                                .get("X-RateLimit-Reset")
147                                .and_then(|v| v.to_str().ok());
148
149                            let delay = calculate_rate_limit_delay(retry_after, rate_limit_reset);
150                            tokio::time::sleep(delay).await;
151                            continue;
152                        } else {
153                            return Err(ApiError::HttpError {
154                                status,
155                                message: "Rate limit exceeded after maximum retries".to_string(),
156                            });
157                        }
158                    }
159
160                    // Check for secondary rate limit (403 with abuse detection)
161                    if status == 403 {
162                        let body = response.text().await.unwrap_or_default();
163
164                        if detect_secondary_rate_limit(status, &body) {
165                            if attempt < max_retries {
166                                // Secondary rate limits require longer backoff
167                                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
168                                continue;
169                            } else {
170                                return Err(ApiError::SecondaryRateLimit);
171                            }
172                        } else {
173                            // Not a rate limit, it's a permission error - don't retry
174                            return Err(ApiError::AuthorizationFailed);
175                        }
176                    }
177
178                    // Check for server errors (5xx)
179                    if status >= 500 {
180                        if attempt < max_retries {
181                            let delay = calculate_exponential_backoff(
182                                attempt,
183                                initial_delay,
184                                max_delay,
185                                backoff_multiplier,
186                                true, // with jitter
187                            );
188                            tokio::time::sleep(delay).await;
189                            continue;
190                        } else {
191                            let body = response.text().await.unwrap_or_default();
192                            return Err(ApiError::HttpError {
193                                status,
194                                message: body,
195                            });
196                        }
197                    }
198
199                    // Check for other client errors (4xx except 429 and 403 which are handled above)
200                    // These are non-retryable - map to appropriate error types
201                    if (400..500).contains(&status) {
202                        let body = response.text().await.unwrap_or_default();
203                        return Err(match status {
204                            401 => ApiError::AuthenticationFailed,
205                            403 => ApiError::AuthorizationFailed, // Permission denied (not rate limit)
206                            404 => ApiError::NotFound,
207                            422 => ApiError::InvalidRequest { message: body },
208                            _ => ApiError::HttpError {
209                                status,
210                                message: body,
211                            },
212                        });
213                    }
214
215                    // Success (2xx or 3xx)
216                    return Ok(response);
217                }
218                Err(e) => {
219                    // Check if the error is transient
220                    if e.is_transient() && attempt < max_retries {
221                        last_error = Some(e);
222                        let delay = calculate_exponential_backoff(
223                            attempt,
224                            initial_delay,
225                            max_delay,
226                            backoff_multiplier,
227                            true, // with jitter
228                        );
229                        tokio::time::sleep(delay).await;
230                        continue;
231                    } else {
232                        // Non-retryable error or max retries exhausted
233                        return Err(e);
234                    }
235                }
236            }
237        }
238
239        // This should never be reached, but return the last error if it happens
240        Err(last_error.unwrap_or_else(|| ApiError::HttpError {
241            status: 500,
242            message: format!(
243                "Max retries ({}) exhausted for {}",
244                max_retries, operation_name
245            ),
246        }))
247    }
248
249    /// Prepare a request with installation token authentication and normalized path.
250    ///
251    /// This helper extracts common logic for token retrieval, path normalization,
252    /// and URL construction used by all HTTP methods.
253    ///
254    /// # Arguments
255    ///
256    /// * `path` - API path (leading slash optional)
257    /// * `method` - HTTP method name for error messages
258    ///
259    /// # Returns
260    ///
261    /// Returns `(token, url)` tuple with the installation token and complete URL.
262    ///
263    /// # Errors
264    ///
265    /// Returns `ApiError::TokenGenerationFailed` if token retrieval fails.
266    async fn prepare_request(
267        &self,
268        path: &str,
269        method: &str,
270    ) -> Result<(String, String), ApiError> {
271        // Get installation token from auth provider
272        let token = self
273            .client
274            .auth_provider()
275            .installation_token(self.installation_id)
276            .await
277            .map_err(|e| ApiError::TokenGenerationFailed {
278                message: format!(
279                    "{} request to {}: failed to get installation token: {}",
280                    method, path, e
281                ),
282            })?;
283
284        // Normalize path - remove leading slash if present
285        let normalized_path = path.strip_prefix('/').unwrap_or(path);
286
287        // Build request URL
288        let url = format!(
289            "{}/{}",
290            self.client.config().github_api_url,
291            normalized_path
292        );
293
294        Ok((token.token().to_string(), url))
295    }
296
297    /// Make an authenticated GET request to the GitHub API.
298    ///
299    /// Uses installation token for authentication and includes automatic retry logic
300    /// for transient errors (5xx, 429, network failures).
301    ///
302    /// # Arguments
303    ///
304    /// * `path` - API path (e.g., "/repos/owner/repo" or "repos/owner/repo")
305    ///
306    /// # Returns
307    ///
308    /// Returns the raw `reqwest::Response` for flexible handling.
309    ///
310    /// # Errors
311    ///
312    /// Returns `ApiError` for HTTP errors, authentication failures, or network issues.
313    pub async fn get(&self, path: &str) -> Result<reqwest::Response, ApiError> {
314        let path = path.to_string();
315        self.execute_with_retry("GET", || async {
316            let (token, url) = self.prepare_request(&path, "GET").await?;
317
318            // Make authenticated request
319            self.client
320                .http_client()
321                .get(&url)
322                .header("Authorization", format!("Bearer {}", token))
323                .header("Accept", "application/vnd.github+json")
324                .send()
325                .await
326                .map_err(ApiError::HttpClientError)
327        })
328        .await
329    }
330
331    /// Make an authenticated POST request to the GitHub API.
332    ///
333    /// Includes automatic retry logic for transient errors.
334    ///
335    /// # Arguments
336    ///
337    /// * `path` - API path
338    /// * `body` - Request body (will be serialized to JSON)
339    ///
340    /// # Errors
341    ///
342    /// Returns `ApiError` for HTTP errors, serialization failures, or network issues.
343    pub async fn post<T: serde::Serialize>(
344        &self,
345        path: &str,
346        body: &T,
347    ) -> Result<reqwest::Response, ApiError> {
348        let path = path.to_string();
349        let body_json = serde_json::to_value(body).map_err(ApiError::JsonError)?;
350
351        self.execute_with_retry("POST", || async {
352            let (token, url) = self.prepare_request(&path, "POST").await?;
353
354            // Make authenticated request with JSON body
355            self.client
356                .http_client()
357                .post(&url)
358                .header("Authorization", format!("Bearer {}", token))
359                .header("Accept", "application/vnd.github+json")
360                .json(&body_json)
361                .send()
362                .await
363                .map_err(ApiError::HttpClientError)
364        })
365        .await
366    }
367
368    /// Make an authenticated PUT request to the GitHub API.
369    ///
370    /// Includes automatic retry logic for transient errors.
371    pub async fn put<T: serde::Serialize>(
372        &self,
373        path: &str,
374        body: &T,
375    ) -> Result<reqwest::Response, ApiError> {
376        let path = path.to_string();
377        let body_json = serde_json::to_value(body).map_err(ApiError::JsonError)?;
378
379        self.execute_with_retry("PUT", || async {
380            let (token, url) = self.prepare_request(&path, "PUT").await?;
381
382            // Make authenticated request with JSON body
383            self.client
384                .http_client()
385                .put(&url)
386                .header("Authorization", format!("Bearer {}", token))
387                .header("Accept", "application/vnd.github+json")
388                .json(&body_json)
389                .send()
390                .await
391                .map_err(ApiError::HttpClientError)
392        })
393        .await
394    }
395
396    /// Make an authenticated DELETE request to the GitHub API.
397    ///
398    /// Includes automatic retry logic for transient errors.
399    pub async fn delete(&self, path: &str) -> Result<reqwest::Response, ApiError> {
400        let path = path.to_string();
401        self.execute_with_retry("DELETE", || async {
402            let (token, url) = self.prepare_request(&path, "DELETE").await?;
403
404            // Make authenticated request
405            self.client
406                .http_client()
407                .delete(&url)
408                .header("Authorization", format!("Bearer {}", token))
409                .header("Accept", "application/vnd.github+json")
410                .send()
411                .await
412                .map_err(ApiError::HttpClientError)
413        })
414        .await
415    }
416
417    /// Make an authenticated POST request to the GitHub GraphQL API.
418    ///
419    /// GitHub's GraphQL endpoint always returns HTTP 200 even for errors.
420    /// Application-level errors are detected by checking the `.errors`
421    /// array in the response body; the first error message is surfaced as
422    /// `ApiError::GraphQlError`. On success, the `.data` value is returned.
423    ///
424    /// Retry logic (backoff, 5xx, rate-limit handling) is identical to the
425    /// REST helpers because the transport layer is the same.
426    ///
427    /// # Arguments
428    ///
429    /// * `query`     - GraphQL query or mutation string
430    /// * `variables` - Variables object (serialised to JSON)
431    ///
432    /// # Returns
433    ///
434    /// Returns the `data` field from the GraphQL response as a `serde_json::Value`.
435    ///
436    /// # Errors
437    ///
438    /// - `ApiError::GraphQlError` — GitHub returned `.errors` in the body
439    /// - `ApiError::GraphQlError` — response body contains neither `.errors` nor a
440    ///   non-null `.data` field (malformed or unexpected response shape)
441    /// - `ApiError::AuthenticationFailed` — HTTP 401
442    /// - `ApiError::JsonError` — response body could not be parsed
443    /// - Any other `ApiError` variant for HTTP-level failures
444    pub async fn post_graphql<V: serde::Serialize>(
445        &self,
446        query: &str,
447        variables: V,
448    ) -> Result<serde_json::Value, ApiError> {
449        // json! already returns Value; no outer to_value needed.
450        let body_json = serde_json::json!({ "query": query, "variables": variables });
451
452        let response = self
453            .execute_with_retry("POST graphql", || async {
454                let (token, url) = self.prepare_request("graphql", "POST").await?;
455                self.client
456                    .http_client()
457                    .post(&url)
458                    .header("Authorization", format!("Bearer {}", token))
459                    .header("Accept", "application/vnd.github+json")
460                    .json(&body_json)
461                    .send()
462                    .await
463                    .map_err(ApiError::HttpClientError)
464            })
465            .await?;
466
467        let payload: serde_json::Value =
468            response.json().await.map_err(ApiError::HttpClientError)?;
469
470        // GraphQL returns HTTP 200 even for errors; check .errors[] first.
471        if let Some(errors) = payload.get("errors").and_then(|e| e.as_array()) {
472            // NOT_FOUND type maps directly to ApiError::NotFound.
473            if errors
474                .iter()
475                .any(|e| e.get("type").and_then(|t| t.as_str()) == Some("NOT_FOUND"))
476            {
477                return Err(ApiError::NotFound);
478            }
479            if let Some(first_message) = errors
480                .first()
481                .and_then(|e| e.get("message"))
482                .and_then(|m| m.as_str())
483            {
484                return Err(ApiError::GraphQlError {
485                    message: first_message.to_string(),
486                });
487            }
488            // Errors present but neither NOT_FOUND type nor a parseable message field
489            // (e.g. {"type": "FORBIDDEN"} with no message). Surface a generic error
490            // rather than silently falling through to the data check.
491            return Err(ApiError::GraphQlError {
492                message: "GraphQL response contained errors".to_string(),
493            });
494        }
495
496        if payload.get("data").is_none_or(|v| v.is_null()) {
497            return Err(ApiError::GraphQlError {
498                message: "GraphQL response missing `data` field".to_string(),
499            });
500        }
501
502        Ok(payload["data"].clone())
503    }
504
505    /// Make an authenticated PATCH request to the GitHub API.
506    ///
507    /// Includes automatic retry logic for transient errors.
508    pub async fn patch<T: serde::Serialize>(
509        &self,
510        path: &str,
511        body: &T,
512    ) -> Result<reqwest::Response, ApiError> {
513        let path = path.to_string();
514        let body_json = serde_json::to_value(body).map_err(ApiError::JsonError)?;
515
516        self.execute_with_retry("PATCH", || async {
517            let (token, url) = self.prepare_request(&path, "PATCH").await?;
518
519            // Make authenticated request with JSON body
520            self.client
521                .http_client()
522                .patch(&url)
523                .header("Authorization", format!("Bearer {}", token))
524                .header("Accept", "application/vnd.github+json")
525                .json(&body_json)
526                .send()
527                .await
528                .map_err(ApiError::HttpClientError)
529        })
530        .await
531    }
532}
533
534impl GitHubClient {
535    /// Create an installation-scoped client.
536    ///
537    /// # Arguments
538    ///
539    /// * `installation_id` - The GitHub App installation ID
540    ///
541    /// # Returns
542    ///
543    /// Returns an `InstallationClient` bound to the specified installation.
544    ///
545    /// # Errors
546    ///
547    /// Returns `ApiError` if the installation ID is invalid or inaccessible.
548    pub async fn installation_by_id(
549        &self,
550        installation_id: InstallationId,
551    ) -> Result<InstallationClient, ApiError> {
552        // Create installation client immediately
553        // Token validation will happen on first API call
554        Ok(InstallationClient::new(
555            Arc::new(self.clone()),
556            installation_id,
557        ))
558    }
559}