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}