Skip to content

Error Handling

Rumen Damyanov edited this page Jul 31, 2025 · 2 revisions

Error Handling

Comprehensive error handling patterns, exception management, and debugging strategies for php-geolocation.

Table of Contents

Exception Hierarchy

Core Exception Classes

<?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 {}

Specialized Exception Classes

<?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)
            ]
        );
    }
}

Error Types

Error Classification System

<?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'];
    }
}

Exception Handling

Centralized Exception Handler

<?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;
    }
}

Graceful Degradation

Fallback Strategy Implementation

<?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'
            ]
        ];
    }
}

Testing Error Scenarios

Error Testing Suite

<?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);
    }
}

Best Practices

Error Handling Guidelines

<?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


Summary

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.

Clone this wiki locally