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

- 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:
2025-11-09 14:46:15 +01:00
parent 85c369e846
commit 36ef2a1e2c
1366 changed files with 104925 additions and 28719 deletions

View File

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

View File

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

View File

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