Files
Jonathan Miller 3f3b1f79a0
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 42s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Generic: Repo Health / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Successful in 4s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 45s
chore: add PHPDoc to Priority 1 Enterprise classes
Added @since, @param, @see tags to:
- CliFramework: class-level @since, 2 undocumented methods
- GitHubAdapter: class @since/@see, constructor @param, property docs
- MokoGiteaAdapter: class @since/@see, constructor @param, property docs
- ApiClient: class @since

Wiki: created Coding-Standards page with full PHPDoc standard,
PHPCS exclusion rationale, and file structure patterns.

Partial progress on #137

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 23:26:07 -05:00

547 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Enterprise.API
* INGROUP: MokoStandards.Enterprise
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* PATH: /lib/Enterprise/ApiClient.php
* BRIEF: HTTP API client library
*/
/**
* API Client Library - Rate-limited, resilient API interactions.
*
* This class provides enterprise-grade API client capabilities with:
* - Automatic rate limiting with backoff
* - Retry logic with exponential backoff
* - Request tracking and throttling
* - Response caching
* - Circuit breaker pattern
* - Health monitoring
*
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @package MokoStandards\Enterprise
* @version 04.00.04
* @author MokoStandards Team
* @license GPL-3.0-or-later
*/
namespace MokoEnterprise;
use DateTime;
use DateTimeZone;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
/**
* Circuit breaker states.
*/
enum CircuitState: string
{
case CLOSED = 'closed'; // Normal operation
case OPEN = 'open'; // Failures exceeded threshold, blocking requests
case HALF_OPEN = 'half_open'; // Testing if service recovered
}
/**
* Exception raised when rate limit is exceeded.
*/
class RateLimitExceeded extends RuntimeException
{
}
/**
* Exception raised when circuit breaker is open.
*/
class CircuitBreakerOpen extends RuntimeException
{
}
/**
* Enterprise API client with rate limiting, retry logic, and circuit breaker.
*
* Features:
* - Rate limiting with configurable limits
* - Exponential backoff retry
* - Response caching with TTL
* - Circuit breaker pattern
* - Request tracking and metrics
*
* Example:
* ```php
* $client = new ApiClient(
* baseUrl: 'https://api.github.com',
* authToken: $token,
* maxRequestsPerHour: 5000
* );
* $response = $client->get('/repos/owner/repo');
* ```
*
* @since 04.00.00
*/
class ApiClient
{
private Client $httpClient;
private string $baseUrl;
private ?string $authToken;
private int $maxRequestsPerHour;
private int $maxRetries;
private float $retryBackoffFactor;
private int $cacheTtlSeconds;
private int $circuitBreakerThreshold;
private int $circuitBreakerTimeout;
private bool $enableCaching;
private string $userAgent;
private string $authScheme;
/** @var array<int> Request timestamps for rate limiting */
private array $requestTimestamps = [];
/** @var CacheItemPoolInterface Response cache */
private CacheItemPoolInterface $cache;
/** Circuit breaker state */
private CircuitState $circuitState = CircuitState::CLOSED;
/** Circuit breaker failure count */
private int $circuitFailureCount = 0;
/** Circuit breaker last failure time */
private ?DateTime $circuitLastFailure = null;
/** @var LoggerInterface|null Optional logger instance */
/** @var array<string, mixed> Request metrics */
private array $metrics = [
'total_requests' => 0,
'successful_requests' => 0,
'failed_requests' => 0,
'cache_hits' => 0,
'cache_misses' => 0,
'rate_limit_waits' => 0,
'circuit_breaker_trips' => 0,
];
public const VERSION = '04.06.00';
/**
* Initialize API client.
*
* @param string $baseUrl Base URL for API (e.g., 'https://api.github.com')
* @param string|null $authToken Authentication token (optional)
* @param int $maxRequestsPerHour Maximum requests per hour
* @param int $maxRetries Maximum retry attempts for failed requests
* @param float $retryBackoffFactor Exponential backoff factor
* @param int $cacheTtlSeconds Cache time-to-live in seconds
* @param int $circuitBreakerThreshold Failures before opening circuit
* @param int $circuitBreakerTimeout Seconds before attempting recovery
* @param bool $enableCaching Enable response caching
* @param string $userAgent User agent string
* @param LoggerInterface|null $logger Optional logger
* @param string $authScheme Authorization scheme ('Bearer' for GitHub, 'token' for Gitea)
*/
public function __construct(
string $baseUrl,
?string $authToken = null,
int $maxRequestsPerHour = 5000,
int $maxRetries = 3,
float $retryBackoffFactor = 2.0,
int $cacheTtlSeconds = 300,
int $circuitBreakerThreshold = 5,
int $circuitBreakerTimeout = 60,
bool $enableCaching = true,
string $userAgent = 'MokoStandards-APIClient/1.0',
?LoggerInterface $logger = null,
string $authScheme = 'Bearer'
) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->authToken = $authToken;
$this->maxRequestsPerHour = $maxRequestsPerHour;
$this->maxRetries = $maxRetries;
$this->retryBackoffFactor = $retryBackoffFactor;
$this->cacheTtlSeconds = $cacheTtlSeconds;
$this->circuitBreakerThreshold = $circuitBreakerThreshold;
$this->circuitBreakerTimeout = $circuitBreakerTimeout;
$this->enableCaching = $enableCaching;
$this->userAgent = $userAgent;
$this->authScheme = $authScheme;
// Initialize HTTP client
$this->httpClient = new Client([
'base_uri' => rtrim($this->baseUrl, '/') . '/',
'timeout' => 30,
'headers' => [
'User-Agent' => $this->userAgent,
'Accept' => 'application/json',
],
]);
// Initialize cache
$cacheDir = sys_get_temp_dir() . '/mokostandards/api_cache';
$this->cache = new FilesystemAdapter('api_client', $this->cacheTtlSeconds, $cacheDir);
}
/**
* Perform GET request.
*
* @param string $endpoint API endpoint
* @param array<string, mixed> $params Query parameters
* @return array<string, mixed> Response data
* @throws RateLimitExceeded
* @throws CircuitBreakerOpen
*/
public function get(string $endpoint, array $params = []): array
{
return $this->request('GET', $endpoint, ['query' => $params]);
}
/**
* Perform POST request.
*
* @param string $endpoint API endpoint
* @param array<string, mixed> $data Request body data
* @return array<string, mixed> Response data
* @throws RateLimitExceeded
* @throws CircuitBreakerOpen
*/
public function post(string $endpoint, array $data = []): array
{
return $this->request('POST', $endpoint, ['json' => $data]);
}
/**
* Perform PUT request.
*
* @param string $endpoint API endpoint
* @param array<string, mixed> $data Request body data
* @return array<string, mixed> Response data
* @throws RateLimitExceeded
* @throws CircuitBreakerOpen
*/
public function put(string $endpoint, array $data = []): array
{
return $this->request('PUT', $endpoint, ['json' => $data]);
}
/**
* Perform PATCH request.
*
* @param string $endpoint API endpoint
* @param array<string, mixed> $data Request body
* @return array<string, mixed> Response data
* @throws RateLimitExceeded
* @throws CircuitBreakerOpen
*/
public function patch(string $endpoint, array $data = []): array
{
return $this->request('PATCH', $endpoint, ['json' => $data]);
}
/**
* Perform DELETE request.
*
* @param string $endpoint API endpoint
* @return array<string, mixed> Response data
* @throws RateLimitExceeded
* @throws CircuitBreakerOpen
*/
public function delete(string $endpoint, ?array $body = null): array
{
return $this->request('DELETE', $endpoint, $body);
}
/**
* Perform HTTP request with rate limiting, caching, and resilience.
*
* @param string $method HTTP method
* @param string $endpoint API endpoint
* @param array<string, mixed> $options Request options
* @return array<string, mixed> Response data
* @throws RateLimitExceeded
* @throws CircuitBreakerOpen
*/
private function request(string $method, string $endpoint, array $options = []): array
{
$this->metrics['total_requests']++;
// Check circuit breaker
$this->checkCircuitBreaker();
// Generate cache key
$cacheKey = $this->getCacheKey($method, $endpoint, $options);
// Check cache for GET requests
if ($method === 'GET' && $this->enableCaching) {
$cachedItem = $this->cache->getItem($cacheKey);
if ($cachedItem->isHit()) {
$this->metrics['cache_hits']++;
return $cachedItem->get();
}
$this->metrics['cache_misses']++;
}
// Check rate limit
$this->checkRateLimit();
// Ensure endpoint is relative so Guzzle base_uri path is preserved
$endpoint = ltrim($endpoint, '/');
// Add authentication
if ($this->authToken) {
$options['headers']['Authorization'] = $this->authScheme . ' ' . $this->authToken;
}
// Perform request with retry logic
$response = $this->requestWithRetry($method, $endpoint, $options);
// Cache successful GET responses
if ($method === 'GET' && $this->enableCaching) {
$cachedItem = $this->cache->getItem($cacheKey);
$cachedItem->set($response);
$cachedItem->expiresAfter($this->cacheTtlSeconds);
$this->cache->save($cachedItem);
}
return $response;
}
/**
* Perform request with exponential backoff retry.
*
* @param string $method HTTP method
* @param string $endpoint API endpoint
* @param array<string, mixed> $options Request options
* @return array<string, mixed> Response data
* @throws RuntimeException
*/
private function requestWithRetry(string $method, string $endpoint, array $options): array
{
$attempt = 0;
$lastException = null;
while ($attempt < $this->maxRetries) {
try {
$response = $this->httpClient->request($method, $endpoint, $options);
$body = (string) $response->getBody();
$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
$this->metrics['successful_requests']++;
$this->recordSuccess();
return $data;
} catch (GuzzleException $e) {
$lastException = $e;
$attempt++;
// Do not retry 4xx client errors — they indicate a definitive
// "not found / forbidden / conflict" response, not a transient fault.
// Retrying wastes time and inflates the circuit-breaker failure count.
$statusCode = ($e instanceof RequestException && $e->hasResponse())
? $e->getResponse()->getStatusCode()
: 0;
if ($statusCode >= 400 && $statusCode < 500) {
$this->recordFailure();
break;
}
if ($attempt < $this->maxRetries) {
$waitTime = $this->retryBackoffFactor ** $attempt;
usleep((int) ($waitTime * 1000000));
}
$this->recordFailure();
}
}
$this->metrics['failed_requests']++;
throw new RuntimeException(
"Request failed after {$this->maxRetries} attempts: " . ($lastException?->getMessage() ?? 'Unknown error')
);
}
/**
* Check and enforce rate limit.
*
* @throws RateLimitExceeded
*/
private function checkRateLimit(): void
{
$now = time();
$oneHourAgo = $now - 3600;
// Remove old timestamps
$this->requestTimestamps = array_filter(
$this->requestTimestamps,
fn($ts) => $ts > $oneHourAgo
);
// Check if limit exceeded
if (count($this->requestTimestamps) >= $this->maxRequestsPerHour) {
$oldestTimestamp = min($this->requestTimestamps);
$waitTime = 3600 - ($now - $oldestTimestamp);
$this->metrics['rate_limit_waits']++;
throw new RateLimitExceeded(
"Rate limit of {$this->maxRequestsPerHour} requests/hour exceeded. Wait {$waitTime} seconds."
);
}
// Record this request
$this->requestTimestamps[] = $now;
}
/**
* Check circuit breaker state.
*
* @throws CircuitBreakerOpen
*/
private function checkCircuitBreaker(): void
{
if ($this->circuitState === CircuitState::CLOSED) {
return;
}
if ($this->circuitState === CircuitState::OPEN) {
$now = new DateTime('now', new DateTimeZone('UTC'));
$timeSinceFailure = $now->getTimestamp() - $this->circuitLastFailure?->getTimestamp();
if ($timeSinceFailure >= $this->circuitBreakerTimeout) {
// Try half-open state
$this->circuitState = CircuitState::HALF_OPEN;
$this->circuitFailureCount = 0;
} else {
throw new CircuitBreakerOpen(
"Circuit breaker is open. Service unavailable. Retry in " .
($this->circuitBreakerTimeout - $timeSinceFailure) . " seconds."
);
}
}
}
/**
* Record successful request for circuit breaker.
*/
private function recordSuccess(): void
{
if ($this->circuitState === CircuitState::HALF_OPEN) {
// Service recovered, close circuit
$this->circuitState = CircuitState::CLOSED;
}
// Reset failure count on any success so only truly consecutive failures
// trip the breaker. Without this, expected 404s (e.g. checking if a branch
// or file exists before creating it) accumulate failure_count even when
// subsequent calls succeed, causing premature circuit-open events.
$this->circuitFailureCount = 0;
}
/**
* Record failed request for circuit breaker.
*/
private function recordFailure(): void
{
$this->circuitFailureCount++;
$this->circuitLastFailure = new DateTime('now', new DateTimeZone('UTC'));
if ($this->circuitFailureCount >= $this->circuitBreakerThreshold) {
$this->circuitState = CircuitState::OPEN;
$this->metrics['circuit_breaker_trips']++;
}
}
/**
* Generate cache key for request.
*
* @param string $method HTTP method
* @param string $endpoint API endpoint
* @param array<string, mixed> $options Request options
* @return string Cache key
*/
private function getCacheKey(string $method, string $endpoint, array $options): string
{
$key = $method . '_' . $endpoint;
if (isset($options['query'])) {
$key .= '_' . http_build_query($options['query']);
}
return md5($key);
}
/**
* Get current metrics.
*
* @return array<string, mixed> Metrics data
*/
public function getMetrics(): array
{
return array_merge($this->metrics, [
'circuit_state' => $this->circuitState->value,
'circuit_failure_count' => $this->circuitFailureCount,
'rate_limit_remaining' => max(0, $this->maxRequestsPerHour - count($this->requestTimestamps)),
]);
}
/**
* Get current circuit breaker state.
*
* @return string Circuit state ('CLOSED', 'OPEN', or 'HALF_OPEN')
*/
public function getCircuitState(): string
{
return strtoupper($this->circuitState->value);
}
/**
* Simulate a failure for testing circuit breaker functionality.
* This method is intended for testing only and checks for test environment.
*
* @throws RuntimeException If not in test environment, or always to simulate failure
*/
public function simulateFailure(): void
{
// Only allow in test/development environments
$allowedEnvs = ['test', 'testing', 'development', 'dev', 'ci'];
$currentEnv = getenv('APP_ENV') ?: $_ENV['APP_ENV'] ?? getenv('ENVIRONMENT') ?: $_ENV['ENVIRONMENT'] ?? $_SERVER['APP_ENV'] ?? 'production';
if (!in_array(strtolower($currentEnv), $allowedEnvs, true)) {
throw new RuntimeException('simulateFailure() can only be called in test environments');
}
$this->recordFailure();
throw new RuntimeException('Simulated failure for circuit breaker testing');
}
/**
* Reset circuit breaker to closed state.
*/
public function resetCircuitBreaker(): void
{
$this->circuitState = CircuitState::CLOSED;
$this->circuitFailureCount = 0;
$this->circuitLastFailure = null;
}
/**
* Clear response cache.
*/
public function clearCache(): void
{
$this->cache->clear();
}
}