diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index ab8c48cf..cd0fac7c 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -3,6 +3,9 @@ namespace Utopia\VCS; use Exception; +use Utopia\VCS\Exception\ProviderRateLimited; +use Utopia\VCS\Exception\ProviderRequestFailed; +use Utopia\VCS\Exception\ProviderServerError; abstract class Adapter { @@ -297,33 +300,31 @@ abstract public function getCommit(string $owner, string $repositoryName, string */ abstract public function getLatestCommit(string $owner, string $repositoryName, string $branch): array; + /** + * Maximum number of retry attempts for transient failures + */ + protected int $maxRetries = 3; + /** * Call * - * Make an API call + * Make an API call with automatic retries for transient failures. * * @param string $method * @param string $path + * @param array $headers * @param array $params - * @param array $headers * @param bool $decode * @return array * * @throws Exception + * @throws ProviderServerError + * @throws ProviderRateLimited + * @throws ProviderRequestFailed */ protected function call(string $method, string $path = '', array $headers = [], array $params = [], bool $decode = true) { $headers = array_merge($this->headers, $headers); - $ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); - - if (!$ch) { - throw new Exception('Curl failed to initialize'); - } - - $responseHeaders = []; - $responseStatus = -1; - $responseType = ''; - $responseBody = ''; switch ($headers['content-type']) { case 'application/json': @@ -343,81 +344,150 @@ protected function call(string $method, string $path = '', array $headers = [], break; } + $formattedHeaders = []; foreach ($headers as $i => $header) { - $headers[] = $i . ':' . $header; - unset($headers[$i]); + $formattedHeaders[] = $i . ':' . $header; } - curl_setopt($ch, CURLOPT_PATH_AS_IS, 1); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36'); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { - $len = strlen($header); - $header = explode(':', $header, 2); - - if (count($header) < 2) { // ignore invalid headers + $lastException = null; + $lastResponseStatus = 0; + $lastResponseBody = ''; + $lastResponseHeaders = []; + + for ($attempt = 1; $attempt <= $this->maxRetries; $attempt++) { + $responseHeaders = []; + $ch = curl_init($this->endpoint . $path . (($method == self::METHOD_GET && !empty($params)) ? '?' . http_build_query($params) : '')); + + if (!$ch) { + throw new Exception('Curl failed to initialize'); + } + + curl_setopt($ch, CURLOPT_PATH_AS_IS, 1); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36'); + curl_setopt($ch, CURLOPT_HTTPHEADER, $formattedHeaders); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) { + $len = strlen($header); + $header = explode(':', $header, 2); + + if (count($header) < 2) { // ignore invalid headers + return $len; + } + + $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); + return $len; + }); + + if ($method != self::METHOD_GET) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $query); } - $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]); + // Allow self signed certificates + if ($this->selfSigned) { + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + } - return $len; - }); + $responseBody = \curl_exec($ch) ?: ''; - if ($method != self::METHOD_GET) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $query); - } + if ($responseBody === true) { + $responseBody = ''; + } - // Allow self signed certificates - if ($this->selfSigned) { - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - } + $curlErrno = curl_errno($ch); + $curlError = curl_error($ch); + $responseStatus = \curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + // Handle curl-level network errors (retry) + if ($curlErrno) { + $lastException = new ProviderRequestFailed($curlError . ' with status code ' . $responseStatus, $responseStatus); + if ($attempt < $this->maxRetries) { + \usleep($this->getRetryDelay($attempt)); + continue; + } + throw $lastException; + } - $responseBody = \curl_exec($ch) ?: ''; + $responseType = $responseHeaders['content-type'] ?? ''; - if ($responseBody === true) { - $responseBody = ''; - } + if ($decode) { + $length = strpos($responseType, ';') ?: strlen($responseType); + switch (substr($responseType, 0, $length)) { + case 'application/json': + $json = \json_decode($responseBody, true); - $responseType = $responseHeaders['content-type'] ?? ''; - $responseStatus = \curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($json === null) { + throw new ProviderRequestFailed('Failed to parse response: ' . $responseBody, $responseStatus); + } - if ($decode) { - $length = strpos($responseType, ';') ?: strlen($responseType); - switch (substr($responseType, 0, $length)) { - case 'application/json': - $json = \json_decode($responseBody, true); + $responseBody = $json; + $json = null; + break; + } + } - if ($json === null) { - throw new Exception('Failed to parse response: ' . $responseBody); - } + $responseHeaders['status-code'] = $responseStatus; + + // Rate limited (429 or 403 with rate-limit headers) + if ($responseStatus === 429 || ($responseStatus === 403 && isset($responseHeaders['x-ratelimit-remaining']) && $responseHeaders['x-ratelimit-remaining'] === '0')) { + if ($attempt < $this->maxRetries) { + $retryAfter = isset($responseHeaders['retry-after']) ? (int) $responseHeaders['retry-after'] : null; + $delay = $retryAfter !== null ? $retryAfter * 1_000_000 : $this->getRetryDelay($attempt); + \usleep($delay); + continue; + } + throw new ProviderRateLimited('Rate limited by provider (HTTP ' . $responseStatus . ')', $responseStatus); + } - $responseBody = $json; - $json = null; - break; + // Server errors (5xx) — retry + if ($responseStatus >= 500) { + $lastResponseStatus = $responseStatus; + $lastResponseBody = $responseBody; + $lastResponseHeaders = $responseHeaders; + if ($attempt < $this->maxRetries) { + \usleep($this->getRetryDelay($attempt)); + continue; + } + throw new ProviderServerError( + 'Provider returned server error (HTTP ' . $responseStatus . ') for ' . $method . ' ' . $path, + $responseStatus + ); } - } - if ((curl_errno($ch)/* || 200 != $responseStatus*/)) { - throw new Exception(curl_error($ch) . ' with status code ' . $responseStatus, $responseStatus); + // Success or client error (4xx) — return immediately, no retry + return [ + 'headers' => $responseHeaders, + 'body' => $responseBody, + ]; } - $responseHeaders['status-code'] = $responseStatus; - - if ($responseStatus === 500) { - echo 'Server error(' . $method . ': ' . $path . '. Params: ' . json_encode($params) . '): ' . json_encode($responseBody) . "\n"; + // Should not reach here, but handle gracefully + if ($lastException) { + throw $lastException; } - return [ - 'headers' => $responseHeaders, - 'body' => $responseBody, - ]; + throw new ProviderServerError( + 'Provider returned server error (HTTP ' . $lastResponseStatus . ') for ' . $method . ' ' . $path, + $lastResponseStatus + ); + } + + /** + * Get retry delay in microseconds using exponential backoff + * + * @param int $attempt Current attempt number (1-based) + * @return int Delay in microseconds + */ + protected function getRetryDelay(int $attempt): int + { + // 1s, 2s, 4s + return (int) (pow(2, $attempt - 1) * 1_000_000); } /** diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index d9b45f6c..dbc47bdf 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -370,13 +370,19 @@ public function getRepositoryName(string $repositoryId): string $url = "/repositories/$repositoryId"; $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "Bearer $this->accessToken"]); + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode === 404) { + throw new RepositoryNotFound("Repository not found"); + } + $responseBody = $response['body'] ?? []; - if (!array_key_exists('name', $responseBody)) { - throw new RepositoryNotFound("Repository not found"); + if (!is_array($responseBody) || !array_key_exists('name', $responseBody)) { + throw new Exception("Unexpected response from provider: missing 'name' field (HTTP $responseHeadersStatusCode)"); } - return $responseBody['name'] ?? ''; + return $responseBody['name']; } /** diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 988cd3e7..ac3cc2c3 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -148,9 +148,12 @@ public function getRepository(string $owner, string $repositoryName): array $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; - if ($responseHeadersStatusCode >= 400) { + if ($responseHeadersStatusCode === 404) { throw new RepositoryNotFound("Repository not found"); } + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to get repository: HTTP {$responseHeadersStatusCode}"); + } return $response['body'] ?? []; } @@ -221,12 +224,20 @@ public function getRepositoryName(string $repositoryId): string $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode === 404) { + throw new RepositoryNotFound("Repository not found"); + } if ($responseHeadersStatusCode >= 400) { - throw new Exception("Repository {$repositoryId} not found"); + throw new Exception("Failed to get repository {$repositoryId}: HTTP {$responseHeadersStatusCode}"); } $responseBody = $response['body'] ?? []; - return $responseBody['path'] ?? ''; + + if (!is_array($responseBody) || !array_key_exists('path', $responseBody)) { + throw new Exception("Unexpected response from provider: missing 'path' field (HTTP $responseHeadersStatusCode)"); + } + + return $responseBody['path']; } public function getRepositoryTree(string $owner, string $repositoryName, string $branch, bool $recursive = false): array diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index bc6544ef..1e959e30 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -234,12 +234,14 @@ public function getRepository(string $owner, string $repositoryName): array $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); - $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; - if ($responseHeadersStatusCode >= 400) { + if ($responseHeadersStatusCode === 404) { throw new RepositoryNotFound("Repository not found"); } + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to get repository: HTTP {$responseHeadersStatusCode}"); + } return $response['body'] ?? []; } @@ -250,13 +252,19 @@ public function getRepositoryName(string $repositoryId): string $response = $this->call(self::METHOD_GET, $url, ['Authorization' => "token $this->accessToken"]); + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode === 404) { + throw new RepositoryNotFound("Repository not found"); + } + $responseBody = $response['body'] ?? []; - if (!array_key_exists('name', $responseBody)) { - throw new RepositoryNotFound("Repository not found"); + if (!is_array($responseBody) || !array_key_exists('name', $responseBody)) { + throw new Exception("Unexpected response from provider: missing 'name' field (HTTP $responseHeadersStatusCode)"); } - return $responseBody['name'] ?? ''; + return $responseBody['name']; } public function getRepositoryTree(string $owner, string $repositoryName, string $branch, bool $recursive = false): array diff --git a/src/VCS/Exception/ProviderRateLimited.php b/src/VCS/Exception/ProviderRateLimited.php new file mode 100644 index 00000000..a0cc0236 --- /dev/null +++ b/src/VCS/Exception/ProviderRateLimited.php @@ -0,0 +1,7 @@ +