Skip to content

RFC 7234 HTTP Caching Support #40

@Thavarshan

Description

@Thavarshan

Summary

Add RFC 7234 compliant HTTP caching support with intelligent cache management, ETags, Last-Modified headers, and configurable cache backends.

Motivation

HTTP caching is essential for:

  • Performance: Dramatically reduce network requests and response times
  • Bandwidth: Save on data transfer costs
  • Reliability: Serve cached responses when services are unavailable
  • User Experience: Faster application response times
  • Rate Limiting: Reduce API quota consumption
  • Offline Support: Enable offline-first applications

Currently, Fetch PHP has no built-in caching mechanism, forcing developers to implement custom solutions or use separate libraries.

Proposed API

// Simple caching with default settings
$response = fetch_client()
    ->withCache()  // Uses default file cache
    ->get('/api/users');

// Advanced caching configuration
$response = fetch_client()
    ->withCache(new RedisCache($redis), [
        'default_ttl' => 3600,
        'respect_cache_headers' => true,
        'stale_while_revalidate' => 300,
        'max_stale' => 86400,
        'vary_headers' => ['Authorization', 'Accept-Language'],
        'cache_methods' => ['GET', 'HEAD'],
        'cache_status_codes' => [200, 203, 300, 301, 410],
    ])
    ->get('/api/data');

// Per-request cache control
$response = fetch('/api/users', [
    'cache' => [
        'ttl' => 1800,
        'key' => 'users_page_1',
        'force_refresh' => false,
        'private' => true,
    ]
]);

// Cache policies
$response = fetch_client()
    ->withCachePolicy([
        'max-age' => 3600,
        'stale-while-revalidate' => 300,
        'stale-if-error' => 86400,
        'must-revalidate' => false,
    ])
    ->get('/api/data');

Implementation Details

Cache Backends

// File-based cache (default)
class FileCache implements CacheInterface
{
    public function __construct(
        private string $directory = '/tmp/fetch-cache',
        private int $defaultTtl = 3600,
        private int $maxSize = 104857600 // 100MB
    ) {}
    
    public function get(string $key): ?CachedResponse;
    public function set(string $key, CachedResponse $response, ?int $ttl = null): void;
    public function delete(string $key): void;
    public function clear(): void;
    public function prune(): int; // Remove expired entries
}

// Redis cache
class RedisCache implements CacheInterface
{
    public function __construct(
        private \Redis $redis,
        private string $keyPrefix = 'fetch:',
        private int $defaultTtl = 3600
    ) {}
}

// Memory cache (for single request lifecycle)
class MemoryCache implements CacheInterface
{
    private array $cache = [];
    private int $maxItems = 1000;
}

// Multi-tier cache
class TieredCache implements CacheInterface
{
    public function __construct(array $caches)
    {
        // L1: Memory, L2: Redis, L3: File
        $this->caches = $caches;
    }
}

Cache Key Generation

class CacheKeyGenerator
{
    public function generate(RequestInterface $request, array $options = []): string
    {
        $components = [
            'method' => $request->getMethod(),
            'uri' => $this->normalizeUri($request->getUri()),
            'headers' => $this->extractVaryHeaders($request, $options['vary_headers'] ?? []),
            'body_hash' => $this->hashBody($request),
        ];
        
        return 'fetch:' . md5(serialize($components));
    }
    
    private function normalizeUri(UriInterface $uri): string
    {
        // Remove fragment, normalize query parameters
        return $uri->withFragment('')->withQuery($this->normalizeQuery($uri->getQuery()));
    }
}

Cache Control Headers

class CacheControl
{
    public static function parse(string $cacheControl): array
    {
        // Parse Cache-Control: max-age=3600, must-revalidate, private
        $directives = [];
        foreach (explode(',', $cacheControl) as $directive) {
            $parts = explode('=', trim($directive), 2);
            $directives[trim($parts[0])] = isset($parts[1]) ? trim($parts[1], '"') : true;
        }
        return $directives;
    }
    
