Skip to content
20 changes: 20 additions & 0 deletions backend/app/Exceptions/GitHubAuthenticationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Exceptions;

/**
* The GitHub API rejected the request as unauthenticated or unauthorized
* (HTTP 401, or a 403 that does not carry a rate-limit signal).
*/
class GitHubAuthenticationException extends GitHubClientException
{
public static function forRepository(string $repository, int $status): self
{
return new self("GitHub authentication or permission check failed (HTTP {$status}) while reading [{$repository}].");
}

public function failureCode(): string
{
return 'github_authentication_failed';
}
}
29 changes: 29 additions & 0 deletions backend/app/Exceptions/GitHubClientException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace App\Exceptions;

use Illuminate\Support\Str;
use RuntimeException;

/**
* Base type for every failure a GitHubClient implementation can raise.
*
* IngestionService::run() catches this single type so that any GitHub
* domain failure ends the ingestion run explicitly as "failed" with a
* stable log code, instead of leaving the run stuck in "running" or
* silently succeeding with no documents.
*/
abstract class GitHubClientException extends RuntimeException
{
/**
* Stable, log-safe code identifying the failure category.
*
* Defaults to the snake_cased class name so existing exception types
* keep their historical codes; new categories override this with an
* explicit, cleaner literal.
*/
public function failureCode(): string
{
return Str::snake(class_basename($this));
}
}
22 changes: 22 additions & 0 deletions backend/app/Exceptions/GitHubMalformedResponseException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\Exceptions;

/**
* GitHub returned a response whose payload cannot be trusted: a
* non-JSON body, a missing tree, an unsupported content encoding, or
* invalid base64 content. The ingestion run fails closed instead of
* silently producing an empty or partial result.
*/
class GitHubMalformedResponseException extends GitHubClientException
{
public static function forRepository(string $repository, string $detail): self
{
return new self("GitHub returned a malformed response ({$detail}) while reading [{$repository}].");
}

public function failureCode(): string
{
return 'github_malformed_response';
}
}
4 changes: 1 addition & 3 deletions backend/app/Exceptions/GitHubRateLimitException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

namespace App\Exceptions;

use RuntimeException;

class GitHubRateLimitException extends RuntimeException
class GitHubRateLimitException extends GitHubClientException
{
public static function forRepository(string $repository): self
{
Expand Down
4 changes: 1 addition & 3 deletions backend/app/Exceptions/GitHubRepositoryNotFoundException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

namespace App\Exceptions;

use RuntimeException;

class GitHubRepositoryNotFoundException extends RuntimeException
class GitHubRepositoryNotFoundException extends GitHubClientException
{
public static function forRepository(string $repository): self
{
Expand Down
22 changes: 22 additions & 0 deletions backend/app/Exceptions/GitHubTransientErrorException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\Exceptions;

/**
* GitHub reported a transient failure: a 5xx response or a network-level
* connection failure. No retry/backoff is attempted here; the ingestion
* run ends explicitly as failed so a later manual or scheduled sync can
* try again.
*/
class GitHubTransientErrorException extends GitHubClientException
{
public static function forRepository(string $repository, string $detail): self
{
return new self("GitHub reported a transient failure ({$detail}) while reading [{$repository}].");
}

public function failureCode(): string
{
return 'github_transient_error';
}
}
22 changes: 22 additions & 0 deletions backend/app/Exceptions/GitHubTreeTruncatedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\Exceptions;

/**
* GitHub reported the git tree as truncated. Treating a truncated tree as
* complete would silently skip documents, so the run fails closed with a
* dedicated code instead of folding into the generic malformed-response
* category.
*/
class GitHubTreeTruncatedException extends GitHubMalformedResponseException
{
public static function forRepository(string $repository, string $detail = 'tree was truncated'): self
{
return new self("GitHub returned a malformed response ({$detail}) while reading [{$repository}].");
}

public function failureCode(): string
{
return 'github_tree_truncated';
}
}
20 changes: 20 additions & 0 deletions backend/app/Exceptions/GitHubValidationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace App\Exceptions;

/**
* The GitHub API rejected the request as a conflict or validation failure
* (HTTP 409 or 422). Retrying with the same request will not help.
*/
class GitHubValidationException extends GitHubClientException
{
public static function forRepository(string $repository, int $status): self
{
return new self("GitHub rejected the request as a conflict or validation failure (HTTP {$status}) while reading [{$repository}].");
}

public function failureCode(): string
{
return 'github_validation_failed';
}
}
81 changes: 74 additions & 7 deletions backend/app/Services/GitHub/HttpGitHubClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@

use App\Contracts\GitHubClient;
use App\Data\GitHubMarkdownFile;
use App\Exceptions\GitHubAuthenticationException;
use App\Exceptions\GitHubClientException;
use App\Exceptions\GitHubMalformedResponseException;
use App\Exceptions\GitHubRateLimitException;
use App\Exceptions\GitHubRepositoryNotFoundException;
use App\Exceptions\GitHubTransientErrorException;
use App\Exceptions\GitHubTreeTruncatedException;
use App\Exceptions\GitHubValidationException;
use App\Models\Plugin;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
Expand All @@ -27,7 +34,17 @@ public function markdownFiles(Plugin $plugin, ?string $branch = null): array
'recursive' => '1',
]);

$files = collect($tree->json('tree', []))
$treeJson = $tree->json();

if (! is_array($treeJson) || ! is_array($treeJson['tree'] ?? null)) {
throw GitHubMalformedResponseException::forRepository($repository, 'response body did not include a tree');
}

if ($treeJson['truncated'] ?? false) {
throw GitHubTreeTruncatedException::forRepository($repository);
}

$files = collect($treeJson['tree'])
->filter(fn (array $node): bool => ($node['type'] ?? null) === 'blob')
->pluck('path')
->filter(fn (string $path): bool => Str::endsWith(Str::lower($path), '.md'))
Expand All @@ -40,6 +57,14 @@ public function markdownFiles(Plugin $plugin, ?string $branch = null): array
}

/**
* Perform a GitHub API request and fail closed on every response that
* is not a trusted success.
*
* Every non-2xx/304 status, and every connection-level failure, is
* mapped to a stable {@see GitHubClientException}
* subtype so callers never mistake a GitHub-side failure for an empty
* or successful result.
*
* @param array<string, string> $query
*/
private function request(string $repository, string $uri, array $query = [], array $headers = []): Response
Expand All @@ -51,19 +76,55 @@ private function request(string $repository, string $uri, array $query = [], arr
'X-GitHub-Api-Version' => '2022-11-28',
] + $headers));

