Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 136 additions & 66 deletions src/VCS/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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<mixed> $headers
* @param array<mixed> $params
* @param array<string, string> $headers
* @param bool $decode
* @return array<mixed>
*
* @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':
Expand All @@ -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);
}

/**
Expand Down
12 changes: 9 additions & 3 deletions src/VCS/Adapter/Git/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
}

/**
Expand Down
17 changes: 14 additions & 3 deletions src/VCS/Adapter/Git/GitLab.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? [];
}
Expand Down Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions src/VCS/Adapter/Git/Gitea.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? [];
}
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/VCS/Exception/ProviderRateLimited.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace Utopia\VCS\Exception;

class ProviderRateLimited extends \Exception
{
}
7 changes: 7 additions & 0 deletions src/VCS/Exception/ProviderRequestFailed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace Utopia\VCS\Exception;

class ProviderRequestFailed extends \Exception
{
}
7 changes: 7 additions & 0 deletions src/VCS/Exception/ProviderServerError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace Utopia\VCS\Exception;

class ProviderServerError extends \Exception
{
}
Loading