    public function shouldCache(ResponseInterface $response): bool
    {
        $cacheControl = $this->parse($response->getHeaderLine('Cache-Control'));
        
        // Don't cache if explicitly forbidden
        if (isset($cacheControl['no-cache']) || isset($cacheControl['no-store'])) {
            return false;
        }
        
        // Don't cache private responses in shared cache
        if (isset($cacheControl['private']) && $this->isSharedCache) {
            return false;
        }
        
        return true;
    }
    
    public function getTtl(ResponseInterface $response): ?int
    {
        $cacheControl = $this->parse($response->getHeaderLine('Cache-Control'));
        
        // Respect max-age directive
        if (isset($cacheControl['max-age'])) {
            return (int) $cacheControl['max-age'];
        }
        
        // Fall back to Expires header
        if ($expires = $response->getHeaderLine('Expires')) {
            $expiresTime = strtotime($expires);
            return max(0, $expiresTime - time());
        }
        
        return null;
    }
}

ETags and Conditional Requests

class ConditionalRequests
{
    public function addConditionalHeaders(RequestInterface $request, ?CachedResponse $cached): RequestInterface
    {
        if (!$cached) {
            return $request;
        }
        
        // Add If-None-Match for ETag
        if ($etag = $cached->getETag()) {
            $request = $request->withHeader('If-None-Match', $etag);
        }
        
        // Add If-Modified-Since for Last-Modified
        if ($lastModified = $cached->getLastModified()) {
            $request = $request->withHeader('If-Modified-Since', $lastModified);
        }
        
        return $request;
    }
    
    public function handleNotModified(ResponseInterface $response, CachedResponse $cached): ResponseInterface
    {
        if ($response->getStatusCode() === 304) {
            // Update cache with new headers but keep body from cache
            return $cached->withHeaders($response->getHeaders());
        }
        
        return $response;
    }
}

Stale-While-Revalidate Support

class StaleWhileRevalidate
{
    public function handle(
        RequestInterface $request,
        CachedResponse $staleResponse,
        callable $fetchFresh
    ): ResponseInterface {
        // Serve stale immediately
        $staleResponse = $staleResponse->withHeader('X-Cache-Status', 'STALE');
        
        // Trigger background revalidation
        if ($this->shouldRevalidate($staleResponse)) {
            $this->scheduleRevalidation($request, $fetchFresh);
        }
        
        return $staleResponse;
    }
    
    private function scheduleRevalidation(RequestInterface $request, callable $fetchFresh): void
    {
        // Use async processing to update cache in background
        async(function() use ($request, $fetchFresh) {
            try {
                $fresh = await($fetchFresh($request));
                $this->cache->set($this->generateKey($request), $fresh);
            } catch (\Throwable $e) {
                $this->logger->warning('Cache revalidation failed', ['error' => $e->getMessage()]);
            }
        });
    }
}

Advanced Features

Intelligent Cache Warming

$client = fetch_client()
    ->withCache($cache)
    ->warmCache([
        '/api/users' => ['ttl' => 3600, 'priority' => 'high'],
        '/api/settings' => ['ttl' => 86400, 'priority' => 'medium'],
        '/api/config' => ['ttl' => 604800, 'priority' => 'low'],
    ]);

Cache Tagging and Invalidation

$response = fetch('/api/users', [
    'cache' => [
        'tags' => ['users', 'api_v2'],
        'ttl' => 3600,
    ]
]);

// Invalidate by tags
$cache->invalidateByTags(['users']);

Cache Statistics and Monitoring

$stats = $cache->getStats();
// Returns: hit_rate, miss_rate, evictions, size, items, etc.

$client = fetch_client()
    ->withCache($cache)
    ->onCacheHit(fn($key, $response) => $metrics->increment('cache.hit'))
    ->onCacheMiss(fn($key) => $metrics->increment('cache.miss'))
    ->get('/api/data');

Cache Compression

$cache = new FileCache('/tmp/cache', [
    'compression' => 'gzip',
    'compression_level' => 6,
    'compression_threshold' => 1024, // Only compress responses > 1KB
]);

Use Cases

API Response Caching

