- Added GitHub spec-kit for development workflow - Standardized file signatures to Descomplicar® format - Updated development configuration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
649 lines
20 KiB
PHP
649 lines
20 KiB
PHP
/**
|
|
* Descomplicar® Crescimento Digital
|
|
* https://descomplicar.pt
|
|
*/
|
|
|
|
<?php
|
|
|
|
defined('BASEPATH') or exit('No direct script access allowed');
|
|
|
|
/**
|
|
* Retry Handler
|
|
* Advanced retry logic with exponential backoff, jitter, and circuit breaker pattern
|
|
*
|
|
* @package DeskMoloni
|
|
* @subpackage Libraries
|
|
* @category RetryLogic
|
|
* @author Descomplicar® - PHP Fullstack Engineer
|
|
* @version 1.0.0
|
|
*/
|
|
|
|
namespace DeskMoloni\Libraries;
|
|
|
|
use DeskMoloni\Libraries\ErrorHandler;
|
|
|
|
class RetryHandler
|
|
{
|
|
protected $CI;
|
|
protected $model;
|
|
protected $error_handler;
|
|
|
|
// Retry configuration
|
|
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
const DEFAULT_BASE_DELAY = 1; // seconds
|
|
const DEFAULT_MAX_DELAY = 300; // 5 minutes
|
|
const DEFAULT_BACKOFF_MULTIPLIER = 2;
|
|
const DEFAULT_JITTER_ENABLED = true;
|
|
|
|
// Circuit breaker configuration
|
|
const CIRCUIT_BREAKER_FAILURE_THRESHOLD = 10;
|
|
const CIRCUIT_BREAKER_TIMEOUT = 300; // 5 minutes
|
|
const CIRCUIT_BREAKER_SUCCESS_THRESHOLD = 3;
|
|
|
|
// Retry strategies
|
|
const STRATEGY_EXPONENTIAL = 'exponential';
|
|
const STRATEGY_LINEAR = 'linear';
|
|
const STRATEGY_FIXED = 'fixed';
|
|
const STRATEGY_FIBONACCI = 'fibonacci';
|
|
|
|
// Circuit breaker states
|
|
const CIRCUIT_CLOSED = 'closed';
|
|
const CIRCUIT_OPEN = 'open';
|
|
const CIRCUIT_HALF_OPEN = 'half_open';
|
|
|
|
// Retryable error types
|
|
protected $retryable_errors = [
|
|
'connection_timeout',
|
|
'read_timeout',
|
|
'network_error',
|
|
'server_error',
|
|
'rate_limit',
|
|
'temporary_unavailable',
|
|
'circuit_breaker_open'
|
|
];
|
|
|
|
// Non-retryable error types
|
|
protected $non_retryable_errors = [
|
|
'authentication_failed',
|
|
'authorization_denied',
|
|
'invalid_data',
|
|
'resource_not_found',
|
|
'bad_request',
|
|
'conflict'
|
|
];
|
|
|
|
public function __construct()
|
|
{
|
|
$this->CI = &get_instance();
|
|
$this->CI->load->model('desk_moloni_model');
|
|
$this->model = $this->CI->desk_moloni_model;
|
|
$this->error_handler = new ErrorHandler();
|
|
|
|
log_activity('RetryHandler initialized');
|
|
}
|
|
|
|
/**
|
|
* Calculate retry delay with exponential backoff
|
|
*
|
|
* @param int $attempt_number
|
|
* @param string $strategy
|
|
* @param array $options
|
|
* @return int Delay in seconds
|
|
*/
|
|
public function calculate_retry_delay($attempt_number, $strategy = self::STRATEGY_EXPONENTIAL, $options = [])
|
|
{
|
|
$base_delay = $options['base_delay'] ?? self::DEFAULT_BASE_DELAY;
|
|
$max_delay = $options['max_delay'] ?? self::DEFAULT_MAX_DELAY;
|
|
$multiplier = $options['multiplier'] ?? self::DEFAULT_BACKOFF_MULTIPLIER;
|
|
$jitter_enabled = $options['jitter'] ?? self::DEFAULT_JITTER_ENABLED;
|
|
|
|
switch ($strategy) {
|
|
case self::STRATEGY_EXPONENTIAL:
|
|
$delay = $base_delay * pow($multiplier, $attempt_number - 1);
|
|
break;
|
|
|
|
case self::STRATEGY_LINEAR:
|
|
$delay = $base_delay * $attempt_number;
|
|
break;
|
|
|
|
case self::STRATEGY_FIXED:
|
|
$delay = $base_delay;
|
|
break;
|
|
|
|
case self::STRATEGY_FIBONACCI:
|
|
$delay = $this->fibonacci_delay($attempt_number, $base_delay);
|
|
break;
|
|
|
|
default:
|
|
$delay = $base_delay * pow($multiplier, $attempt_number - 1);
|
|
}
|
|
|
|
// Cap at maximum delay
|
|
$delay = min($delay, $max_delay);
|
|
|
|
// Add jitter to prevent thundering herd
|
|
if ($jitter_enabled) {
|
|
$delay = $this->add_jitter($delay);
|
|
}
|
|
|
|
return (int)$delay;
|
|
}
|
|
|
|
/**
|
|
* Determine if an error is retryable
|
|
*
|
|
* @param string $error_type
|
|
* @param string $error_message
|
|
* @param int $http_status_code
|
|
* @return bool
|
|
*/
|
|
public function is_retryable_error($error_type, $error_message = '', $http_status_code = null)
|
|
{
|
|
// Check explicit non-retryable errors first
|
|
if (in_array($error_type, $this->non_retryable_errors)) {
|
|
return false;
|
|
}
|
|
|
|
// Check explicit retryable errors
|
|
if (in_array($error_type, $this->retryable_errors)) {
|
|
return true;
|
|
}
|
|
|
|
// Check HTTP status codes
|
|
if ($http_status_code !== null) {
|
|
return $this->is_retryable_http_status($http_status_code);
|
|
}
|
|
|
|
// Check error message patterns
|
|
return $this->is_retryable_error_message($error_message);
|
|
}
|
|
|
|
/**
|
|
* Execute operation with retry logic
|
|
*
|
|
* @param callable $operation
|
|
* @param array $retry_config
|
|
* @param array $context
|
|
* @return array
|
|
*/
|
|
public function execute_with_retry(callable $operation, $retry_config = [], $context = [])
|
|
{
|
|
$max_attempts = $retry_config['max_attempts'] ?? self::DEFAULT_MAX_ATTEMPTS;
|
|
$strategy = $retry_config['strategy'] ?? self::STRATEGY_EXPONENTIAL;
|
|
$circuit_breaker_key = $context['circuit_breaker_key'] ?? null;
|
|
|
|
// Check circuit breaker if enabled
|
|
if ($circuit_breaker_key && $this->is_circuit_breaker_open($circuit_breaker_key)) {
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Circuit breaker is open',
|
|
'error_type' => 'circuit_breaker_open',
|
|
'attempts' => 0
|
|
];
|
|
}
|
|
|
|
$last_error = null;
|
|
|
|
for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
|
|
try {
|
|
// Record attempt
|
|
$this->record_retry_attempt($context, $attempt);
|
|
|
|
// Execute operation
|
|
$result = $operation($attempt);
|
|
|
|
// Success - record and reset circuit breaker
|
|
if ($result['success']) {
|
|
$this->record_retry_success($context, $attempt);
|
|
|
|
if ($circuit_breaker_key) {
|
|
$this->record_circuit_breaker_success($circuit_breaker_key);
|
|
}
|
|
|
|
return array_merge($result, ['attempts' => $attempt]);
|
|
}
|
|
|
|
$last_error = $result;
|
|
|
|
// Check if error is retryable
|
|
if (!$this->is_retryable_error(
|
|
$result['error_type'] ?? 'unknown',
|
|
$result['message'] ?? '',
|
|
$result['http_status'] ?? null
|
|
)) {
|
|
break;
|
|
}
|
|
|
|
// Don't delay after last attempt
|
|
if ($attempt < $max_attempts) {
|
|
$delay = $this->calculate_retry_delay($attempt, $strategy, $retry_config);
|
|
$this->record_retry_delay($context, $attempt, $delay);
|
|
sleep($delay);
|
|
}
|
|
|
|
} catch (\Exception $e) {
|
|
$last_error = [
|
|
'success' => false,
|
|
'message' => $e->getMessage(),
|
|
'error_type' => 'exception',
|
|
'exception' => $e
|
|
];
|
|
|
|
// Record exception attempt
|
|
$this->record_retry_exception($context, $attempt, $e);
|
|
|
|
// Check if exception is retryable
|
|
if (!$this->is_retryable_exception($e)) {
|
|
break;
|
|
}
|
|
|
|
if ($attempt < $max_attempts) {
|
|
$delay = $this->calculate_retry_delay($attempt, $strategy, $retry_config);
|
|
sleep($delay);
|
|
}
|
|
}
|
|
}
|
|
|
|
// All retries failed
|
|
$this->record_retry_failure($context, $max_attempts, $last_error);
|
|
|
|
// Update circuit breaker on failure
|
|
if ($circuit_breaker_key) {
|
|
$this->record_circuit_breaker_failure($circuit_breaker_key);
|
|
}
|
|
|
|
return array_merge($last_error, ['attempts' => $max_attempts]);
|
|
}
|
|
|
|
/**
|
|
* Get retry statistics for monitoring
|
|
*
|
|
* @param array $filters
|
|
* @return array
|
|
*/
|
|
public function get_retry_statistics($filters = [])
|
|
{
|
|
return [
|
|
'total_attempts' => $this->model->count_retry_attempts($filters),
|
|
'total_successes' => $this->model->count_retry_successes($filters),
|
|
'total_failures' => $this->model->count_retry_failures($filters),
|
|
'success_rate' => $this->calculate_retry_success_rate($filters),
|
|
'average_attempts' => $this->model->get_average_retry_attempts($filters),
|
|
'retry_distribution' => $this->model->get_retry_attempt_distribution($filters),
|
|
'error_types' => $this->model->get_retry_error_types($filters),
|
|
'circuit_breaker_states' => $this->get_circuit_breaker_states()
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check circuit breaker state
|
|
*
|
|
* @param string $circuit_key
|
|
* @return bool
|
|
*/
|
|
public function is_circuit_breaker_open($circuit_key)
|
|
{
|
|
$circuit_state = $this->get_circuit_breaker_state($circuit_key);
|
|
|
|
switch ($circuit_state['state']) {
|
|
case self::CIRCUIT_OPEN:
|
|
// Check if timeout has passed
|
|
if (time() - $circuit_state['opened_at'] >= self::CIRCUIT_BREAKER_TIMEOUT) {
|
|
$this->set_circuit_breaker_state($circuit_key, self::CIRCUIT_HALF_OPEN);
|
|
return false;
|
|
}
|
|
return true;
|
|
|
|
case self::CIRCUIT_HALF_OPEN:
|
|
return false;
|
|
|
|
case self::CIRCUIT_CLOSED:
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record circuit breaker failure
|
|
*
|
|
* @param string $circuit_key
|
|
*/
|
|
public function record_circuit_breaker_failure($circuit_key)
|
|
{
|
|
$circuit_state = $this->get_circuit_breaker_state($circuit_key);
|
|
$failure_count = $circuit_state['failure_count'] + 1;
|
|
|
|
if ($failure_count >= self::CIRCUIT_BREAKER_FAILURE_THRESHOLD) {
|
|
$this->set_circuit_breaker_state($circuit_key, self::CIRCUIT_OPEN, [
|
|
'failure_count' => $failure_count,
|
|
'opened_at' => time()
|
|
]);
|
|
|
|
$this->error_handler->log_error(
|
|
ErrorHandler::CATEGORY_SYSTEM,
|
|
'CIRCUIT_BREAKER_OPENED',
|
|
"Circuit breaker opened for {$circuit_key} after {$failure_count} failures",
|
|
['circuit_key' => $circuit_key],
|
|
ErrorHandler::SEVERITY_HIGH
|
|
);
|
|
} else {
|
|
$this->update_circuit_breaker_failure_count($circuit_key, $failure_count);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record circuit breaker success
|
|
*
|
|
* @param string $circuit_key
|
|
*/
|
|
public function record_circuit_breaker_success($circuit_key)
|
|
{
|
|
$circuit_state = $this->get_circuit_breaker_state($circuit_key);
|
|
|
|
if ($circuit_state['state'] === self::CIRCUIT_HALF_OPEN) {
|
|
$success_count = $circuit_state['success_count'] + 1;
|
|
|
|
if ($success_count >= self::CIRCUIT_BREAKER_SUCCESS_THRESHOLD) {
|
|
$this->set_circuit_breaker_state($circuit_key, self::CIRCUIT_CLOSED, [
|
|
'success_count' => 0,
|
|
'failure_count' => 0
|
|
]);
|
|
|
|
log_activity("Circuit breaker closed for {$circuit_key} after successful operations");
|
|
} else {
|
|
$this->update_circuit_breaker_success_count($circuit_key, $success_count);
|
|
}
|
|
} else {
|
|
// Reset failure count on success
|
|
$this->update_circuit_breaker_failure_count($circuit_key, 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get optimal retry configuration for operation type
|
|
*
|
|
* @param string $operation_type
|
|
* @param string $entity_type
|
|
* @return array
|
|
*/
|
|
public function get_optimal_retry_config($operation_type, $entity_type = null)
|
|
{
|
|
$base_config = [
|
|
'max_attempts' => self::DEFAULT_MAX_ATTEMPTS,
|
|
'strategy' => self::STRATEGY_EXPONENTIAL,
|
|
'base_delay' => self::DEFAULT_BASE_DELAY,
|
|
'max_delay' => self::DEFAULT_MAX_DELAY,
|
|
'multiplier' => self::DEFAULT_BACKOFF_MULTIPLIER,
|
|
'jitter' => self::DEFAULT_JITTER_ENABLED
|
|
];
|
|
|
|
// Customize based on operation type
|
|
switch ($operation_type) {
|
|
case 'api_call':
|
|
$base_config['max_attempts'] = 3;
|
|
$base_config['base_delay'] = 2;
|
|
$base_config['max_delay'] = 60;
|
|
break;
|
|
|
|
case 'database_operation':
|
|
$base_config['max_attempts'] = 2;
|
|
$base_config['strategy'] = self::STRATEGY_FIXED;
|
|
$base_config['base_delay'] = 1;
|
|
break;
|
|
|
|
case 'file_operation':
|
|
$base_config['max_attempts'] = 3;
|
|
$base_config['strategy'] = self::STRATEGY_LINEAR;
|
|
$base_config['base_delay'] = 1;
|
|
break;
|
|
|
|
case 'sync_operation':
|
|
$base_config['max_attempts'] = 5;
|
|
$base_config['base_delay'] = 5;
|
|
$base_config['max_delay'] = 300;
|
|
break;
|
|
}
|
|
|
|
// Further customize based on entity type
|
|
if ($entity_type) {
|
|
switch ($entity_type) {
|
|
case 'customer':
|
|
$base_config['max_attempts'] = min($base_config['max_attempts'], 3);
|
|
break;
|
|
|
|
case 'invoice':
|
|
$base_config['max_attempts'] = 5; // More important
|
|
$base_config['max_delay'] = 600;
|
|
break;
|
|
|
|
case 'product':
|
|
$base_config['max_attempts'] = 3;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $base_config;
|
|
}
|
|
|
|
/**
|
|
* Add jitter to delay to prevent thundering herd
|
|
*
|
|
* @param float $delay
|
|
* @param float $jitter_factor
|
|
* @return float
|
|
*/
|
|
protected function add_jitter($delay, $jitter_factor = 0.1)
|
|
{
|
|
$jitter_range = $delay * $jitter_factor;
|
|
$jitter = (mt_rand() / mt_getrandmax()) * $jitter_range * 2 - $jitter_range;
|
|
|
|
return max(0, $delay + $jitter);
|
|
}
|
|
|
|
/**
|
|
* Calculate fibonacci delay
|
|
*
|
|
* @param int $n
|
|
* @param float $base_delay
|
|
* @return float
|
|
*/
|
|
protected function fibonacci_delay($n, $base_delay)
|
|
{
|
|
if ($n <= 1) return $base_delay;
|
|
if ($n == 2) return $base_delay;
|
|
|
|
$a = $base_delay;
|
|
$b = $base_delay;
|
|
|
|
for ($i = 3; $i <= $n; $i++) {
|
|
$temp = $a + $b;
|
|
$a = $b;
|
|
$b = $temp;
|
|
}
|
|
|
|
return $b;
|
|
}
|
|
|
|
/**
|
|
* Check if HTTP status code is retryable
|
|
*
|
|
* @param int $status_code
|
|
* @return bool
|
|
*/
|
|
protected function is_retryable_http_status($status_code)
|
|
{
|
|
// 5xx server errors are generally retryable
|
|
if ($status_code >= 500) {
|
|
return true;
|
|
}
|
|
|
|
// Some 4xx errors are retryable
|
|
$retryable_4xx = [408, 429, 423, 424]; // Request timeout, rate limit, locked, failed dependency
|
|
|
|
return in_array($status_code, $retryable_4xx);
|
|
}
|
|
|
|
/**
|
|
* Check if error message indicates retryable error
|
|
*
|
|
* @param string $error_message
|
|
* @return bool
|
|
*/
|
|
protected function is_retryable_error_message($error_message)
|
|
{
|
|
$retryable_patterns = [
|
|
'/timeout/i',
|
|
'/connection.*failed/i',
|
|
'/network.*error/i',
|
|
'/temporary.*unavailable/i',
|
|
'/service.*unavailable/i',
|
|
'/rate.*limit/i',
|
|
'/too many requests/i',
|
|
'/server.*error/i'
|
|
];
|
|
|
|
foreach ($retryable_patterns as $pattern) {
|
|
if (preg_match($pattern, $error_message)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if exception is retryable
|
|
*
|
|
* @param \Exception $exception
|
|
* @return bool
|
|
*/
|
|
protected function is_retryable_exception($exception)
|
|
{
|
|
$retryable_exceptions = [
|
|
'PDOException',
|
|
'mysqli_sql_exception',
|
|
'RedisException',
|
|
'cURLException',
|
|
'TimeoutException'
|
|
];
|
|
|
|
$exception_class = get_class($exception);
|
|
|
|
return in_array($exception_class, $retryable_exceptions) ||
|
|
$this->is_retryable_error_message($exception->getMessage());
|
|
}
|
|
|
|
/**
|
|
* Record retry attempt
|
|
*
|
|
* @param array $context
|
|
* @param int $attempt
|
|
*/
|
|
protected function record_retry_attempt($context, $attempt)
|
|
{
|
|
$this->model->record_retry_attempt([
|
|
'operation_type' => $context['operation_type'] ?? 'unknown',
|
|
'entity_type' => $context['entity_type'] ?? null,
|
|
'entity_id' => $context['entity_id'] ?? null,
|
|
'attempt_number' => $attempt,
|
|
'attempted_at' => date('Y-m-d H:i:s'),
|
|
'context' => json_encode($context)
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Record retry success
|
|
*
|
|
* @param array $context
|
|
* @param int $total_attempts
|
|
*/
|
|
protected function record_retry_success($context, $total_attempts)
|
|
{
|
|
$this->model->record_retry_success([
|
|
'operation_type' => $context['operation_type'] ?? 'unknown',
|
|
'entity_type' => $context['entity_type'] ?? null,
|
|
'entity_id' => $context['entity_id'] ?? null,
|
|
'total_attempts' => $total_attempts,
|
|
'succeeded_at' => date('Y-m-d H:i:s'),
|
|
'context' => json_encode($context)
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Record retry failure
|
|
*
|
|
* @param array $context
|
|
* @param int $total_attempts
|
|
* @param array $last_error
|
|
*/
|
|
protected function record_retry_failure($context, $total_attempts, $last_error)
|
|
{
|
|
$this->model->record_retry_failure([
|
|
'operation_type' => $context['operation_type'] ?? 'unknown',
|
|
'entity_type' => $context['entity_type'] ?? null,
|
|
'entity_id' => $context['entity_id'] ?? null,
|
|
'total_attempts' => $total_attempts,
|
|
'failed_at' => date('Y-m-d H:i:s'),
|
|
'last_error' => json_encode($last_error),
|
|
'context' => json_encode($context)
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get circuit breaker state
|
|
*
|
|
* @param string $circuit_key
|
|
* @return array
|
|
*/
|
|
protected function get_circuit_breaker_state($circuit_key)
|
|
{
|
|
return $this->model->get_circuit_breaker_state($circuit_key) ?: [
|
|
'state' => self::CIRCUIT_CLOSED,
|
|
'failure_count' => 0,
|
|
'success_count' => 0,
|
|
'opened_at' => null
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Set circuit breaker state
|
|
*
|
|
* @param string $circuit_key
|
|
* @param string $state
|
|
* @param array $additional_data
|
|
*/
|
|
protected function set_circuit_breaker_state($circuit_key, $state, $additional_data = [])
|
|
{
|
|
$data = array_merge([
|
|
'circuit_key' => $circuit_key,
|
|
'state' => $state,
|
|
'updated_at' => date('Y-m-d H:i:s')
|
|
], $additional_data);
|
|
|
|
$this->model->set_circuit_breaker_state($circuit_key, $data);
|
|
}
|
|
|
|
/**
|
|
* Calculate retry success rate
|
|
*
|
|
* @param array $filters
|
|
* @return float
|
|
*/
|
|
protected function calculate_retry_success_rate($filters)
|
|
{
|
|
$total_attempts = $this->model->count_retry_attempts($filters);
|
|
$total_successes = $this->model->count_retry_successes($filters);
|
|
|
|
return $total_attempts > 0 ? ($total_successes / $total_attempts) * 100 : 0;
|
|
}
|
|
|
|
/**
|
|
* Get all circuit breaker states
|
|
*
|
|
* @return array
|
|
*/
|
|
protected function get_circuit_breaker_states()
|
|
{
|
|
return $this->model->get_all_circuit_breaker_states();
|
|
}
|
|
} |