feat(provider): add health-probe contract and supporting value objects

Add getName(), ping(), and probe() to ProviderInterface. Introduce
ProbeTrait to classify HTTP/transport exceptions into typed error codes,
ProviderStatus as the result value object, and ProviderStatusType /
ProviderErrorCode as backing enums.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 14:13:07 +02:00
parent 225c614057
commit 71bfb38028
5 changed files with 123 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
trait ProbeTrait
{
public function probe(): ProviderStatus
{
if (!$this->isConfigured()) {
return new ProviderStatus($this->getName(), ProviderStatusType::NotConfigured);
}
try {
$this->ping();
return new ProviderStatus($this->getName(), ProviderStatusType::Ok);
} catch (\Throwable $e) {
return $this->statusFromException($e);
}
}
private function statusFromException(\Throwable $e): ProviderStatus
{
[$error, $message] = $this->classifyException($e);
return new ProviderStatus($this->getName(), ProviderStatusType::Error, $error, $message);
}
/** @return array{ProviderErrorCode, string} */
private function classifyException(\Throwable $e): array
{
if ($e instanceof TransportExceptionInterface) {
return [ProviderErrorCode::UrlUnreachable, 'Could not reach the server: ' . $e->getMessage()];
}
if ($e instanceof HttpExceptionInterface) {
$code = $e->getResponse()->getStatusCode();
return match (true) {
$code === 401 => [ProviderErrorCode::AuthFailed, 'Invalid or expired token — verify your credentials'],
$code === 403 => [ProviderErrorCode::AuthFailed, 'Access denied — token lacks the required scopes'],
$code === 404 => [ProviderErrorCode::UrlUnreachable, 'Endpoint not found — check the configured URL'],
default => [ProviderErrorCode::Unknown, "HTTP {$code}: " . $e->getMessage()],
};
}
$msg = $e->getMessage();
$lower = strtolower($msg);
$error = match (true) {
str_contains($msg, 'not found') || str_contains($msg, 'Could not resolve') => ProviderErrorCode::UserNotFound,
str_contains($msg, 'GraphQL error')
&& (str_contains($lower, 'unauthorized') || str_contains($lower, 'bad credentials')) => ProviderErrorCode::AuthFailed,
default => ProviderErrorCode::Unknown,
};
return [$error, $msg];
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Service;
enum ProviderErrorCode: string
{
case AuthFailed = 'auth_failed';
case UrlUnreachable = 'url_unreachable';
case UserNotFound = 'user_not_found';
case Unknown = 'unknown';
}
+6
View File
@@ -10,4 +10,10 @@ interface ProviderInterface
public function fetch(): array;
public function isConfigured(): bool;
public function getName(): string;
public function probe(): ProviderStatus;
public function ping(): void;
}
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Service;
final class ProviderStatus
{
public function __construct(
public readonly string $name,
public readonly ProviderStatusType $status,
public readonly ?ProviderErrorCode $error = null,
public readonly ?string $message = null,
) {}
/** @return array<string, string> */
public function toArray(): array
{
$data = ['status' => $this->status->value];
if ($this->error !== null) {
$data['error'] = $this->error->value;
}
if ($this->message !== null) {
$data['message'] = $this->message;
}
return $data;
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Service;
enum ProviderStatusType: string
{
case Ok = 'ok';
case Error = 'error';
case NotConfigured = 'not_configured';
}