Skip to content
Open
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 = [];
Comment on lines +352 to +355
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unused variables $lastResponseBody and $lastResponseHeaders

Both variables are assigned inside the retry loop but are never read anywhere in the method. They can be removed.

Suggested change
$lastException = null;
$lastResponseStatus = 0;
$lastResponseBody = '';
$lastResponseHeaders = [];
$lastException = null;
$lastResponseStatus = 0;


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);
Comment on lines +396 to +402
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 $responseBody === true is unreachable

curl_exec() with CURLOPT_RETURNTRANSFER=1 returns a string or false; the ?: '' operator already converts false to '', so $responseBody is always a string at this point and can never equal true. The check can be removed.

Suggested change
$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);
$responseBody = \curl_exec($ch) ?: '';
$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;
}
Comment on lines +419 to +432
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 JSON decode runs before HTTP status check, breaking 5xx retry

The JSON decoding block (lines 419–432) executes before the 5xx retry check (line 449). When a server returns a 500/503 with Content-Type: application/json but a non-JSON body (e.g., an HTML error page from a proxy/load balancer), json_decode returns null and ProviderRequestFailed is thrown immediately at line 426 — skipping all retry attempts for that request. The status code check for retryable errors should happen before decoding so that the retry path isn't short-circuited.

}

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) {
Comment on lines +448 to +453
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Remove the two corresponding in-loop assignments of the unused variables

Suggested change
// Server errors (5xx) — retry
if ($responseStatus >= 500) {
$lastResponseStatus = $responseStatus;
$lastResponseBody = $responseBody;
$lastResponseHeaders = $responseHeaders;
if ($attempt < $this->maxRetries) {
// Server errors (5xx) — retry
if ($responseStatus >= 500) {
$lastResponseStatus = $responseStatus;
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