$response = $request->get($uri, $query);
try {
$response = $request->get($uri, $query);
} catch (ConnectionException) {
throw GitHubTransientErrorException::forRepository($repository, 'connection failure');
}

if ($response->status() === 403) {
throw GitHubRateLimitException::forRepository($repository);
$status = $response->status();

if ($status === 304) {
return $response;
}

if ($response->status() === 404) {
if ($status === 404) {
throw GitHubRepositoryNotFoundException::forRepository($repository);
}

if ($status === 429 || ($status === 403 && $this->isRateLimitSignal($response))) {
throw GitHubRateLimitException::forRepository($repository);
}

if ($status === 401 || $status === 403) {
throw GitHubAuthenticationException::forRepository($repository, $status);
}

if ($status === 409 || $status === 422) {
throw GitHubValidationException::forRepository($repository, $status);
}

if ($status >= 500) {
throw GitHubTransientErrorException::forRepository($repository, "HTTP {$status}");
}

if (! $response->successful()) {
throw GitHubMalformedResponseException::forRepository($repository, "unexpected HTTP {$status}");
}

return $response;
}

/**
* GitHub signals primary rate limiting on a 403 response via the
* `X-RateLimit-Remaining` header. A 403 without that signal is an
* authentication/permission failure instead.
*/
private function isRateLimitSignal(Response $response): bool
{
return $response->header('X-RateLimit-Remaining') === '0';
}

private function readMarkdownFile(string $repository, string $branch, string $path): GitHubMarkdownFile
{
$cacheKey = $this->etagCacheKey($repository, $branch, $path);
Expand All @@ -90,12 +151,18 @@ private function readMarkdownFile(string $repository, string $branch, string $pa
$encoding = $response->json('encoding');

if (! is_string($content) || $encoding !== 'base64') {
throw new RuntimeException("GitHub returned unsupported content encoding for [{$path}].");
throw GitHubMalformedResponseException::forRepository($repository, "unsupported content encoding for [{$path}]");
}

$decoded = base64_decode(str_replace("\n", '', $content), true);

if ($decoded === false) {
throw GitHubMalformedResponseException::forRepository($repository, "invalid base64 content for [{$path}]");
}

return new GitHubMarkdownFile(
path: $path,
content: base64_decode(str_replace("\n", '', $content), true) ?: '',
content: $decoded,
etag: $nextEtag,
);
}
Expand Down
7 changes: 3 additions & 4 deletions backend/app/Services/Ingestion/IngestionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
use App\Data\ParsedDocument;
use App\Events\CommandIndexUpdated;
use App\Events\IngestionRunStatusChanged;
use App\Exceptions\GitHubRateLimitException;
use App\Exceptions\GitHubRepositoryNotFoundException;
use App\Exceptions\GitHubClientException;
use App\Models\Category;
use App\Models\Command;
use App\Models\Document;
Expand Down Expand Up @@ -84,10 +83,10 @@ public function run(PluginVersion $pluginVersion, ?IngestionRun $run = null): In

try {
$files = $this->github->markdownFiles($pluginVersion->plugin, $pluginVersion->git_ref ?: $pluginVersion->plugin->default_branch);
} catch (GitHubRateLimitException|GitHubRepositoryNotFoundException $exception) {
} catch (GitHubClientException $exception) {
return $this->fail($run, $stats, [[
'level' => 'error',
'code' => Str::snake(class_basename($exception)),
'code' => $exception->failureCode(),
'message' => $exception->getMessage(),
]]);
}
Expand Down
Loading
Loading