mirror of
https://github.com/ivuorinen/monolog-gdpr-filter.git
synced 2026-02-07 13:47:45 +00:00
feat: add advanced architecture, documentation, and coverage improvements (#65)
* fix(style): resolve PHPCS line-length warnings in source files * fix(style): resolve PHPCS line-length warnings in test files * feat(audit): add structured audit logging with ErrorContext and AuditContext - ErrorContext: standardized error information with sensitive data sanitization - AuditContext: structured context for audit entries with operation types - StructuredAuditLogger: enhanced audit logger wrapper with timing support * feat(recovery): add recovery mechanism for failed masking operations - FailureMode enum: FAIL_OPEN, FAIL_CLOSED, FAIL_SAFE modes - RecoveryStrategy interface and RecoveryResult value object - RetryStrategy: exponential backoff with configurable attempts - FallbackMaskStrategy: type-aware fallback values * feat(strategies): add CallbackMaskingStrategy for custom masking logic - Wraps custom callbacks as MaskingStrategy implementations - Factory methods: constant(), hash(), partial() for common use cases - Supports exact match and prefix match for field paths * docs: add framework integration guides and examples - symfony-integration.md: Symfony service configuration and Monolog setup - psr3-decorator.md: PSR-3 logger decorator pattern implementation - framework-examples.md: CakePHP, CodeIgniter 4, Laminas, Yii2, PSR-15 - docker-development.md: Docker development environment guide * chore(docker): add Docker development environment - Dockerfile: PHP 8.2-cli-alpine with Xdebug for coverage - docker-compose.yml: development services with volume mounts * feat(demo): add interactive GDPR pattern tester playground - PatternTester.php: pattern testing utility with strategy support - index.php: web API endpoint with JSON response handling - playground.html: interactive web interface for testing patterns * docs(todo): update with completed medium priority items - Mark all PHPCS warnings as fixed (81 → 0) - Document new Audit and Recovery features - Update test count to 1,068 tests with 2,953 assertions - Move remaining items to low priority * feat: add advanced architecture, documentation, and coverage improvements - Add architecture improvements: - ArrayAccessorInterface and DotArrayAccessor for decoupled array access - MaskingOrchestrator for single-responsibility masking coordination - GdprProcessorBuilder for fluent configuration - MaskingPluginInterface and AbstractMaskingPlugin for plugin architecture - PluginAwareProcessor for plugin hook execution - AuditLoggerFactory for instance-based audit logger creation - Add advanced features: - SerializedDataProcessor for handling print_r/var_export/serialize output - KAnonymizer with GeneralizationStrategy for GDPR k-anonymity - RetentionPolicy for configurable data retention periods - StreamingProcessor for memory-efficient large log processing - Add comprehensive documentation: - docs/performance-tuning.md - benchmarking, optimization, caching - docs/troubleshooting.md - common issues and solutions - docs/logging-integrations.md - ELK, Graylog, Datadog, etc. - docs/plugin-development.md - complete plugin development guide - Improve test coverage (84.41% → 85.07%): - ConditionalRuleFactoryInstanceTest (100% coverage) - GdprProcessorBuilderEdgeCasesTest (100% coverage) - StrategyEdgeCasesTest for ReDoS detection and type parsing - 78 new tests, 119 new assertions - Update TODO.md with current statistics: - 141 PHP files, 1,346 tests, 85.07% line coverage * chore: tests, update actions, sonarcloud issues * chore: rector * fix: more sonarcloud fixes * chore: more fixes * refactor: copilot review fix * chore: rector
This commit is contained in:
57
src/Recovery/FailureMode.php
Normal file
57
src/Recovery/FailureMode.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Recovery;
|
||||
|
||||
/**
|
||||
* Defines how the processor should behave when masking operations fail.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
enum FailureMode: string
|
||||
{
|
||||
/**
|
||||
* Fail open: On failure, return the original value unmasked.
|
||||
*
|
||||
* Use this when availability is more important than privacy,
|
||||
* but be aware this may expose sensitive data in error scenarios.
|
||||
*/
|
||||
case FAIL_OPEN = 'fail_open';
|
||||
|
||||
/**
|
||||
* Fail closed: On failure, return a completely masked/redacted value.
|
||||
*
|
||||
* Use this when privacy is critical and you'd rather lose data
|
||||
* than risk exposing sensitive information.
|
||||
*/
|
||||
case FAIL_CLOSED = 'fail_closed';
|
||||
|
||||
/**
|
||||
* Fail safe: On failure, apply a conservative fallback mask.
|
||||
*
|
||||
* This is the recommended default. It attempts to provide useful
|
||||
* information while still protecting potentially sensitive data.
|
||||
*/
|
||||
case FAIL_SAFE = 'fail_safe';
|
||||
|
||||
/**
|
||||
* Get a human-readable description of this failure mode.
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FAIL_OPEN => 'Return original value on failure (risky)',
|
||||
self::FAIL_CLOSED => 'Return fully redacted value on failure (strict)',
|
||||
self::FAIL_SAFE => 'Apply conservative fallback mask on failure (balanced)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recommended failure mode for production environments.
|
||||
*/
|
||||
public static function recommended(): self
|
||||
{
|
||||
return self::FAIL_SAFE;
|
||||
}
|
||||
}
|
||||
178
src/Recovery/FallbackMaskStrategy.php
Normal file
178
src/Recovery/FallbackMaskStrategy.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
|
||||
/**
|
||||
* Provides fallback mask values for different data types and scenarios.
|
||||
*
|
||||
* Used by recovery strategies to determine appropriate masked values
|
||||
* when masking operations fail.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class FallbackMaskStrategy
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $customFallbacks Custom fallback values by type
|
||||
* @param string $defaultFallback Default fallback for unknown types
|
||||
* @param bool $preserveType Whether to try preserving the original type
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $customFallbacks = [],
|
||||
private readonly string $defaultFallback = MaskConstants::MASK_MASKED,
|
||||
private readonly bool $preserveType = true,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy with default fallback values.
|
||||
*/
|
||||
public static function default(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strict strategy that always uses the same mask.
|
||||
*/
|
||||
public static function strict(string $mask = MaskConstants::MASK_REDACTED): self
|
||||
{
|
||||
return new self(
|
||||
defaultFallback: $mask,
|
||||
preserveType: false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy with custom type mappings.
|
||||
*
|
||||
* @param array<string, string> $typeMappings Type name => fallback value
|
||||
*/
|
||||
public static function withMappings(array $typeMappings): self
|
||||
{
|
||||
return new self(customFallbacks: $typeMappings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate fallback value for a given original value.
|
||||
*
|
||||
* @param mixed $originalValue The original value that couldn't be masked
|
||||
* @param FailureMode $mode The failure mode to apply
|
||||
*/
|
||||
public function getFallback(
|
||||
mixed $originalValue,
|
||||
FailureMode $mode = FailureMode::FAIL_SAFE
|
||||
): mixed {
|
||||
return match ($mode) {
|
||||
FailureMode::FAIL_OPEN => $originalValue,
|
||||
FailureMode::FAIL_CLOSED => $this->getClosedFallback(),
|
||||
FailureMode::FAIL_SAFE => $this->getSafeFallback($originalValue),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback for FAIL_CLOSED mode.
|
||||
*/
|
||||
private function getClosedFallback(): string
|
||||
{
|
||||
return $this->customFallbacks['closed'] ?? MaskConstants::MASK_REDACTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback for FAIL_SAFE mode (type-aware).
|
||||
*/
|
||||
private function getSafeFallback(mixed $originalValue): mixed
|
||||
{
|
||||
$type = gettype($originalValue);
|
||||
|
||||
// Check for custom fallback first
|
||||
if (isset($this->customFallbacks[$type])) {
|
||||
return $this->customFallbacks[$type];
|
||||
}
|
||||
|
||||
// If not preserving type, return default
|
||||
if (!$this->preserveType) {
|
||||
return $this->defaultFallback;
|
||||
}
|
||||
|
||||
// Return type-appropriate fallback
|
||||
return match ($type) {
|
||||
'string' => $this->getStringFallback($originalValue),
|
||||
'integer' => MaskConstants::MASK_INT,
|
||||
'double' => MaskConstants::MASK_FLOAT,
|
||||
'boolean' => MaskConstants::MASK_BOOL,
|
||||
'array' => $this->getArrayFallback($originalValue),
|
||||
'object' => $this->getObjectFallback($originalValue),
|
||||
'NULL' => MaskConstants::MASK_NULL,
|
||||
'resource', 'resource (closed)' => MaskConstants::MASK_RESOURCE,
|
||||
default => $this->defaultFallback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback for string values.
|
||||
*
|
||||
* @param string $originalValue
|
||||
*/
|
||||
private function getStringFallback(string $originalValue): string
|
||||
{
|
||||
// Try to preserve length indication
|
||||
$length = strlen($originalValue);
|
||||
|
||||
if ($length <= 10) {
|
||||
return MaskConstants::MASK_STRING;
|
||||
}
|
||||
|
||||
return sprintf('%s (%d chars)', MaskConstants::MASK_STRING, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback for array values.
|
||||
*
|
||||
* @param array<mixed> $originalValue
|
||||
*/
|
||||
private function getArrayFallback(array $originalValue): string
|
||||
{
|
||||
$count = count($originalValue);
|
||||
|
||||
if ($count === 0) {
|
||||
return MaskConstants::MASK_ARRAY;
|
||||
}
|
||||
|
||||
return sprintf('%s (%d items)', MaskConstants::MASK_ARRAY, $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback for object values.
|
||||
*/
|
||||
private function getObjectFallback(object $originalValue): string
|
||||
{
|
||||
$class = $originalValue::class;
|
||||
|
||||
// Extract just the class name without namespace
|
||||
$lastBackslash = strrpos($class, '\\');
|
||||
$shortClass = $lastBackslash !== false
|
||||
? substr($class, $lastBackslash + 1)
|
||||
: $class;
|
||||
|
||||
return sprintf('%s (%s)', MaskConstants::MASK_OBJECT, $shortClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a description of this strategy's configuration.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'custom_fallbacks' => $this->customFallbacks,
|
||||
'default_fallback' => $this->defaultFallback,
|
||||
'preserve_type' => $this->preserveType,
|
||||
];
|
||||
}
|
||||
}
|
||||
202
src/Recovery/RecoveryResult.php
Normal file
202
src/Recovery/RecoveryResult.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Audit\AuditContext;
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
|
||||
/**
|
||||
* Result of a recovery operation.
|
||||
*
|
||||
* Encapsulates the outcome of attempting an operation with recovery,
|
||||
* including whether it succeeded, failed, or used a fallback.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final readonly class RecoveryResult
|
||||
{
|
||||
public const OUTCOME_SUCCESS = 'success';
|
||||
public const OUTCOME_RECOVERED = 'recovered';
|
||||
public const OUTCOME_FALLBACK = 'fallback';
|
||||
public const OUTCOME_FAILED = 'failed';
|
||||
|
||||
/**
|
||||
* @param mixed $value The resulting value (masked or fallback)
|
||||
* @param string $outcome The outcome type
|
||||
* @param int $attempts Number of attempts made
|
||||
* @param float $totalDurationMs Total time spent including retries
|
||||
* @param ErrorContext|null $lastError The last error if any occurred
|
||||
*/
|
||||
public function __construct(
|
||||
public mixed $value,
|
||||
public string $outcome,
|
||||
public int $attempts = 1,
|
||||
public float $totalDurationMs = 0.0,
|
||||
public ?ErrorContext $lastError = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success result (first attempt succeeded).
|
||||
*
|
||||
* @param mixed $value The masked value
|
||||
* @param float $durationMs Operation duration
|
||||
*/
|
||||
public static function success(mixed $value, float $durationMs = 0.0): self
|
||||
{
|
||||
return new self(
|
||||
value: $value,
|
||||
outcome: self::OUTCOME_SUCCESS,
|
||||
attempts: 1,
|
||||
totalDurationMs: $durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a recovered result (succeeded after retry).
|
||||
*
|
||||
* @param mixed $value The masked value
|
||||
* @param int $attempts Number of attempts needed
|
||||
* @param float $totalDurationMs Total duration including retries
|
||||
*/
|
||||
public static function recovered(
|
||||
mixed $value,
|
||||
int $attempts,
|
||||
float $totalDurationMs = 0.0
|
||||
): self {
|
||||
return new self(
|
||||
value: $value,
|
||||
outcome: self::OUTCOME_RECOVERED,
|
||||
attempts: $attempts,
|
||||
totalDurationMs: $totalDurationMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fallback result (used fallback value after failures).
|
||||
*
|
||||
* @param mixed $fallbackValue The fallback value used
|
||||
* @param int $attempts Number of attempts made before fallback
|
||||
* @param ErrorContext $lastError The error that triggered fallback
|
||||
* @param float $totalDurationMs Total duration including retries
|
||||
*/
|
||||
public static function fallback(
|
||||
mixed $fallbackValue,
|
||||
int $attempts,
|
||||
ErrorContext $lastError,
|
||||
float $totalDurationMs = 0.0
|
||||
): self {
|
||||
return new self(
|
||||
value: $fallbackValue,
|
||||
outcome: self::OUTCOME_FALLBACK,
|
||||
attempts: $attempts,
|
||||
totalDurationMs: $totalDurationMs,
|
||||
lastError: $lastError,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a failed result (all recovery attempts exhausted).
|
||||
*
|
||||
* @param mixed $originalValue The original value (returned as-is)
|
||||
* @param int $attempts Number of attempts made
|
||||
* @param ErrorContext $error The final error
|
||||
* @param float $totalDurationMs Total duration including retries
|
||||
*/
|
||||
public static function failed(
|
||||
mixed $originalValue,
|
||||
int $attempts,
|
||||
ErrorContext $error,
|
||||
float $totalDurationMs = 0.0
|
||||
): self {
|
||||
return new self(
|
||||
value: $originalValue,
|
||||
outcome: self::OUTCOME_FAILED,
|
||||
attempts: $attempts,
|
||||
totalDurationMs: $totalDurationMs,
|
||||
lastError: $error,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the operation was successful (including recovery).
|
||||
*/
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->outcome === self::OUTCOME_SUCCESS
|
||||
|| $this->outcome === self::OUTCOME_RECOVERED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a fallback was used.
|
||||
*/
|
||||
public function usedFallback(): bool
|
||||
{
|
||||
return $this->outcome === self::OUTCOME_FALLBACK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the operation completely failed.
|
||||
*/
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->outcome === self::OUTCOME_FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if retry was needed.
|
||||
*/
|
||||
public function neededRetry(): bool
|
||||
{
|
||||
return $this->attempts > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AuditContext from this result.
|
||||
*
|
||||
* @param string $operationType The type of operation performed
|
||||
*/
|
||||
public function toAuditContext(string $operationType): AuditContext
|
||||
{
|
||||
return match ($this->outcome) {
|
||||
self::OUTCOME_SUCCESS => AuditContext::success(
|
||||
$operationType,
|
||||
$this->totalDurationMs
|
||||
),
|
||||
self::OUTCOME_RECOVERED => AuditContext::recovered(
|
||||
$operationType,
|
||||
$this->attempts,
|
||||
$this->totalDurationMs
|
||||
),
|
||||
default => AuditContext::failed(
|
||||
$operationType,
|
||||
$this->lastError ?? ErrorContext::create('unknown', 'Unknown error'),
|
||||
$this->attempts,
|
||||
$this->totalDurationMs,
|
||||
['outcome' => $this->outcome]
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for logging/debugging.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [
|
||||
'outcome' => $this->outcome,
|
||||
'attempts' => $this->attempts,
|
||||
'duration_ms' => round($this->totalDurationMs, 3),
|
||||
];
|
||||
|
||||
if ($this->lastError instanceof ErrorContext) {
|
||||
$data['error'] = $this->lastError->toArray();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
60
src/Recovery/RecoveryStrategy.php
Normal file
60
src/Recovery/RecoveryStrategy.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Recovery;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Interface for implementing recovery strategies when masking operations fail.
|
||||
*
|
||||
* Recovery strategies define how the processor should handle errors during
|
||||
* masking operations, including retry logic and fallback behavior.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
interface RecoveryStrategy
|
||||
{
|
||||
/**
|
||||
* Attempt to execute an operation with recovery logic.
|
||||
*
|
||||
* @param callable $operation The masking operation to execute
|
||||
* @param mixed $originalValue The original value being masked
|
||||
* @param string $path The field path
|
||||
* @param callable|null $auditLogger Optional audit logger for recovery events
|
||||
*
|
||||
* @return RecoveryResult The result of the operation (success or fallback)
|
||||
*/
|
||||
public function execute(
|
||||
callable $operation,
|
||||
mixed $originalValue,
|
||||
string $path,
|
||||
?callable $auditLogger = null
|
||||
): RecoveryResult;
|
||||
|
||||
/**
|
||||
* Determine if an error is recoverable (worth retrying).
|
||||
*
|
||||
* @param Throwable $error The error that occurred
|
||||
* @return bool True if the operation should be retried
|
||||
*/
|
||||
public function isRecoverable(Throwable $error): bool;
|
||||
|
||||
/**
|
||||
* Get the failure mode for this recovery strategy.
|
||||
*/
|
||||
public function getFailureMode(): FailureMode;
|
||||
|
||||
/**
|
||||
* Get the maximum number of retry attempts.
|
||||
*/
|
||||
public function getMaxAttempts(): int;
|
||||
|
||||
/**
|
||||
* Get configuration information about this strategy.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getConfiguration(): array;
|
||||
}
|
||||
307
src/Recovery/RetryStrategy.php
Normal file
307
src/Recovery/RetryStrategy.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Ivuorinen\MonologGdprFilter\Recovery;
|
||||
|
||||
use Ivuorinen\MonologGdprFilter\Audit\ErrorContext;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\MaskingOperationFailedException;
|
||||
use Ivuorinen\MonologGdprFilter\Exceptions\RecursionDepthExceededException;
|
||||
use Ivuorinen\MonologGdprFilter\MaskConstants;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Retry strategy with exponential backoff and fallback behavior.
|
||||
*
|
||||
* Attempts to retry failed masking operations with configurable
|
||||
* delays and maximum attempts, then falls back to a safe value.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
final class RetryStrategy implements RecoveryStrategy
|
||||
{
|
||||
private const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
private const DEFAULT_BASE_DELAY_MS = 10;
|
||||
private const DEFAULT_MAX_DELAY_MS = 100;
|
||||
|
||||
/**
|
||||
* @param int $maxAttempts Maximum number of attempts (1 = no retry)
|
||||
* @param int $baseDelayMs Base delay in milliseconds for exponential backoff
|
||||
* @param int $maxDelayMs Maximum delay cap in milliseconds
|
||||
* @param FailureMode $failureMode How to handle final failure
|
||||
* @param string|null $fallbackMask Custom fallback mask value
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $maxAttempts = self::DEFAULT_MAX_ATTEMPTS,
|
||||
private readonly int $baseDelayMs = self::DEFAULT_BASE_DELAY_MS,
|
||||
private readonly int $maxDelayMs = self::DEFAULT_MAX_DELAY_MS,
|
||||
private readonly FailureMode $failureMode = FailureMode::FAIL_SAFE,
|
||||
private readonly ?string $fallbackMask = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a retry strategy with default settings.
|
||||
*/
|
||||
public static function default(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy that doesn't retry (immediate fallback).
|
||||
*/
|
||||
public static function noRetry(FailureMode $failureMode = FailureMode::FAIL_SAFE): self
|
||||
{
|
||||
return new self(maxAttempts: 1, failureMode: $failureMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy optimized for fast recovery.
|
||||
*/
|
||||
public static function fast(): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: 2,
|
||||
baseDelayMs: 5,
|
||||
maxDelayMs: 20,
|
||||
failureMode: FailureMode::FAIL_SAFE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a strategy for thorough retry attempts.
|
||||
*/
|
||||
public static function thorough(): self
|
||||
{
|
||||
return new self(
|
||||
maxAttempts: 5,
|
||||
baseDelayMs: 20,
|
||||
maxDelayMs: 200,
|
||||
failureMode: FailureMode::FAIL_CLOSED
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(
|
||||
callable $operation,
|
||||
mixed $originalValue,
|
||||
string $path,
|
||||
?callable $auditLogger = null
|
||||
): RecoveryResult {
|
||||
$startTime = microtime(true);
|
||||
$lastError = null;
|
||||
|
||||
for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
|
||||
$attemptResult = $this->handleRetryAttempt($operation, $startTime, $attempt, $path, $auditLogger);
|
||||
|
||||
if ($attemptResult['success'] && $attemptResult['result'] instanceof RecoveryResult) {
|
||||
return $attemptResult['result'];
|
||||
}
|
||||
|
||||
$lastError = $attemptResult['errorContext'];
|
||||
$exception = $attemptResult['exception'];
|
||||
|
||||
if (!$this->shouldContinueRetrying($exception, $attempt)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->applyFallback($originalValue, $path, $startTime, $lastError, $auditLogger);
|
||||
}
|
||||
|
||||
public function isRecoverable(Throwable $error): bool
|
||||
{
|
||||
// These errors indicate permanent failures that won't recover with retry
|
||||
if ($error instanceof RecursionDepthExceededException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Some MaskingOperationFailedException errors are non-recoverable
|
||||
if ($error instanceof MaskingOperationFailedException) {
|
||||
$message = $error->getMessage();
|
||||
$isNonRecoverable = str_contains($message, 'Pattern compilation failed')
|
||||
|| str_contains($message, 'ReDoS');
|
||||
|
||||
return !$isNonRecoverable;
|
||||
}
|
||||
|
||||
// Transient errors like timeouts might recover
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getFailureMode(): FailureMode
|
||||
{
|
||||
return $this->failureMode;
|
||||
}
|
||||
|
||||
public function getMaxAttempts(): int
|
||||
{
|
||||
return $this->maxAttempts;
|
||||
}
|
||||
|
||||
public function getConfiguration(): array
|
||||
{
|
||||
return [
|
||||
'max_attempts' => $this->maxAttempts,
|
||||
'base_delay_ms' => $this->baseDelayMs,
|
||||
'max_delay_ms' => $this->maxDelayMs,
|
||||
'failure_mode' => $this->failureMode->value,
|
||||
'fallback_mask' => $this->fallbackMask ?? '[auto]',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single retry attempt.
|
||||
*
|
||||
* @return array{
|
||||
* success: bool,
|
||||
* result: RecoveryResult|null,
|
||||
* errorContext: ErrorContext|null,
|
||||
* exception: Throwable|null
|
||||
* }
|
||||
*/
|
||||
private function handleRetryAttempt(
|
||||
callable $operation,
|
||||
float $startTime,
|
||||
int $attempt,
|
||||
string $path,
|
||||
?callable $auditLogger
|
||||
): array {
|
||||
try {
|
||||
$result = $operation();
|
||||
$duration = (microtime(true) - $startTime) * 1000.0;
|
||||
|
||||
$recoveryResult = $attempt === 1
|
||||
? RecoveryResult::success($result, $duration)
|
||||
: RecoveryResult::recovered($result, $attempt, $duration);
|
||||
|
||||
return ['success' => true, 'result' => $recoveryResult, 'errorContext' => null, 'exception' => null];
|
||||
} catch (Throwable $e) {
|
||||
$errorContext = ErrorContext::fromThrowable($e);
|
||||
$this->logRetryAttempt($path, $attempt, $errorContext, $auditLogger);
|
||||
|
||||
return ['success' => false, 'result' => null, 'errorContext' => $errorContext, 'exception' => $e];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a retry attempt to the audit logger.
|
||||
*/
|
||||
private function logRetryAttempt(
|
||||
string $path,
|
||||
int $attempt,
|
||||
ErrorContext $errorContext,
|
||||
?callable $auditLogger
|
||||
): void {
|
||||
if ($auditLogger !== null && $attempt < $this->maxAttempts) {
|
||||
$auditLogger(
|
||||
'recovery_retry',
|
||||
['path' => $path, 'attempt' => $attempt],
|
||||
['error' => $errorContext->message, 'will_retry' => true]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if retry should be continued.
|
||||
*/
|
||||
private function shouldContinueRetrying(?Throwable $exception, int $attempt): bool
|
||||
{
|
||||
if (!$exception instanceof \Throwable || !$this->isRecoverable($exception)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($attempt >= $this->maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->delay($attempt);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply fallback value after all retry attempts failed.
|
||||
*/
|
||||
private function applyFallback(
|
||||
mixed $originalValue,
|
||||
string $path,
|
||||
float $startTime,
|
||||
?ErrorContext $lastError,
|
||||
?callable $auditLogger
|
||||
): RecoveryResult {
|
||||
$duration = (microtime(true) - $startTime) * 1000.0;
|
||||
$fallbackValue = $this->getFallbackValue($originalValue);
|
||||
|
||||
$errorContext = $lastError ?? ErrorContext::create('unknown', 'No error captured');
|
||||
|
||||
if ($auditLogger !== null) {
|
||||
$auditLogger(
|
||||
'recovery_fallback',
|
||||
['path' => $path, 'mode' => $this->failureMode->value],
|
||||
['error' => $errorContext->message, 'fallback_applied' => true]
|
||||
);
|
||||
}
|
||||
|
||||
return RecoveryResult::fallback(
|
||||
$fallbackValue,
|
||||
$this->maxAttempts,
|
||||
$errorContext,
|
||||
$duration
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fallback value based on failure mode.
|
||||
*/
|
||||
private function getFallbackValue(mixed $originalValue): mixed
|
||||
{
|
||||
if ($this->fallbackMask !== null) {
|
||||
return $this->fallbackMask;
|
||||
}
|
||||
|
||||
return match ($this->failureMode) {
|
||||
FailureMode::FAIL_OPEN => $originalValue,
|
||||
FailureMode::FAIL_CLOSED => MaskConstants::MASK_REDACTED,
|
||||
FailureMode::FAIL_SAFE => $this->getSafeFallback($originalValue),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a safe fallback value that preserves type information.
|
||||
*/
|
||||
private function getSafeFallback(mixed $originalValue): mixed
|
||||
{
|
||||
return match (gettype($originalValue)) {
|
||||
'string' => MaskConstants::MASK_STRING,
|
||||
'integer' => MaskConstants::MASK_INT,
|
||||
'double' => MaskConstants::MASK_FLOAT,
|
||||
'boolean' => MaskConstants::MASK_BOOL,
|
||||
'array' => MaskConstants::MASK_ARRAY,
|
||||
'object' => MaskConstants::MASK_OBJECT,
|
||||
'NULL' => MaskConstants::MASK_NULL,
|
||||
default => MaskConstants::MASK_MASKED,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply exponential backoff delay.
|
||||
*
|
||||
* @param int $attempt Current attempt number (1-based)
|
||||
*/
|
||||
private function delay(int $attempt): void
|
||||
{
|
||||
// Exponential backoff: baseDelay * 2^(attempt-1)
|
||||
$delay = $this->baseDelayMs * (2 ** ($attempt - 1));
|
||||
|
||||
// Apply jitter (random 0-25% of delay)
|
||||
$jitterMax = (int) floor((float) $delay * 0.25);
|
||||
$jitter = random_int(0, $jitterMax);
|
||||
$delay += $jitter;
|
||||
|
||||
// Cap at max delay
|
||||
$delay = min($delay, $this->maxDelayMs);
|
||||
|
||||
// Convert to microseconds and sleep
|
||||
usleep($delay * 1000);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user