From d104b8b3437e758dce287621e3b8737045bdbaed Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Jan 2026 19:24:38 +0100 Subject: [PATCH 1/2] fix: reduce GitHub API calls and add configurable timeout (#7769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pre-generate GitHub installation token once per deployment instead of multiple times, reducing API calls from 4-6 down to 2 - Add configurable timeout (GITHUB_API_TIMEOUT env var, default 30s) - Add retry logic (3 retries with 200ms delay) for resilience - Pass token through deployment context to avoid regeneration - Fixes timeout issues on slow networks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Jobs/ApplicationDeploymentJob.php | 17 +++++++++++++++-- app/Models/Application.php | 10 ++++++---- app/Providers/AppServiceProvider.php | 6 ++++-- bootstrap/helpers/github.php | 25 ++++++++++++++++++++----- config/constants.php | 4 ++++ 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 84f362519..b593ea836 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -175,6 +175,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private Collection|string $build_secrets; + private ?string $github_access_token = null; + public function tags() { // Do not remove this one, it needs to properly identify which worker is running the job @@ -212,6 +214,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($source) { $this->source = $source->getMorphClass()::where('id', $this->application->source->id)->first(); } + + // Pre-generate GitHub installation token once to avoid multiple API calls during deployment + if ($this->source instanceof GithubApp && ! $this->source->is_public) { + try { + $this->github_access_token = generateGithubInstallationToken($this->source); + } catch (\Exception $e) { + // Token generation will be retried later if needed + $this->github_access_token = null; + } + } $this->server = Server::find($this->application_deployment_queue->server_id); $this->timeout = $this->server->settings->dynamic_timeout; $this->destination = $this->server->destinations()->where('id', $this->application_deployment_queue->destination_id)->first(); @@ -2065,7 +2077,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function check_git_if_build_needed() { if (is_object($this->source) && $this->source->getMorphClass() === \App\Models\GithubApp::class && $this->source->is_public === false) { - $repository = githubApi($this->source, "repos/{$this->customRepository}"); + $repository = githubApi($this->source, "repos/{$this->customRepository}", token: $this->github_access_token); $data = data_get($repository, 'data'); $repository_project_id = data_get($data, 'id'); if (isset($repository_project_id)) { @@ -2190,7 +2202,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue deployment_uuid: $this->deployment_uuid, pull_request_id: $this->pull_request_id, git_type: $this->git_type, - commit: $this->commit + commit: $this->commit, + github_access_token: $this->github_access_token ); return $commands; diff --git a/app/Models/Application.php b/app/Models/Application.php index 2eab8df20..a72d2e2b8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1143,7 +1143,7 @@ class Application extends BaseModel } } - public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_in_docker = true) + public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_in_docker = true, ?string $github_access_token = null) { $branch = $this->git_branch; ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); @@ -1163,7 +1163,8 @@ class Application extends BaseModel $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $base_command = "{$base_command} {$escapedRepoUrl}"; } else { - $github_access_token = generateGithubInstallationToken($this->source); + // Use provided token or generate a new one + $github_access_token = $github_access_token ?? generateGithubInstallationToken($this->source); if ($exec_in_docker) { $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; @@ -1250,7 +1251,7 @@ class Application extends BaseModel } } - public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null) + public function generateGitImportCommands(string $deployment_uuid, int $pull_request_id = 0, ?string $git_type = null, bool $exec_in_docker = true, bool $only_checkout = false, ?string $custom_base_dir = null, ?string $commit = null, ?string $github_access_token = null) { $branch = $this->git_branch; ['repository' => $customRepository, 'port' => $customPort] = $this->customRepository(); @@ -1301,7 +1302,8 @@ class Application extends BaseModel $commands->push($git_clone_command); } } else { - $github_access_token = generateGithubInstallationToken($this->source); + // Use provided token or generate a new one + $github_access_token = $github_access_token ?? generateGithubInstallationToken($this->source); if ($exec_in_docker) { $repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; $escapedRepoUrl = escapeshellarg($repoUrl); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 717daf2a2..40ed73829 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -66,16 +66,18 @@ class AppServiceProvider extends ServiceProvider private function configureGitHubHttp(): void { Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) { + $timeout = config('constants.github.api_timeout', 30); + if ($github_access_token) { return Http::withHeaders([ 'X-GitHub-Api-Version' => '2022-11-28', 'Accept' => 'application/vnd.github.v3+json', 'Authorization' => "Bearer $github_access_token", - ])->baseUrl($api_url); + ])->baseUrl($api_url)->timeout($timeout); } else { return Http::withHeaders([ 'Accept' => 'application/vnd.github.v3+json', - ])->baseUrl($api_url); + ])->baseUrl($api_url)->timeout($timeout); } }); } diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 4a61960fb..b8ea9a585 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -14,7 +14,19 @@ use Lcobucci\JWT\Token\Builder; function generateGithubToken(GithubApp $source, string $type) { - $response = Http::get("{$source->api_url}/zen"); + $timeout = config('constants.github.api_timeout', 30); + + $response = Http::timeout($timeout) + ->retry(3, 200, throw: false) + ->get("{$source->api_url}/zen"); + + if (! $response->successful()) { + throw new \Exception( + 'Failed to connect to GitHub API to sync time. '. + 'Please check your network connection and try again.' + ); + } + $serverTime = CarbonImmutable::now()->setTimezone('UTC'); $githubTime = Carbon::parse($response->header('date')); $timeDiff = abs($serverTime->diffInSeconds($githubTime)); @@ -44,11 +56,13 @@ function generateGithubToken(GithubApp $source, string $type) return match ($type) { 'jwt' => $jwt, - 'installation' => (function () use ($source, $jwt) { + 'installation' => (function () use ($source, $jwt, $timeout) { $response = Http::withHeaders([ 'Authorization' => "Bearer $jwt", 'Accept' => 'application/vnd.github.machine-man-preview+json', - ])->post("{$source->api_url}/app/installations/{$source->installation_id}/access_tokens"); + ])->timeout($timeout) + ->retry(3, 200, throw: false) + ->post("{$source->api_url}/app/installations/{$source->installation_id}/access_tokens"); if (! $response->successful()) { $error = data_get($response->json(), 'message', 'no error message found'); @@ -74,7 +88,7 @@ function generateGithubJwt(GithubApp $source) return generateGithubToken($source, 'jwt'); } -function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true) +function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true, ?string $token = null) { if (is_null($source)) { throw new \Exception('Source is required for API calls'); @@ -87,7 +101,8 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m if ($source->is_public) { $response = Http::GitHub($source->api_url)->$method($endpoint); } else { - $token = generateGithubInstallationToken($source); + // Use provided token or generate a new one + $token = $token ?? generateGithubInstallationToken($source); if ($data && in_array(strtolower($method), ['post', 'patch', 'put'])) { $response = Http::GitHub($source->api_url, $token)->$method($endpoint, $data); } else { diff --git a/config/constants.php b/config/constants.php index 499076e85..67c967de2 100644 --- a/config/constants.php +++ b/config/constants.php @@ -59,6 +59,10 @@ return [ 'minimum_required_version' => '24.0', ], + 'github' => [ + 'api_timeout' => env('GITHUB_API_TIMEOUT', 30), + ], + 'ssh' => [ 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)), 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), From 6bbae4097ed5b93660a5e12a85087e6ae794fe6a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:06:26 +0100 Subject: [PATCH 2/2] chore: prepare for PR --- app/Jobs/ApplicationDeploymentJob.php | 21 ++++++++++++--------- bootstrap/helpers/github.php | 9 ++++++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e70829dd8..e63e47211 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -217,15 +217,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->source = $source->getMorphClass()::where('id', $this->application->source->id)->first(); } - // Pre-generate GitHub installation token once to avoid multiple API calls during deployment - if ($this->source instanceof GithubApp && ! $this->source->is_public) { - try { - $this->github_access_token = generateGithubInstallationToken($this->source); - } catch (\Exception $e) { - // Token generation will be retried later if needed - $this->github_access_token = null; - } - } $this->server = Server::find($this->application_deployment_queue->server_id); $this->timeout = $this->server->settings->dynamic_timeout; $this->destination = $this->server->destinations()->where('id', $this->application_deployment_queue->destination_id)->first(); @@ -291,6 +282,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return; } + + // Generate GitHub installation token once per deployment execution (not in constructor, + // since queued jobs may sit in the queue long enough for tokens to expire) + if ($this->source instanceof GithubApp && ! $this->source->is_public) { + try { + $this->github_access_token = generateGithubInstallationToken($this->source); + } catch (\Exception $e) { + // Token generation will be retried later via null-coalesce fallback + $this->github_access_token = null; + } + } + try { // Make sure the private key is stored in the filesystem $this->server->privateKey->storeInFileSystem(); diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index b8ea9a585..baa10f134 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -61,7 +61,14 @@ function generateGithubToken(GithubApp $source, string $type) 'Authorization' => "Bearer $jwt", 'Accept' => 'application/vnd.github.machine-man-preview+json', ])->timeout($timeout) - ->retry(3, 200, throw: false) + ->retry(3, 200, function (\Exception $exception) { + // Don't retry auth errors — the same JWT won't become valid on retry + if ($exception instanceof \Illuminate\Http\Client\RequestException) { + return ! in_array($exception->response->status(), [401, 403]); + } + + return true; + }, throw: false) ->post("{$source->api_url}/app/installations/{$source->installation_id}/access_tokens"); if (! $response->successful()) {