289 lines
10 KiB
PHP
289 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\ApiGateway;
|
|
|
|
use App\Framework\ApiGateway\Exceptions\ApiGatewayException;
|
|
use App\Framework\ApiGateway\Metrics\ApiMetrics;
|
|
use App\Framework\CircuitBreaker\CircuitBreaker;
|
|
use App\Framework\CircuitBreaker\CircuitBreakerConfig;
|
|
use App\Framework\CircuitBreaker\CircuitBreakerManager;
|
|
use App\Framework\CircuitBreaker\Exceptions\CircuitBreakerException;
|
|
use App\Framework\Core\ValueObjects\Duration;
|
|
use App\Framework\HttpClient\ClientOptions;
|
|
use App\Framework\HttpClient\ClientRequest;
|
|
use App\Framework\HttpClient\ClientResponse;
|
|
use App\Framework\HttpClient\HttpClient;
|
|
use App\Framework\Logging\Logger;
|
|
use App\Framework\Performance\OperationTracker;
|
|
use App\Framework\Performance\PerformanceCategory;
|
|
use App\Framework\Retry\RetryStrategy;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Central API Gateway for all outbound API calls
|
|
*
|
|
* Provides cross-cutting concerns:
|
|
* - Circuit breaker pattern for resilience
|
|
* - Retry strategies for transient failures
|
|
* - Centralized logging and metrics
|
|
* - Request/response transformation
|
|
*
|
|
* Example usage:
|
|
*
|
|
* $request = new SendEmailApiRequest($email, $template);
|
|
* $response = $apiGateway->send($request);
|
|
*/
|
|
final readonly class ApiGateway
|
|
{
|
|
public function __construct(
|
|
private HttpClient $httpClient,
|
|
private CircuitBreakerManager $circuitBreakerManager,
|
|
private ApiMetrics $metrics,
|
|
private OperationTracker $operationTracker,
|
|
private ?Logger $logger = null
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Send an API request through the gateway
|
|
*
|
|
* @param ApiRequest $request The API request to send
|
|
* @return ClientResponse The response from the API
|
|
* @throws ApiGatewayException If the request fails after all retries
|
|
* @throws CircuitBreakerException If the circuit breaker is open
|
|
*/
|
|
public function send(ApiRequest $request): ClientResponse
|
|
{
|
|
$service = $request->getEndpoint()->getServiceIdentifier();
|
|
$requestName = $request->getRequestName();
|
|
|
|
// Start performance tracking
|
|
$operationId = "api_gateway.{$service}.{$requestName}." . uniqid();
|
|
$snapshot = $this->operationTracker->startOperation(
|
|
operationId: $operationId,
|
|
category: PerformanceCategory::HTTP,
|
|
contextData: [
|
|
'service' => $service,
|
|
'request_name' => $requestName,
|
|
'method' => $request->getMethod()->value,
|
|
'endpoint' => $request->getEndpoint()->toString(),
|
|
]
|
|
);
|
|
|
|
$retryAttempts = 0;
|
|
$circuitBreakerTriggered = false;
|
|
|
|
// Log request start
|
|
$this->logger?->info("[ApiGateway] Sending request", [
|
|
'operation_id' => $operationId,
|
|
'service' => $service,
|
|
'request_name' => $requestName,
|
|
'method' => $request->getMethod()->value,
|
|
'endpoint' => $request->getEndpoint()->toString(),
|
|
]);
|
|
|
|
// Get circuit breaker for this service
|
|
$circuitBreaker = $this->circuitBreakerManager->getCircuitBreaker($service);
|
|
$circuitBreakerConfig = CircuitBreakerConfig::forHttpClient();
|
|
|
|
// Check circuit breaker status (throws if open)
|
|
try {
|
|
$circuitBreaker->check($service, $circuitBreakerConfig);
|
|
} catch (CircuitBreakerException $e) {
|
|
$circuitBreakerTriggered = true;
|
|
|
|
$this->logger?->warning("[ApiGateway] Circuit breaker open", [
|
|
'operation_id' => $operationId,
|
|
'service' => $service,
|
|
'request_name' => $requestName,
|
|
]);
|
|
|
|
// Record failure and metrics
|
|
$completedSnapshot = $this->operationTracker->failOperation($operationId, $e);
|
|
if ($completedSnapshot !== null) {
|
|
$this->recordMetrics($service, $requestName, $completedSnapshot, false, 0, true);
|
|
}
|
|
|
|
throw $e;
|
|
}
|
|
|
|
// Convert ApiRequest to ClientRequest
|
|
$clientRequest = $this->buildClientRequest($request);
|
|
|
|
// Execute with retry strategy if configured
|
|
$retryStrategy = $request->getRetryStrategy();
|
|
|
|
try {
|
|
if ($retryStrategy !== null) {
|
|
$result = $this->sendWithRetry($clientRequest, $retryStrategy, $service, $requestName, $operationId);
|
|
$response = $result['response'];
|
|
$retryAttempts = $result['attempts'];
|
|
} else {
|
|
$response = $this->httpClient->send($clientRequest);
|
|
}
|
|
|
|
// Record success with circuit breaker
|
|
$circuitBreaker->recordSuccess($service, $circuitBreakerConfig);
|
|
|
|
$this->logger?->info("[ApiGateway] Request successful", [
|
|
'operation_id' => $operationId,
|
|
'service' => $service,
|
|
'request_name' => $requestName,
|
|
'status' => $response->status->value,
|
|
'retry_attempts' => $retryAttempts,
|
|
]);
|
|
|
|
// Complete operation tracking and record metrics
|
|
$completedSnapshot = $this->operationTracker->completeOperation($operationId);
|
|
if ($completedSnapshot !== null) {
|
|
$this->recordMetrics($service, $requestName, $completedSnapshot, true, $retryAttempts, false);
|
|
}
|
|
|
|
return $response;
|
|
} catch (Throwable $exception) {
|
|
// Record failure with circuit breaker
|
|
$circuitBreaker->recordFailure($service, $exception, $circuitBreakerConfig);
|
|
|
|
$this->logger?->error("[ApiGateway] Request failed", [
|
|
'operation_id' => $operationId,
|
|
'service' => $service,
|
|
'request_name' => $requestName,
|
|
'error' => $exception->getMessage(),
|
|
'exception_class' => get_class($exception),
|
|
'retry_attempts' => $retryAttempts,
|
|
]);
|
|
|
|
// Record failure and metrics
|
|
$completedSnapshot = $this->operationTracker->failOperation($operationId, $exception);
|
|
if ($completedSnapshot !== null) {
|
|
$this->recordMetrics($service, $requestName, $completedSnapshot, false, $retryAttempts, $circuitBreakerTriggered);
|
|
}
|
|
|
|
throw new ApiGatewayException(
|
|
"API request failed for {$requestName}",
|
|
0,
|
|
$exception
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build ClientRequest from ApiRequest
|
|
*/
|
|
private function buildClientRequest(ApiRequest $request): ClientRequest
|
|
{
|
|
// Convert timeout Duration to seconds for ClientOptions
|
|
$timeoutSeconds = $request->getTimeout()->toSeconds();
|
|
|
|
$options = new ClientOptions(
|
|
timeout: $timeoutSeconds,
|
|
connectTimeout: min(3, $timeoutSeconds), // Connect timeout max 3s or total timeout
|
|
);
|
|
|
|
// Use factory method for JSON requests if payload is present
|
|
if ($request instanceof HasPayload) {
|
|
return ClientRequest::json(
|
|
method: $request->getMethod(),
|
|
url: $request->getEndpoint()->toString(),
|
|
data: $request->getPayload(),
|
|
options: $options
|
|
)->with([
|
|
'headers' => $request->getHeaders(),
|
|
]);
|
|
}
|
|
|
|
// Simple request without body
|
|
return new ClientRequest(
|
|
method: $request->getMethod(),
|
|
url: $request->getEndpoint()->toString(),
|
|
headers: $request->getHeaders(),
|
|
body: '',
|
|
options: $options
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Send request with retry strategy
|
|
*
|
|
* @return array{response: ClientResponse, attempts: int}
|
|
*/
|
|
private function sendWithRetry(
|
|
ClientRequest $request,
|
|
RetryStrategy $retryStrategy,
|
|
string $service,
|
|
string $requestName,
|
|
string $operationId
|
|
): array {
|
|
$attempt = 0;
|
|
|
|
while (true) {
|
|
try {
|
|
if ($attempt > 0) {
|
|
$this->logger?->info("[ApiGateway] Retry attempt", [
|
|
'operation_id' => $operationId,
|
|
'service' => $service,
|
|
'request_name' => $requestName,
|
|
'attempt' => $attempt,
|
|
]);
|
|
}
|
|
|
|
$response = $this->httpClient->send($request);
|
|
|
|
return [
|
|
'response' => $response,
|
|
'attempts' => $attempt,
|
|
];
|
|
} catch (Throwable $exception) {
|
|
$attempt++;
|
|
|
|
if (!$retryStrategy->shouldRetry($exception, $attempt)) {
|
|
$this->logger?->warning("[ApiGateway] Retry limit reached", [
|
|
'operation_id' => $operationId,
|
|
'service' => $service,
|
|
'request_name' => $requestName,
|
|
'attempts' => $attempt,
|
|
]);
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
$delay = $retryStrategy->getDelay($attempt);
|
|
|
|
$this->logger?->info("[ApiGateway] Retrying after delay", [
|
|
'operation_id' => $operationId,
|
|
'service' => $service,
|
|
'request_name' => $requestName,
|
|
'attempt' => $attempt,
|
|
'delay_ms' => $delay->toMilliseconds(),
|
|
]);
|
|
|
|
// Sleep before retry
|
|
usleep($delay->toMicroseconds());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record metrics from performance snapshot
|
|
*/
|
|
private function recordMetrics(
|
|
string $service,
|
|
string $requestName,
|
|
\App\Framework\Performance\PerformanceSnapshot $snapshot,
|
|
bool $success,
|
|
int $retryAttempts,
|
|
bool $circuitBreakerTriggered
|
|
): void {
|
|
$this->metrics->recordRequest(
|
|
service: $service,
|
|
requestName: $requestName,
|
|
durationMs: $snapshot->duration->toMilliseconds(),
|
|
success: $success,
|
|
retryAttempts: $retryAttempts,
|
|
circuitBreakerTriggered: $circuitBreakerTriggered
|
|
);
|
|
}
|
|
}
|