// Cache API responses with appropriate TTL
$users = fetch_client()
    ->withCache(new RedisCache($redis))
    ->get('/api/users')
    ->json();

Image/Asset Caching

// Cache images and assets with long TTL
$image = fetch('/images/avatar.jpg', [
    'cache' => ['ttl' => 604800] // 1 week
]);

Configuration Caching

// Cache configuration with background refresh
$config = fetch('/api/config', [
    'cache' => [
        'ttl' => 3600,
        'stale_while_revalidate' => 300,
        'stale_if_error' => 86400,
    ]
])->json();

Offline-First Applications

try {
    $data = fetch('/api/data', [
        'cache' => [
            'serve_stale_on_error' => true,
            'max_stale' => 86400,
        ]
    ])->json();
} catch (NetworkException $e) {
    // Will serve stale cache if available
    throw $e;
}

Testing Strategy

class CacheTest extends TestCase
{
    public function test_caches_get_requests()
    {
        $cache = new MemoryCache();
        $client = fetch_client()->withCache($cache);
        
        // First request - cache miss
        $response1 = $client->get('/api/users');
        $this->assertEquals('MISS', $response1->getHeaderLine('X-Cache-Status'));
        
        // Second request - cache hit
        $response2 = $client->get('/api/users');
        $this->assertEquals('HIT', $response2->getHeaderLine('X-Cache-Status'));
        $this->assertEquals($response1->getBody(), $response2->getBody());
    }
    
    public function test_respects_cache_control_headers()
    {
        MockServer::fake([
            'GET /api/no-cache' => MockResponse::json(['data' => 'test'])
                ->withHeader('Cache-Control', 'no-cache'),
        ]);
        
        $cache = new MemoryCache();
        $client = fetch_client()->withCache($cache);
        
        $response = $client->get('/api/no-cache');
        $this->assertFalse($cache->has($this->generateKey('/api/no-cache')));
    }
    
    public function test_etag_conditional_requests()
    {
        MockServer::fake([
            'GET /api/data' => MockResponse::json(['data' => 'original'])
                ->withHeader('ETag', '"version1"'),
        ]);
        
        $cache = new MemoryCache();
        $client = fetch_client()->withCache($cache);
        
        // First request
        $response1 = $client->get('/api/data');
        
        // Mock 304 Not Modified for second request
        MockServer::fake([
            'GET /api/data' => function ($request) {
                if ($request->hasHeader('If-None-Match')) {
                    return MockResponse::create(304);
                }
                return MockResponse::json(['data' => 'updated']);
            }
        ]);
        
        $response2 = $client->get('/api/data');
        $this->assertEquals(200, $response2->getStatusCode());
        $this->assertEquals('original', $response2->json()['data']);
    }
}

Benefits

  • Performance: 10-100x faster responses for cached content
  • Bandwidth Savings: Reduce data transfer by 60-90%
  • API Quota Management: Minimize API calls and costs
  • Reliability: Serve cached responses during outages
  • User Experience: Near-instant response times
  • Offline Support: Enable offline-first applications
  • Standards Compliance: Full RFC 7234 compliance
  • Flexibility: Multiple cache backends and strategies

Migration Path

  1. Phase 1: Core caching infrastructure and file backend
  2. Phase 2: Redis backend and conditional requests
  3. Phase 3: Advanced features (stale-while-revalidate, tagging)
  4. Phase 4: Performance optimizations and monitoring

Configuration Examples

// Production configuration
fetch_client([
    'cache' => [
        'backend' => new TieredCache([
            new MemoryCache(['max_items' => 100]),
            new RedisCache($redis, ['ttl' => 3600]),
            new FileCache('/var/cache/fetch', ['ttl' => 86400]),
        ]),
        'respect_cache_headers' => true,
        'stale_while_revalidate' => 300,
        'compression' => true,
    ]
]);

// Development configuration
fetch_client([
    'cache' => [
        'backend' => new MemoryCache(),
        'ttl' => 60, // Short TTL for development
        'debug' => true,
    ]
]);

Priority

High Impact, Medium Effort - Caching is fundamental for production applications and significantly improves performance.

Metadata

Metadata

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions