fix: Gitea Traefik routing and connection pool optimization
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\RateLimit;
|
||||
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception Fingerprint
|
||||
*
|
||||
* Generates unique fingerprints for exceptions to enable rate limiting
|
||||
* by grouping similar exceptions together.
|
||||
*
|
||||
* Immutable value object.
|
||||
*/
|
||||
final readonly class ExceptionFingerprint
|
||||
{
|
||||
private function __construct(
|
||||
private string $hash,
|
||||
private string $normalizedMessage
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fingerprint from exception
|
||||
*/
|
||||
public static function fromException(Throwable $exception): self
|
||||
{
|
||||
$normalizedMessage = self::normalizeMessage($exception->getMessage());
|
||||
|
||||
$components = [
|
||||
get_class($exception),
|
||||
$normalizedMessage,
|
||||
$exception->getFile(),
|
||||
(string) $exception->getLine(),
|
||||
];
|
||||
|
||||
$hash = hash('sha256', implode('|', $components));
|
||||
|
||||
return new self($hash, $normalizedMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fingerprint from exception with context
|
||||
*
|
||||
* Includes component and operation from context for more precise grouping.
|
||||
*/
|
||||
public static function fromExceptionWithContext(
|
||||
Throwable $exception,
|
||||
?string $component = null,
|
||||
?string $operation = null
|
||||
): self {
|
||||
$normalizedMessage = self::normalizeMessage($exception->getMessage());
|
||||
|
||||
$components = [
|
||||
get_class($exception),
|
||||
$normalizedMessage,
|
||||
$exception->getFile(),
|
||||
(string) $exception->getLine(),
|
||||
];
|
||||
|
||||
// Add context if available for more precise grouping
|
||||
if ($component !== null) {
|
||||
$components[] = $component;
|
||||
}
|
||||
if ($operation !== null) {
|
||||
$components[] = $operation;
|
||||
}
|
||||
|
||||
$hash = hash('sha256', implode('|', $components));
|
||||
|
||||
return new self($hash, $normalizedMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize exception message for fingerprinting
|
||||
*
|
||||
* Removes variable parts like:
|
||||
* - UUIDs
|
||||
* - Timestamps
|
||||
* - IDs
|
||||
* - Numbers (but keeps words containing numbers)
|
||||
*/
|
||||
private static function normalizeMessage(string $message): string
|
||||
{
|
||||
$normalized = $message;
|
||||
|
||||
// UUIDs entfernen
|
||||
$normalized = preg_replace(
|
||||
'/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i',
|
||||
'{UUID}',
|
||||
$normalized
|
||||
);
|
||||
|
||||
// Timestamps entfernen (ISO 8601)
|
||||
$normalized = preg_replace(
|
||||
'/\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}/',
|
||||
'{TIMESTAMP}',
|
||||
$normalized
|
||||
);
|
||||
|
||||
// File paths mit Zeilennummern
|
||||
$normalized = preg_replace(
|
||||
'/\/[\w\/\-\.]+\.php:\d+/',
|
||||
'{FILE}',
|
||||
$normalized
|
||||
);
|
||||
|
||||
// Zahlen (aber behalte Wörter die Zahlen enthalten)
|
||||
$normalized = preg_replace('/\b\d+\b/', '{NUMBER}', $normalized);
|
||||
|
||||
// Email-Adressen
|
||||
$normalized = preg_replace('/\b[\w\.-]+@[\w\.-]+\.\w+\b/', '{EMAIL}', $normalized);
|
||||
|
||||
// URLs
|
||||
$normalized = preg_replace('/https?:\/\/[^\s]+/', '{URL}', $normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fingerprint hash
|
||||
*/
|
||||
public function getHash(): string
|
||||
{
|
||||
return $this->hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get normalized message
|
||||
*/
|
||||
public function getNormalizedMessage(): string
|
||||
{
|
||||
return $this->normalizedMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->hash;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\RateLimit;
|
||||
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
|
||||
/**
|
||||
* Exception Rate Limit Configuration
|
||||
*
|
||||
* Immutable configuration for exception rate limiting.
|
||||
*/
|
||||
final readonly class ExceptionRateLimitConfig
|
||||
{
|
||||
/**
|
||||
* @param int $maxExceptions Maximum number of exceptions allowed per time window
|
||||
* @param Duration $timeWindow Time window for rate limiting
|
||||
* @param bool $enabled Whether rate limiting is enabled
|
||||
* @param bool $skipLoggingOnLimit Whether to skip logging when rate limit is reached
|
||||
* @param bool $skipAuditOnLimit Whether to skip audit logging when rate limit is reached
|
||||
* @param bool $trackMetricsOnLimit Whether to track metrics even when rate limit is reached
|
||||
*/
|
||||
public function __construct(
|
||||
public int $maxExceptions = 10,
|
||||
public Duration $timeWindow = new Duration(60), // 1 minute default
|
||||
public bool $enabled = true,
|
||||
public bool $skipLoggingOnLimit = true,
|
||||
public bool $skipAuditOnLimit = true,
|
||||
public bool $trackMetricsOnLimit = true
|
||||
) {
|
||||
if ($this->maxExceptions < 1) {
|
||||
throw new \InvalidArgumentException('maxExceptions must be at least 1');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default configuration
|
||||
*/
|
||||
public static function default(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create configuration with custom limits
|
||||
*/
|
||||
public static function withLimits(int $maxExceptions, Duration $timeWindow): self
|
||||
{
|
||||
return new self(
|
||||
maxExceptions: $maxExceptions,
|
||||
timeWindow: $timeWindow
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create disabled configuration
|
||||
*/
|
||||
public static function disabled(): self
|
||||
{
|
||||
return new self(enabled: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new instance with different max exceptions
|
||||
*/
|
||||
public function withMaxExceptions(int $maxExceptions): self
|
||||
{
|
||||
return new self(
|
||||
maxExceptions: $maxExceptions,
|
||||
timeWindow: $this->timeWindow,
|
||||
enabled: $this->enabled,
|
||||
skipLoggingOnLimit: $this->skipLoggingOnLimit,
|
||||
skipAuditOnLimit: $this->skipAuditOnLimit,
|
||||
trackMetricsOnLimit: $this->trackMetricsOnLimit
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new instance with different time window
|
||||
*/
|
||||
public function withTimeWindow(Duration $timeWindow): self
|
||||
{
|
||||
return new self(
|
||||
maxExceptions: $this->maxExceptions,
|
||||
timeWindow: $timeWindow,
|
||||
enabled: $this->enabled,
|
||||
skipLoggingOnLimit: $this->skipLoggingOnLimit,
|
||||
skipAuditOnLimit: $this->skipAuditOnLimit,
|
||||
trackMetricsOnLimit: $this->trackMetricsOnLimit
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new instance with enabled/disabled state
|
||||
*/
|
||||
public function withEnabled(bool $enabled): self
|
||||
{
|
||||
return new self(
|
||||
maxExceptions: $this->maxExceptions,
|
||||
timeWindow: $this->timeWindow,
|
||||
enabled: $enabled,
|
||||
skipLoggingOnLimit: $this->skipLoggingOnLimit,
|
||||
skipAuditOnLimit: $this->skipAuditOnLimit,
|
||||
trackMetricsOnLimit: $this->trackMetricsOnLimit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\ExceptionHandling\RateLimit;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheItem;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Exception Rate Limiter
|
||||
*
|
||||
* Prevents log spam by rate limiting repeated identical exceptions.
|
||||
* Uses cache-based tracking with configurable thresholds.
|
||||
*/
|
||||
final readonly class ExceptionRateLimiter
|
||||
{
|
||||
private const string CACHE_PREFIX = 'exception_rate_limit:';
|
||||
|
||||
public function __construct(
|
||||
private Cache $cache,
|
||||
private ExceptionRateLimitConfig $config
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if exception should be rate limited
|
||||
*
|
||||
* Returns true if exception should be processed (not rate limited),
|
||||
* false if it should be skipped due to rate limiting.
|
||||
*
|
||||
* @param Throwable $exception Exception to check
|
||||
* @param ExceptionContextData|null $context Optional context for fingerprinting
|
||||
* @return bool True if exception should be processed, false if rate limited
|
||||
*/
|
||||
public function shouldProcess(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled) {
|
||||
return true; // Rate limiting disabled, always process
|
||||
}
|
||||
|
||||
$fingerprint = $context !== null
|
||||
? ExceptionFingerprint::fromExceptionWithContext(
|
||||
$exception,
|
||||
$context->component,
|
||||
$context->operation
|
||||
)
|
||||
: ExceptionFingerprint::fromException($exception);
|
||||
|
||||
$cacheKey = $this->buildCacheKey($fingerprint);
|
||||
$currentCount = $this->getCachedCount($cacheKey);
|
||||
|
||||
if ($currentCount >= $this->config->maxExceptions) {
|
||||
// Rate limit reached
|
||||
return false;
|
||||
}
|
||||
|
||||
// Increment count
|
||||
$this->incrementCount($cacheKey);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logging should be skipped due to rate limiting
|
||||
*/
|
||||
public function shouldSkipLogging(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled || !$this->config->skipLoggingOnLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->shouldProcess($exception, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if audit logging should be skipped due to rate limiting
|
||||
*/
|
||||
public function shouldSkipAudit(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled || !$this->config->skipAuditOnLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->shouldProcess($exception, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metrics should be tracked even when rate limited
|
||||
*/
|
||||
public function shouldTrackMetrics(Throwable $exception, ?ExceptionContextData $context = null): bool
|
||||
{
|
||||
if (!$this->config->enabled || !$this->config->trackMetricsOnLimit) {
|
||||
return true; // Always track if not enabled or tracking not disabled
|
||||
}
|
||||
|
||||
// Track metrics even if rate limited
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current count for exception fingerprint
|
||||
*/
|
||||
public function getCurrentCount(Throwable $exception, ?ExceptionContextData $context = null): int
|
||||
{
|
||||
$fingerprint = $context !== null
|
||||
? ExceptionFingerprint::fromExceptionWithContext(
|
||||
$exception,
|
||||
$context->component,
|
||||
$context->operation
|
||||
)
|
||||
: ExceptionFingerprint::fromException($exception);
|
||||
|
||||
$cacheKey = $this->buildCacheKey($fingerprint);
|
||||
return $this->getCachedCount($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key from fingerprint
|
||||
*/
|
||||
private function buildCacheKey(ExceptionFingerprint $fingerprint): CacheKey
|
||||
{
|
||||
return CacheKey::fromString(self::CACHE_PREFIX . $fingerprint->getHash());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current count from cache key
|
||||
*/
|
||||
private function getCachedCount(CacheKey $cacheKey): int
|
||||
{
|
||||
$result = $this->cache->get($cacheKey);
|
||||
$item = $result->getFirstHit();
|
||||
|
||||
if ($item === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$value = $item->value;
|
||||
return is_int($value) ? $value : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment count in cache
|
||||
*/
|
||||
private function incrementCount(CacheKey $cacheKey): void
|
||||
{
|
||||
$currentCount = $this->getCachedCount($cacheKey);
|
||||
$newCount = $currentCount + 1;
|
||||
|
||||
$cacheItem = CacheItem::fromKey(
|
||||
$cacheKey,
|
||||
$newCount,
|
||||
$this->config->timeWindow
|
||||
);
|
||||
|
||||
$this->cache->set($cacheItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limit for exception fingerprint
|
||||
*
|
||||
* Useful for testing or manual reset.
|
||||
*/
|
||||
public function reset(Throwable $exception, ?ExceptionContextData $context = null): void
|
||||
{
|
||||
$fingerprint = $context !== null
|
||||
? ExceptionFingerprint::fromExceptionWithContext(
|
||||
$exception,
|
||||
$context->component,
|
||||
$context->operation
|
||||
)
|
||||
: ExceptionFingerprint::fromException($exception);
|
||||
|
||||
$cacheKey = $this->buildCacheKey($fingerprint);
|
||||
$this->cache->forget($cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user