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