-
Notifications
You must be signed in to change notification settings - Fork 0
Error Handling
Rumen Damyanov edited this page Jul 31, 2025
·
2 revisions
Comprehensive error handling patterns, exception management, and debugging strategies for php-geolocation.
- Exception Hierarchy
- Error Types
- Exception Handling
- Logging and Monitoring
- Graceful Degradation
- Custom Error Handlers
- Debugging Techniques
- Error Recovery
- Testing Error Scenarios
- Best Practices
<?php
// src/Exceptions/GeolocationException.php
namespace Rumenx\Geolocation\Exceptions;
class GeolocationException extends \Exception
{
protected string $errorCode;
protected array $context;
public function __construct(
string $message = '',
int $code = 0,
?\Throwable $previous = null,
string $errorCode = '',
array $context = []
) {
parent::__construct($message, $code, $previous);
$this->errorCode = $errorCode;
$this->context = $context;
}
public function getErrorCode(): string
{
return $this->errorCode;
}
public function getContext(): array
{
return $this->context;
}
public function toArray(): array
{
return [
'error' => true,
'type' => get_class($this),
'message' => $this->getMessage(),
'code' => $this->getCode(),
'error_code' => $this->errorCode,
'context' => $this->context,
'file' => $this->getFile(),
'line' => $this->getLine(),
'trace' => $this->getTrace()
];
}
}
// Provider-related exceptions
class ProviderException extends GeolocationException {}
class ProviderUnavailableException extends ProviderException {}
class ProviderAuthenticationException extends ProviderException {}
class ProviderRateLimitException extends ProviderException {}
class ProviderTimeoutException extends ProviderException {}
// Data-related exceptions
class InvalidIPException extends GeolocationException {}
class InvalidDataException extends GeolocationException {}
class DataNotFoundException extends GeolocationException {}
// Cache-related exceptions
class CacheException extends GeolocationException {}
class CacheConnectionException extends CacheException {}
class CacheSerializationException extends CacheException {}
// Configuration exceptions
class ConfigurationException extends GeolocationException {}
class InvalidConfigurationException extends ConfigurationException {}
class MissingConfigurationException extends ConfigurationException {}
// API-related exceptions
class ApiException extends GeolocationException {}
class ApiAuthenticationException extends ApiException {}
class ApiRateLimitException extends ApiException {}
class ApiValidationException extends ApiException {}
// Network exceptions
class NetworkException extends GeolocationException {}
class ConnectionException extends NetworkException {}
class TimeoutException extends NetworkException {}
class SSLException extends NetworkException {}<?php
// src/Exceptions/ProviderExceptions.php
class CloudflareException extends ProviderException
{
public static function missingHeaders(array $requiredHeaders): self
{
return new self(
'Required CloudFlare headers are missing',
1001,
null,
'CLOUDFLARE_MISSING_HEADERS',
['required_headers' => $requiredHeaders]
);
}
public static function invalidCountryCode(string $countryCode): self
{
return new self(
"Invalid country code received from CloudFlare: {$countryCode}",
1002,
null,
'CLOUDFLARE_INVALID_COUNTRY',
['country_code' => $countryCode]
);
}
}
class MaxMindException extends ProviderException
{
public static function databaseNotFound(string $path): self
{
return new self(
"MaxMind database not found at: {$path}",
2001,
null,
'MAXMIND_DATABASE_NOT_FOUND',
['database_path' => $path]
);
}
public static function licenseKeyMissing(): self
{
return new self(
'MaxMind license key is required but not configured',
2002,
null,
'MAXMIND_LICENSE_MISSING'
);
}
public static function ipNotFound(string $ip): self
{
return new self(
"IP address not found in MaxMind database: {$ip}",
2003,
null,
'MAXMIND_IP_NOT_FOUND',
['ip_address' => $ip]
);
}
}
class ExternalApiException extends ProviderException
{
public static function rateLimitExceeded(string $provider, int $resetTime): self
{
return new self(
"Rate limit exceeded for provider: {$provider}",
3001,
null,
'EXTERNAL_API_RATE_LIMIT',
[
'provider' => $provider,
'reset_time' => $resetTime,
'retry_after' => $resetTime - time()
]
);
}
public static function apiKeyInvalid(string $provider): self
{
return new self(
"Invalid API key for provider: {$provider}",
3002,
null,
'EXTERNAL_API_INVALID_KEY',
['provider' => $provider]
);
}
public static function unexpectedResponse(string $provider, int $statusCode, string $response): self
{
return new self(
"Unexpected response from provider: {$provider}",
3003,
null,
'EXTERNAL_API_UNEXPECTED_RESPONSE',
[
'provider' => $provider,
'status_code' => $statusCode,
'response' => substr($response, 0, 500)
]
);
}
}<?php
// src/ErrorClassification.php
namespace Rumenx\Geolocation;
class ErrorClassification
{
const SEVERITY_LOW = 'low';
const SEVERITY_MEDIUM = 'medium';
const SEVERITY_HIGH = 'high';
const SEVERITY_CRITICAL = 'critical';
const CATEGORY_PROVIDER = 'provider';
const CATEGORY_NETWORK = 'network';
const CATEGORY_DATA = 'data';
const CATEGORY_CACHE = 'cache';
const CATEGORY_CONFIG = 'configuration';
const CATEGORY_API = 'api';
private static array $errorMap = [
// Provider errors
1001 => ['severity' => self::SEVERITY_MEDIUM, 'category' => self::CATEGORY_PROVIDER, 'recoverable' => true],
1002 => ['severity' => self::SEVERITY_LOW, 'category' => self::CATEGORY_PROVIDER, 'recoverable' => true],
2001 => ['severity' => self::SEVERITY_HIGH, 'category' => self::CATEGORY_PROVIDER, 'recoverable' => false],
2002 => ['severity' => self::SEVERITY_HIGH, 'category' => self::CATEGORY_CONFIG, 'recoverable' => false],
2003 => ['severity' => self::SEVERITY_LOW, 'category' => self::CATEGORY_DATA, 'recoverable' => true],
3001 => ['severity' => self::SEVERITY_MEDIUM, 'category' => self::CATEGORY_PROVIDER, 'recoverable' => true],
3002 => ['severity' => self::SEVERITY_HIGH, 'category' => self::CATEGORY_CONFIG, 'recoverable' => false],
3003 => ['severity' => self::SEVERITY_MEDIUM, 'category' => self::CATEGORY_PROVIDER, 'recoverable' => true],
// Network errors
4001 => ['severity' => self::SEVERITY_MEDIUM, 'category' => self::CATEGORY_NETWORK, 'recoverable' => true],
4002 => ['severity' => self::SEVERITY_HIGH, 'category' => self::CATEGORY_NETWORK, 'recoverable' => true],
4003 => ['severity' => self::SEVERITY_MEDIUM, 'category' => self::CATEGORY_NETWORK, 'recoverable' => true],
// Cache errors
5001 => ['severity' => self::SEVERITY_LOW, 'category' => self::CATEGORY_CACHE, 'recoverable' => true],
5002 => ['severity' => self::SEVERITY_MEDIUM, 'category' => self::CATEGORY_CACHE, 'recoverable' => true],
// API errors
6001 => ['severity' => self::SEVERITY_MEDIUM, 'category' => self::CATEGORY_API, 'recoverable' => false],
6002 => ['severity' => self::SEVERITY_HIGH, 'category' => self::CATEGORY_API, 'recoverable' => false],
6003 => ['severity' => self::SEVERITY_LOW, 'category' => self::CATEGORY_API, 'recoverable' => false]
];
public static function classify(int $errorCode): array
{
return self::$errorMap[$errorCode] ?? [
'severity' => self::SEVERITY_MEDIUM,
'category' => 'unknown',
'recoverable' => false
];
}
public static function isRecoverable(int $errorCode): bool
{
$classification = self::classify($errorCode);
return $classification['recoverable'];
}
public static function getSeverity(int $errorCode): string
{
$classification = self::classify($errorCode);
return $classification['severity'];
}
public static function getCategory(int $errorCode): string
{
$classification = self::classify($errorCode);
return $classification['category'];
}
}<?php
// src/ExceptionHandler.php
namespace Rumenx\Geolocation;
use Rumenx\Geolocation\Exceptions\GeolocationException;
use Psr\Log\LoggerInterface;
class ExceptionHandler
{
private LoggerInterface $logger;
private array $config;
private array $errorStats = [];
public function __construct(LoggerInterface $logger, array $config = [])
{
$this->logger = $logger;
$this->config = array_merge($this->getDefaultConfig(), $config);
}
private function getDefaultConfig(): array
{
return [
'log_all_exceptions' => true,
'log_stack_trace' => false,
'notify_on_critical' => true,
'max_error_rate' => 0.1, // 10%
'error_rate_window' => 300, // 5 minutes
'fallback_enabled' => true,
'graceful_degradation' => true
];
}
public function handle(\Throwable $exception, array $context = []): mixed
{
// Track error statistics
$this->trackError($exception);
// Log the exception
$this->logException($exception, $context);
// Handle specific exception types
if ($exception instanceof GeolocationException) {
return $this->handleGeolocationException($exception, $context);
}
// Handle other exceptions
return $this->handleGenericException($exception, $context);
}
private function handleGeolocationException(GeolocationException $exception, array $context): mixed
{
$errorCode = $exception->getCode();
$classification = ErrorClassification::classify($errorCode);
// Check if error is recoverable
if ($classification['recoverable'] && $this->config['fallback_enabled']) {
return $this->attemptRecovery($exception, $context);
}
// Check severity for notifications
if ($classification['severity'] === ErrorClassification::SEVERITY_CRITICAL) {
$this->notifyCriticalError($exception);
}
// Return appropriate response based on configuration
if ($this->config['graceful_degradation']) {
return $this->getGracefulFallback($exception);
}
// Re-throw if no graceful handling
throw $exception;
}
private function handleGenericException(\Throwable $exception, array $context): mixed
{
// Log unexpected exceptions
$this->logger->error('Unexpected exception in geolocation', [
'exception' => get_class($exception),
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'context' => $context
]);
if ($this->config['graceful_degradation']) {
return $this->getDefaultFallback();
}
throw $exception;
}
private function attemptRecovery(GeolocationException $exception, array $context): mixed
{
$category = ErrorClassification::getCategory($exception->getCode());
switch ($category) {
case ErrorClassification::CATEGORY_PROVIDER:
return $this->recoverFromProviderError($exception, $context);
case ErrorClassification::CATEGORY_NETWORK:
return $this->recoverFromNetworkError($exception, $context);
case ErrorClassification::CATEGORY_CACHE:
return $this->recoverFromCacheError($exception, $context);
default:
return $this->getGracefulFallback($exception);
}
}
private function recoverFromProviderError(GeolocationException $exception, array $context): mixed
{
// Try alternative providers
$providerChain = $context['provider_chain'] ?? [];
$currentProvider = $context['current_provider'] ?? null;
// Remove failed provider and try next
$availableProviders = array_filter($providerChain, function($provider) use ($currentProvider) {
return $provider !== $currentProvider;
});
if (!empty($availableProviders)) {
$this->logger->info('Attempting provider failover', [
'failed_provider' => $currentProvider,
'next_provider' => reset($availableProviders),
'exception' => $exception->getMessage()
]);
// Return signal to try next provider
return ['retry_with_provider' => reset($availableProviders)];
}
return $this->getGracefulFallback($exception);
}
private function recoverFromNetworkError(GeolocationException $exception, array $context): mixed
{
$retryCount = $context['retry_count'] ?? 0;
$maxRetries = $context['max_retries'] ?? 3;
if ($retryCount < $maxRetries) {
$backoffDelay = min(pow(2, $retryCount), 30); // Exponential backoff, max 30s
$this->logger->info('Scheduling network retry', [
'retry_count' => $retryCount + 1,
'max_retries' => $maxRetries,
'backoff_delay' => $backoffDelay,
'exception' => $exception->getMessage()
]);
sleep($backoffDelay);
return ['retry' => true, 'retry_count' => $retryCount + 1];
}
return $this->getGracefulFallback($exception);
}
private function recoverFromCacheError(GeolocationException $exception, array $context): mixed
{
// Cache errors are typically non-fatal - continue without cache
$this->logger->warning('Cache error encountered, continuing without cache', [
'exception' => $exception->getMessage(),
'context' => $context
]);
return ['skip_cache' => true];
}
private function getGracefulFallback(GeolocationException $exception): array
{
return [
'country' => $this->config['default_country'] ?? 'US',
'country_name' => $this->config['default_country_name'] ?? 'United States',
'language' => $this->config['default_language'] ?? 'en',
'currency' => $this->config['default_currency'] ?? 'USD',
'timezone' => $this->config['default_timezone'] ?? 'UTC',
'fallback' => true,
'error' => $exception->getErrorCode()
];
}
private function getDefaultFallback(): array
{
return [
'country' => 'US',
'country_name' => 'United States',
'language' => 'en',
'currency' => 'USD',
'timezone' => 'UTC',
'fallback' => true,
'error' => 'UNKNOWN_ERROR'
];
}
private function trackError(\Throwable $exception): void
{
$errorType = get_class($exception);
$timestamp = time();
$windowStart = $timestamp - $this->config['error_rate_window'];
// Initialize error tracking for this type
if (!isset($this->errorStats[$errorType])) {
$this->errorStats[$errorType] = [];
}
// Add current error
$this->errorStats[$errorType][] = $timestamp;
// Clean old errors outside the window
$this->errorStats[$errorType] = array_filter(
$this->errorStats[$errorType],
function($time) use ($windowStart) {
return $time > $windowStart;
}
);
// Check error rate
$errorCount = count($this->errorStats[$errorType]);
$requestCount = $this->getTotalRequests();
if ($requestCount > 0) {
$errorRate = $errorCount / $requestCount;
if ($errorRate > $this->config['max_error_rate']) {
$this->notifyHighErrorRate($errorType, $errorRate);
}
}
}
private function logException(\Throwable $exception, array $context): void
{
if (!$this->config['log_all_exceptions']) {
return;
}
$logContext = [
'exception_type' => get_class($exception),
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'context' => $context
];
if ($this->config['log_stack_trace']) {
$logContext['trace'] = $exception->getTraceAsString();
}
if ($exception instanceof GeolocationException) {
$logContext['error_code'] = $exception->getErrorCode();
$logContext['exception_context'] = $exception->getContext();
$severity = ErrorClassification::getSeverity($exception->getCode());
$logLevel = $this->mapSeverityToLogLevel($severity);
} else {
$logLevel = 'error';
}
$this->logger->log($logLevel, 'Geolocation exception occurred', $logContext);
}
private function mapSeverityToLogLevel(string $severity): string
{
return match($severity) {
ErrorClassification::SEVERITY_CRITICAL => 'critical',
ErrorClassification::SEVERITY_HIGH => 'error',
ErrorClassification::SEVERITY_MEDIUM => 'warning',
ErrorClassification::SEVERITY_LOW => 'info',
default => 'error'
};
}
private function notifyCriticalError(GeolocationException $exception): void
{
if (!$this->config['notify_on_critical']) {
return;
}
// Implementation would depend on your notification system
$this->logger->critical('Critical geolocation error', [
'exception' => $exception->toArray(),
'alert' => true
]);
// Could integrate with services like Slack, PagerDuty, etc.
}
private function notifyHighErrorRate(string $errorType, float $errorRate): void
{
$this->logger->alert('High error rate detected', [
'error_type' => $errorType,
'error_rate' => $errorRate,
'threshold' => $this->config['max_error_rate'],
'window_seconds' => $this->config['error_rate_window']
]);
}
private function getTotalRequests(): int
{
// Implementation depends on your request tracking system
// This is a simplified version
return array_sum(array_map('count', $this->errorStats)) * 10; // Estimate
}
public function getErrorStatistics(): array
{
$stats = [];
foreach ($this->errorStats as $errorType => $timestamps) {
$stats[$errorType] = [
'count' => count($timestamps),
'last_occurrence' => max($timestamps),
'rate_per_hour' => count($timestamps) / ($this->config['error_rate_window'] / 3600)
];
}
return $stats;
}
}<?php
// src/FallbackManager.php
namespace Rumenx\Geolocation;
class FallbackManager
{
private array $config;
private array $fallbackData;
public function __construct(array $config = [])
{
$this->config = array_merge($this->getDefaultConfig(), $config);
$this->loadFallbackData();
}
private function getDefaultConfig(): array
{
return [
'enable_ip_fallback' => true,
'enable_accept_language_fallback' => true,
'enable_geoip_lite' => true,
'enable_timezone_detection' => true,
'cache_fallback_results' => true,
'fallback_confidence' => 0.3
];
}
public function getFallbackData(string $ip, array $context = []): array
{
$fallbackSources = [];
// Try IP-based fallback
if ($this->config['enable_ip_fallback']) {
$ipFallback = $this->getIPBasedFallback($ip);
if ($ipFallback) {
$fallbackSources['ip_based'] = $ipFallback;
}
}
// Try Accept-Language header fallback
if ($this->config['enable_accept_language_fallback']) {
$langFallback = $this->getLanguageFallback($context);
if ($langFallback) {
$fallbackSources['language_based'] = $langFallback;
}
}
// Try browser timezone detection
if ($this->config['enable_timezone_detection']) {
$timezoneFallback = $this->getTimezoneFallback($context);
if ($timezoneFallback) {
$fallbackSources['timezone_based'] = $timezoneFallback;
}
}
// Combine fallback data
return $this->combineFallbackData($fallbackSources);
}
private function getIPBasedFallback(string $ip): ?array
{
// Simple IP range to country mapping
$ipLong = ip2long($ip);
if ($ipLong === false) {
return null;
}
// Basic IP ranges (in production, use a proper GeoIP database)
$ranges = [
['start' => ip2long('8.8.8.0'), 'end' => ip2long('8.8.8.255'), 'country' => 'US'],
['start' => ip2long('1.1.1.0'), 'end' => ip2long('1.1.1.255'), 'country' => 'US'],
['start' => ip2long('208.67.222.0'), 'end' => ip2long('208.67.222.255'), 'country' => 'US'],
];
foreach ($ranges as $range) {
if ($ipLong >= $range['start'] && $ipLong <= $range['end']) {
return $this->getCountryData($range['country']);
}
}
// Default to analyzing IP structure
return $this->analyzeIPStructure($ip);
}
private function analyzeIPStructure(string $ip): ?array
{
// Very basic IP analysis
$parts = explode('.', $ip);
if (count($parts) !== 4) {
return null;
}
$firstOctet = (int) $parts[0];
// Basic geographical distribution based on first octet
// This is very simplified and not accurate
if ($firstOctet >= 1 && $firstOctet <= 50) {
return $this->getCountryData('US');
} elseif ($firstOctet >= 51 && $firstOctet <= 100) {
return $this->getCountryData('GB');
} elseif ($firstOctet >= 101 && $firstOctet <= 150) {
return $this->getCountryData('DE');
}
return $this->getCountryData('US'); // Default fallback
}
private function getLanguageFallback(array $context): ?array
{
$acceptLanguage = $context['accept_language'] ?? $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
if (empty($acceptLanguage)) {
return null;
}
// Parse Accept-Language header
$languages = [];
preg_match_all('/([a-z]{2}(?:-[A-Z]{2})?)(;q=([0-9.]+))?/', $acceptLanguage, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$lang = $match[1];
$quality = isset($match[3]) ? (float) $match[3] : 1.0;
$languages[$lang] = $quality;
}
// Sort by quality
arsort($languages);
// Map language to country
$langToCountry = [
'en-US' => 'US',
'en-GB' => 'GB',
'en-CA' => 'CA',
'en-AU' => 'AU',
'de' => 'DE',
'de-DE' => 'DE',
'fr' => 'FR',
'fr-FR' => 'FR',
'fr-CA' => 'CA',
'es' => 'ES',
'es-ES' => 'ES',
'es-MX' => 'MX',
'it' => 'IT',
'pt' => 'PT',
'pt-BR' => 'BR',
'ja' => 'JP',
'ko' => 'KR',
'zh' => 'CN',
'zh-CN' => 'CN',
'zh-TW' => 'TW',
'ru' => 'RU'
];
foreach ($languages as $lang => $quality) {
if (isset($langToCountry[$lang])) {
$data = $this->getCountryData($langToCountry[$lang]);
$data['confidence'] = $quality * 0.7; // Language-based has lower confidence
return $data;
}
// Try base language
$baseLang = substr($lang, 0, 2);
if (isset($langToCountry[$baseLang])) {
$data = $this->getCountryData($langToCountry[$baseLang]);
$data['confidence'] = $quality * 0.5; // Even lower confidence for base language
return $data;
}
}
return null;
}
private function getTimezoneFallback(array $context): ?array
{
$timezone = $context['timezone'] ?? $_COOKIE['timezone'] ?? null;
if (!$timezone) {
return null;
}
// Map timezone to country
$timezoneToCountry = [
'America/New_York' => 'US',
'America/Chicago' => 'US',
'America/Denver' => 'US',
'America/Los_Angeles' => 'US',
'America/Toronto' => 'CA',
'America/Vancouver' => 'CA',
'Europe/London' => 'GB',
'Europe/Berlin' => 'DE',
'Europe/Paris' => 'FR',
'Europe/Rome' => 'IT',
'Europe/Madrid' => 'ES',
'Asia/Tokyo' => 'JP',
'Asia/Seoul' => 'KR',
'Asia/Shanghai' => 'CN',
'Australia/Sydney' => 'AU',
'America/Sao_Paulo' => 'BR'
];
if (isset($timezoneToCountry[$timezone])) {
$data = $this->getCountryData($timezoneToCountry[$timezone]);
$data['confidence'] = 0.8; // Timezone-based has good confidence
return $data;
}
// Try to extract country from timezone
if (preg_match('/^([^\/]+)\//', $timezone, $matches)) {
$region = $matches[1];
$regionToCountry = [
'America' => 'US',
'Europe' => 'GB',
'Asia' => 'CN',
'Australia' => 'AU',
'Africa' => 'ZA'
];
if (isset($regionToCountry[$region])) {
$data = $this->getCountryData($regionToCountry[$region]);
$data['confidence'] = 0.4; // Lower confidence for region-based
return $data;
}
}
return null;
}
private function combineFallbackData(array $sources): array
{
if (empty($sources)) {
return $this->getCountryData('US'); // Ultimate fallback
}
// Find the source with highest confidence
$bestSource = null;
$bestConfidence = 0;
foreach ($sources as $sourceType => $data) {
$confidence = $data['confidence'] ?? $this->config['fallback_confidence'];
if ($confidence > $bestConfidence) {
$bestConfidence = $confidence;
$bestSource = $data;
$bestSource['fallback_source'] = $sourceType;
}
}
if ($bestSource) {
$bestSource['fallback'] = true;
$bestSource['confidence'] = $bestConfidence;
return $bestSource;
}
return $this->getCountryData('US');
}
private function getCountryData(string $countryCode): array
{
return $this->fallbackData[$countryCode] ?? $this->fallbackData['US'];
}
private function loadFallbackData(): void
{
$this->fallbackData = [
'US' => [
'country' => 'US',
'country_name' => 'United States',
'region' => 'California',
'city' => 'San Francisco',
'latitude' => 37.7749,
'longitude' => -122.4194,
'timezone' => 'America/Los_Angeles',
'currency' => 'USD',
'language' => 'en'
],
'CA' => [
'country' => 'CA',
'country_name' => 'Canada',
'region' => 'Ontario',
'city' => 'Toronto',
'latitude' => 43.6532,
'longitude' => -79.3832,
'timezone' => 'America/Toronto',
'currency' => 'CAD',
'language' => 'en'
],
'GB' => [
'country' => 'GB',
'country_name' => 'United Kingdom',
'region' => 'England',
'city' => 'London',
'latitude' => 51.5074,
'longitude' => -0.1278,
'timezone' => 'Europe/London',
'currency' => 'GBP',
'language' => 'en'
],
'DE' => [
'country' => 'DE',
'country_name' => 'Germany',
'region' => 'Bavaria',
'city' => 'Munich',
'latitude' => 48.1351,
'longitude' => 11.5820,
'timezone' => 'Europe/Berlin',
'currency' => 'EUR',
'language' => 'de'
],
'FR' => [
'country' => 'FR',
'country_name' => 'France',
'region' => 'Île-de-France',
'city' => 'Paris',
'latitude' => 48.8566,
'longitude' => 2.3522,
'timezone' => 'Europe/Paris',
'currency' => 'EUR',
'language' => 'fr'
]
];
}
}<?php
// tests/Unit/ErrorHandlingTest.php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use Rumenx\Geolocation\ExceptionHandler;
use Rumenx\Geolocation\Exceptions\ProviderException;
use Rumenx\Geolocation\Exceptions\NetworkException;
use Psr\Log\Test\TestLogger;
class ErrorHandlingTest extends TestCase
{
private ExceptionHandler $handler;
private TestLogger $logger;
protected function setUp(): void
{
$this->logger = new TestLogger();
$this->handler = new ExceptionHandler($this->logger, [
'graceful_degradation' => true,
'fallback_enabled' => true
]);
}
public function testProviderExceptionHandling(): void
{
$exception = new ProviderException('Provider unavailable', 1001, null, 'PROVIDER_UNAVAILABLE');
$result = $this->handler->handle($exception, [
'provider_chain' => ['cloudflare', 'maxmind', 'ipapi'],
'current_provider' => 'cloudflare'
]);
$this->assertIsArray($result);
$this->assertArrayHasKey('retry_with_provider', $result);
$this->assertEquals('maxmind', $result['retry_with_provider']);
}
public function testNetworkExceptionRetry(): void
{
$exception = new NetworkException('Connection timeout', 4001, null, 'NETWORK_TIMEOUT');
$result = $this->handler->handle($exception, [
'retry_count' => 0,
'max_retries' => 3
]);
$this->assertIsArray($result);
$this->assertArrayHasKey('retry', $result);
$this->assertTrue($result['retry']);
$this->assertEquals(1, $result['retry_count']);
}
public function testGracefulFallback(): void
{
$exception = new ProviderException('All providers failed', 1001);
$result = $this->handler->handle($exception);
$this->assertIsArray($result);
$this->assertArrayHasKey('country', $result);
$this->assertArrayHasKey('fallback', $result);
$this->assertTrue($result['fallback']);
}
public function testErrorRateTracking(): void
{
// Simulate multiple errors
for ($i = 0; $i < 5; $i++) {
$exception = new ProviderException("Error $i", 1001);
$this->handler->handle($exception);
}
$stats = $this->handler->getErrorStatistics();
$this->assertArrayHasKey(ProviderException::class, $stats);
$this->assertEquals(5, $stats[ProviderException::class]['count']);
}
public function testExceptionLogging(): void
{
$exception = new ProviderException('Test error', 1001, null, 'TEST_ERROR', ['test' => true]);
$this->handler->handle($exception);
$this->assertTrue($this->logger->hasWarningRecords());
$records = $this->logger->getRecords();
$this->assertStringContains('Test error', $records[0]['message']);
}
}
// tests/Integration/ErrorRecoveryTest.php
class ErrorRecoveryTest extends TestCase
{
public function testProviderFailover(): void
{
// Test complete provider failover scenario
$geo = new Geolocation([
'providers' => [
'failing_provider' => new FailingProvider(),
'working_provider' => new WorkingProvider()
],
'failover' => ['enabled' => true]
]);
$result = $geo->getCountryCode('8.8.8.8');
$this->assertNotNull($result);
$this->assertEquals('US', $result);
}
public function testNetworkRetryMechanism(): void
{
// Test network retry with eventual success
$provider = new TemporarilyFailingProvider(2); // Fail first 2 attempts
$geo = new Geolocation([
'providers' => ['test' => $provider],
'retry_attempts' => 3
]);
$result = $geo->getCountryCode('8.8.8.8');
$this->assertNotNull($result);
}
public function testCacheErrorRecovery(): void
{
// Test cache failure doesn't break functionality
$cache = new FailingCache();
$geo = new Geolocation([
'cache' => $cache,
'providers' => ['test' => new WorkingProvider()]
]);
$result = $geo->getCountryCode('8.8.8.8');
$this->assertNotNull($result);
}
}<?php
// src/ErrorHandlingBestPractices.php
/**
* Best Practices for Error Handling in php-geolocation
*/
class ErrorHandlingBestPractices
{
/**
* 1. Always use specific exception types
*/
public function goodExceptionUsage(): void
{
try {
// Some geolocation operation
} catch (ProviderAuthenticationException $e) {
// Handle authentication errors specifically
$this->logger->error('Provider authentication failed', ['provider' => $e->getContext()['provider']]);
$this->switchToAlternativeProvider();
} catch (ProviderRateLimitException $e) {
// Handle rate limiting specifically
$this->logger->warning('Rate limit hit', ['retry_after' => $e->getContext()['retry_after']]);
$this->scheduleRetry($e->getContext()['retry_after']);
} catch (NetworkException $e) {
// Handle network errors
$this->logger->warning('Network error', ['message' => $e->getMessage()]);
$this->attemptRetry();
}
}
/**
* 2. Implement proper fallback chains
*/
public function properFallbackChain(): array
{
try {
return $this->primaryProvider->getLocation($ip);
} catch (ProviderException $e) {
try {
return $this->secondaryProvider->getLocation($ip);
} catch (ProviderException $e2) {
try {
return $this->fallbackManager->getFallbackData($ip);
} catch (\Exception $e3) {
// Last resort - return safe defaults
return $this->getSafeDefaults();
}
}
}
}
/**
* 3. Always include context in exceptions
*/
public function contextualExceptions(string $ip): void
{
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
throw new InvalidIPException(
"Invalid IP address format: {$ip}",
6001,
null,
'INVALID_IP_FORMAT',
[
'ip' => $ip,
'validation_filters' => [FILTER_VALIDATE_IP],
'timestamp' => time(),
'source' => 'user_input'
]
);
}
}
/**
* 4. Implement circuit breakers for external services
*/
public function circuitBreakerPattern(string $provider): bool
{
$failures = $this->cache->get("failures_{$provider}", 0);
$lastFailure = $this->cache->get("last_failure_{$provider}", 0);
// Circuit open (too many failures)
if ($failures >= 5 && (time() - $lastFailure) < 300) {
throw new ProviderUnavailableException(
"Circuit breaker open for provider: {$provider}",
1004,
null,
'CIRCUIT_BREAKER_OPEN',
[
'provider' => $provider,
'failures' => $failures,
'last_failure' => $lastFailure,
'retry_after' => 300 - (time() - $lastFailure)
]
);
}
return true;
}
/**
* 5. Log errors with appropriate levels
*/
public function properErrorLogging(\Throwable $e): void
{
if ($e instanceof ProviderAuthenticationException) {
// Configuration issue - high priority
$this->logger->error('Provider authentication failed', [
'provider' => $e->getContext()['provider'] ?? 'unknown',
'action_required' => 'Check API credentials'
]);
} elseif ($e instanceof ProviderRateLimitException) {
// Expected during high traffic - medium priority
$this->logger->warning('Rate limit exceeded', [
'provider' => $e->getContext()['provider'] ?? 'unknown',
'retry_after' => $e->getContext()['retry_after'] ?? 60
]);
} elseif ($e instanceof NetworkException) {
// Transient issue - low priority
$this->logger->info('Network error occurred', [
'message' => $e->getMessage(),
'retryable' => true
]);
}
}
/**
* 6. Always validate and sanitize data
*/
public function validateAndSanitize(array $data): array
{
$validated = [];
// Validate country code
if (isset($data['country'])) {
if (preg_match('/^[A-Z]{2}$/', $data['country'])) {
$validated['country'] = $data['country'];
} else {
throw new InvalidDataException(
'Invalid country code format',
5001,
null,
'INVALID_COUNTRY_CODE',
['received' => $data['country'], 'expected_format' => 'ISO 3166-1 alpha-2']
);
}
}
// Validate coordinates
if (isset($data['latitude']) && isset($data['longitude'])) {
$lat = filter_var($data['latitude'], FILTER_VALIDATE_FLOAT);
$lng = filter_var($data['longitude'], FILTER_VALIDATE_FLOAT);
if ($lat !== false && $lng !== false && $lat >= -90 && $lat <= 90 && $lng >= -180 && $lng <= 180) {
$validated['latitude'] = $lat;
$validated['longitude'] = $lng;
} else {
throw new InvalidDataException(
'Invalid coordinates',
5002,
null,
'INVALID_COORDINATES',
['latitude' => $data['latitude'], 'longitude' => $data['longitude']]
);
}
}
return $validated;
}
/**
* 7. Implement proper timeout handling
*/
public function timeoutHandling(): void
{
$timeout = 5; // seconds
$start = microtime(true);
try {
// Set timeout for the operation
set_time_limit($timeout);
// Perform operation
$result = $this->performLongOperation();
} catch (\Throwable $e) {
$elapsed = microtime(true) - $start;
if ($elapsed >= $timeout) {
throw new TimeoutException(
"Operation timed out after {$timeout} seconds",
4002,
$e,
'OPERATION_TIMEOUT',
['timeout' => $timeout, 'elapsed' => $elapsed]
);
}
throw $e;
} finally {
set_time_limit(0); // Reset timeout
}
}
/**
* 8. Monitor and alert on error patterns
*/
public function errorPatternMonitoring(): void
{
$errorCounts = $this->getRecentErrorCounts();
// Check for error spikes
if ($errorCounts['provider_errors'] > 100) {
$this->sendAlert('High provider error rate', [
'count' => $errorCounts['provider_errors'],
'time_window' => '5 minutes'
]);
}
// Check for new error types
$newErrors = array_diff($errorCounts['error_types'], $this->getKnownErrorTypes());
if (!empty($newErrors)) {
$this->sendAlert('New error types detected', [
'new_errors' => $newErrors
]);
}
}
// Helper methods
private function switchToAlternativeProvider(): void {}
private function scheduleRetry(int $delay): void {}
private function attemptRetry(): void {}
private function getSafeDefaults(): array { return []; }
private function performLongOperation(): mixed { return null; }
private function getRecentErrorCounts(): array { return []; }
private function getKnownErrorTypes(): array { return []; }
private function sendAlert(string $message, array $context): void {}
}Previous: Configuration Reference | Next: Return to HOME
This comprehensive error handling guide covers:
- Exception Hierarchy: Structured exception classes for different error types
- Error Classification: Severity levels and categorization system
- Graceful Degradation: Fallback strategies and recovery mechanisms
- Monitoring: Error tracking and alerting systems
- Testing: Error scenario testing and validation
- Best Practices: Guidelines for robust error handling
The error handling system ensures your application remains stable and provides meaningful feedback even when geolocation services encounter issues.