8000 [HttpClient] Make `CachingHttpClient` compatible with RFC 9111 by Lctrs · Pull Request #59576 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

Conversation

@Lctrs
Copy link
Contributor
@Lctrs Lctrs commented Jan 21, 2025
Q A
Branch? 7.3
Bug fix? no
New feature? yes
Deprecations? yes
Issues Fix #36858 Fix #49937 Fix #41843 Fix #61588
License MIT

This PR brings RFC9111 support to CachingHttpClient by leveraging symfony/cache. It provides caching for GET and HEAD requests, and cache invalidation for POST, PUT, DELETE, PATCH.

The implementation is asynchronous, so the response must be consumed (e.g., via getContent() or streaming) for caching to occur.

Basic usage

// Create the base HTTP client
$baseClient = HttpClient::create();

// Create a cache backend (e.g., Filesystem cache)
$cache = new FilesystemAdapter();

// Instantiate the CachingHttpClient
$client = new CachingHttpClient(
    client: $baseClient,
    cache: $cache,
    defaultOptions: [], // Optional default options
    sharedCache: true,  // Use a shared cache (default)
    maxTtl: 86400      // Maximum cache TTL in seconds (optional)
);

// Make a request
$response = $client->request('GET', 'https://api.example.com/data');

// Get the response content
$content = $response->getContent();

Constructor parameters

  • $client: The underlying HTTP client.
  • $cache: A cache backend implementing TagAwareCacheInterface.
  • $defaultOptions: An optional array of default request options.
  • $sharedCache: true to indicate the cache is shared, false for private. Defaults to true.
  • $maxTtl: Optional maximum time-to-live (in seconds) for cached responses. If set, server-provided TTLs are capped to this value.

Omissions from RFC9111

  • The following cache-control directives are not supported and thus ignored : stale-while-revalidate, min-fresh, max-stale, and only-if-cached. In the case of stale-while-revalidate, a stale response will always be revalidated.
  • Range requests and partial content (206 status, Range/Content-Range headers) are not supported; requests with Range headers bypass caching.

### Integration with FrameworkBundle

framework:
    http_client:
        caching:
            cache_pool: my_taggable_pool
            shared: true
            max_ttl: 86400
        scoped_clients:
            caching_client:
                base_uri: 'https://symfony.com'
                caching:
                    cache_pool: my_taggable_pool

It is also highly recommended to configure a retry strategy to automatically retry the request in case of cache inconsistency.

@carsonbot

This comment has been minimized.

@Lctrs Lctrs marked this pull request as ready for review January 21, 2025 23:54
@carsonbot carsonbot added this to the 7.3 milestone Jan 21, 2025
@stof
Copy link
Member
stof commented Jan 22, 2025

Can we bring this to CachingHttpClient (with a BC layer) instead of introducing a new class that is the good implementation of caching but with a name that is much less clear ?
Also, having the RFC id in the class name is a bad idea to me, as it would be a pain if a new RFC gets released to replace the current one (which happened multiple times for RFCs related to HTTP)

@Lctrs Lctrs force-pushed the rfc9111-http-client branch from 62db76a to 5b52dd2 Compare February 18, 2025 20:59
@Lctrs Lctrs changed the title [HttpClient] Add an RFC 9111 compliant client [HttpClient] Make CachingHttpClientCompatible with RFC 9111 Feb 18, 2025
@Lctrs Lctrs changed the title [HttpClient] Make CachingHttpClientCompatible with RFC 9111 [HttpClient] Make CachingHttpClient compatible with RFC 9111 Feb 18, 2025
@Lctrs
Copy link
Contributor Author
Lctrs commented Feb 19, 2025

UPDATE:

  • CI failure on PHP 8.5 is unrelated
  • Psalm failure seems to be a false positive to me
  • Tests pass locally on low deps, but not on github actions. I need to look at it.
  • I don't know how to please fabbot. Should I not make any change to the upgrade file ?

Otherwise, PR is ready on my side. Ready for another reviews.

@Lctrs Lctrs force-pushed the rfc9111-http-client branch 2 times, most recently from b7a0bb4 to 12bf66f Compare February 20, 8000 2025 16:16
@Lctrs Lctrs requested a review from stof February 21, 2025 08:45
@Lctrs Lctrs force-pushed the rfc9111-http-client branch from e93f3b2 to d0be468 Compare February 22, 2025 23:19
@OskarStark OskarStark changed the title [HttpClient] Make CachingHttpClient compatible with RFC 9111 [HttpClient] Make CachingHttpClient compatible with RFC 9111 Feb 24, 2025
@Lctrs Lctrs force-pushed the rfc9111-http-client branch 3 times, most recently from b9df14d to 0405dc5 Compare February 28, 2025 10:51
Copy link
Member
@stof stof left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, but I'd like a review from @nicolas-grekas as he is the one knowing symfony/http-client best.

Copy link
Member
@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't get into all details but still LGTM thanks!

@nicolas-grekas
Copy link
Member

Can you please maybe expand in the PR description? Think about a blog post we could derive from it :)

@Lctrs
Copy link
Contributor Author
Lctrs commented 5D2A Sep 5, 2025

I'm currently on holiday. I'll get back to it next week. Including my thoughts on possible race conditions discussed earlier.

Can you please maybe expand in the PR description? Think about a blog post we could derive from it :)

May you be more specific ? You want me to explain the code ?

@nicolas-grekas
Copy link
Member

What we could put in the doc about it yes (thus not internal details of course)

@Lctrs
Copy link
Contributor Author
Lctrs commented Sep 8, 2025

@nicolas-grekas Done. Hope it's what you had in mind.

About concurrent requests, due to the async nature of the implementation - and unless I'm missing something -, wrapping everything in a $cache->get() call won't do the trick. We may have to bring symfony/lock to the party to implement request coalescing.

Copy link
Member
@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm proposing to be a bit stricter about vary computation, see comments.

About concurrent requests, I understand that the cached response will become available only after the last chunk has been received. This means the concurrency model is: duplicate requests until one completes. That's totally fine to me.
There's just one issue I think: during this warmup phase, two concurrent requests can corrupt the chunks of the other one.
We could fix this by adding a random value in the chunk cache, stored also in the final item, so that we can be sure we're dealing with the correct chain of chunks.
Does this make sense?

Here are some CS improvements as a patch.
diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php
index 87fae82c95a..8127e4ac205 100644
--- a/src/Symfony/Component/HttpClient/CachingHttpClient.php
+++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php
@@ -164,21 +164,13 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
 
         $requestHash = self::hash($method.$fullUrl.serialize(array_intersect_key($options['normalized_headers'], self::RESPONSE_INFLUENCING_HEADERS)));
         $varyKey = "vary_{$requestHash}";
-        $varyFields = $this->cache->get($varyKey, static function (ItemInterface $item, bool &$save): array {
-            $save = false;
-
-            return [];
-        }, 0);
+        $varyFields = $this->cache->get($varyKey, static fn ($item, &$save): array => ($save = false) ?: [], 0);
 
         $metadataKey = self::getMetadataKey($requestHash, $options['normalized_headers'], $varyFields);
-        $cachedData = $this->cache->get($metadataKey, static function (ItemInterface $item, bool &$save): null {
-            $save = false;
-
-            return null;
-        });
+        $cachedData = $this->cache->get($metadataKey, static fn ($item, &$save): array => ($save = false) ?: [], 0);
 
         $freshness = null;
-        if (\is_array($cachedData)) {
+        if ($cachedData) {
             $freshness = $this->evaluateCacheFreshness($cachedData);
 
             if (Freshness::Fresh === $freshness) {
 
@@ -433,11 +429,11 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
     /**
      * Generates a unique metadata key based on the request hash and varying headers.
      *
-     * @param string                         $requestHash       a hash representing the request details
-     * @param array<string, string|string[]> $normalizedHeaders normalized headers of the request
-     * @param string[]                       $varyFields        headers to consider for building the variant key
+     * @param string                         $requestHash       A hash representing the request details
+     * @param array<string, string|string[]> $normalizedHeaders Normalized headers of the request
+     * @param string[]                       $varyFields        Headers to consider for building the variant key
      *
-     * @return string the metadata key composed of the request hash and variant key
+     * @return string The metadata key composed of the request hash and variant key
      */
     private static function getMetadataKey(string $requestHash, array $normalizedHeaders, array $varyFields): string
     {
@@ -449,7 +445,8 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
     /**
      * Build a variant key for caching, given an array of normalized headers and the vary fields.
      *
-     * The key is a pipe-separated string of "header=value" pairs, with the special case of "header=" for headers that are not present.
+     * The key is a pipe-separated string of "header=value" pairs, with
+     * the special case of "header=" for headers that are not present.
      *
      * @param array<string, string|string[]> $normalizedHeaders
      * @param string[]                       $varyFields
@@ -460,25 +457,23 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
     /**
      * Parse the Cache-Control header and return an array of directive names as keys
      * and their values as values, or true if the directive has no value.
      *
-     * @param array<string, string|string[]> $header the Cache-Control header as an array of strings
+     * @param array<string, string|string[]> $header The Cache-Control header as an array of strings
      *
-     * @return array<string, string|true> the parsed Cache-Control directives
+     * @return array<string, string|true> The parsed Cache-Control directives
      */
     private static function parseCacheControlHeader(array $header): array
     {
@@ -503,7 +498,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
      * This method determines the state of the cached response by analyzing the Cache-Control
      * directives and the expiration timestamp.
      *
-     * @param array{headers: array<string, string[]>, expires_at: int|null} $data the cached response data, including headers and expiration time
+     * @param array{headers: array<string, string[]>, expires_at: int|null} $data The cached response data, including headers and expiration time
      */
     private function evaluateCacheFreshness(array $data): Freshness
     {
@@ -547,14 +542,12 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
      * the Expires header for a valid timestamp, and if present, returns the
      * difference between the timestamp and the current time minus the current age.
      *
-     * If none of the above directives or headers are present, the method returns
-     * null.
+     * If none of the above directives or headers are present, the method returns null.
      *
-     * @param array<string, string|string[]> $headers      an array of HTTP headers
-     * @param array<string, string|true>     $cacheControl an array of parsed Cache-Control directives
+     * @param array<string, string|string[]> $headers      An array of HTTP headers
+     * @param array<string, string|true>     $cacheControl An array of parsed Cache-Control directives
      *
-     * @return int|null the maximum age of the response, or null if it cannot be
-     *                  determined
+     * @return int|null The maximum age of the response, or null if it cannot be determined
      */
     private function determineMaxAge(array $headers, array $cacheControl): ?int
     {
@@ -608,9 +601,9 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
     /**
      * Retrieves the current age of the response from the headers.
      *
-     * @param array<string, string|string[]> $headers an array of HTTP headers
+     * @param array<string, string|string[]> $headers An array of HTTP headers
      *
-     * @return int The age of the response in seconds. Defaults to 0 if not present.
+     * @return int The age of the response in seconds, defaults to 0 if not present
      */
     private static function getCurrentAge(array $headers): int
     {
@@ -620,9 +613,9 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
     /**
      * Calculates the expiration time of the response given the maximum age.
      *
-     * @param int|null $maxAge the maximum age of the response in seconds, or null if it cannot be determined
+     * @param int|null $maxAge The maximum age of the response in seconds, or null if it cannot be determined
      *
-     * @return int|null the expiration time of the response as a Unix timestamp, or null if the maximum age is null
+     * @return int|null The expiration time of the response as a Unix timestamp, or null if the maximum age is null
      */
     private static function calculateExpiresAt(?int $maxAge): ?int
     {
@@ -646,7 +639,6 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
      */
     private function isServerResponseCacheable(int $statusCode, array $requestHeaders, array $responseHeaders, array $cacheControl): bool
     {
-        // no-store => skip caching
         if (isset($cacheControl['no-store'])) {
             return false;
         }
@@ -700,32 +692,28 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
      */
     private function createResponseFromCache(string $key, array $cachedData, string $method, string $url, array $options, string $fullUrlTag): MockResponse
     {
-        return MockResponse::fromRequest(
-            $method,
-            $url,
-            $options,
-            new MockResponse(
-                (function () use ($key, $cachedData, $fullUrlTag): \Generator {
-                    for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) {
-                        yield $this->cache->get("{$key}_chunk_{$i}", function (ItemInterface $item) use ($fullUrlTag): never {
-                            $this->cache->invalidateTags([$fullUrlTag]);
-
-                            throw new ChunkCacheItemNotFoundException(\sprintf('Missing cache item for chunk with key "%s". This indicates an internal cache inconsistency.', $item->getKey()));
-                        }, 0);
-                    }
-                })(),
-                ['http_code' => $cachedData['status_code'], 'response_headers' => ['age' => $cachedData['initial_age'] + (time() - $cachedData['stored_at'])] + $cachedData['headers']]
-            )
-        );
+        $cache = $this->cache;
+        $callback = static function (ItemInterface $item) use ($cache, $fullUrlTag): never {
+            $cache->invalidateTags([$fullUrlTag]);
+
+            th
5D2A
row new ChunkCacheItemNotFoundException(\sprintf('Missing cache item for chunk with key "%s". This indicates an internal cache inconsistency.', $item->getKey()));
+        };
+        $body = static function () use ($cache, $key, $cachedData, $callback): \Generator {
+            for ($i = 0; $i <= $cachedData['chunks_count']; ++$i) {
+                yield $cache->get("{$key}_chunk_{$i}", $callback, 0);
+            }
+        };
+
+        return MockResponse::fromRequest($method, $url, $options, new MockResponse($body(), [
+            'http_code' => $cachedData['status_code'],
+            'response_headers' => [
+                'age' => $cachedData['initial_age'] + (time() - $cachedData['stored_at']),
+            ] + $cachedData['headers'],
+        ]));
     }
 
     private static function createGatewayTimeoutResponse(string $method, string $url, array $options): MockResponse
     {
-        return MockResponse::fromRequest(
-            $method,
-            $url,
-            $options,
-            new MockResponse('', ['http_code' => 504])
-        );
+        return MockResponse::fromRequest($method, $url, $options, new MockResponse('', ['http_code' => 504]));
     }
 }

@Lctrs
Copy link
Contributor Author
Lctrs commented Sep 9, 2025

About concurrent requests, I understand that the cached response will become available only after the last chunk has been received. This means the concurrency model is: duplicate requests until one completes. That's totally fine to me.
There's just one issue I think: during this warmup phase, two concurrent requests can corrupt the chunks of the other one.
We could fix this by adding a random value in the chunk cache, stored also in the final item, so that we can be sure we're dealing with the correct chain of chunks.
Does this make sense?

I don't think the probability of having different responses for doing the 2 same requests in a very short time span is very high but who knows ?

That being said, the only reason the cached response is made available at the last chunk is because of the chunks count. If we change the logic of the reconstitution of cached responses' content by turning the chunks' cache entry as a linked list (ie. array{content: string, next_chunk: string|null}, we could make it work by generating a random id or using chunks' offsets), we could write status code, headers, etc. of the response in the cache in the first chunk (chunks_count would be replaced by first_chunk pointing to the key of the first chunk in the cache). I believe this would make it easier to implement request coalescing.

@nicolas-grekas
Copy link
Member

Linked list works for me too. I'd just not use offsets as it's always possible for two concurrent requests to yield different chunks.

@Lctrs Lctrs force-pushed the rfc9111-http-client branch from db8a067 to 0eb8d4d Compare September 9, 2025 13:00
@Lctrs
Copy link
Contributor Author
Lctrs commented Sep 9, 2025

@nicolas-grekas done.

Copy link
Member
@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can reduce the storage requirements a bit this way.
diff --git a/src/Symfony/Component/HttpClient/CachingHttpClient.php b/src/Symfony/Component/HttpClient/CachingHttpClient.php
index 7e021c1b3cd..714ee889cb9 100644
--- a/src/Symfony/Component/HttpClient/CachingHttpClient.php
+++ b/src/Symfony/Component/HttpClient/CachingHttpClient.php
@@ -174,7 +174,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
             $freshness = $this->evaluateCacheFreshness($cachedData);
 
             if (Freshness::Fresh === $freshness) {
-                return $this->createResponseFromCache($cachedData, $method, $url, $options, $fullUrlTag);
+                return $this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag);
             }
 
             if (isset($cachedData['headers']['etag'])) {
@@ -213,7 +213,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
                         // avoid throwing exception in ErrorChunk#__destruct()
                         $chunk instanceof ErrorChunk && $chunk->didThrow(true);
                         $context->passthru();
-                        $context->replaceResponse($this->createResponseFromCache($cachedData, $method, $url, $options, $fullUrlTag));
+                        $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag));
 
                         return;
                     }
@@ -253,7 +253,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
                         }, \INF);
 
                         $context->passthru();
-                        $context->replaceResponse($this->createResponseFromCache($cachedData, $method, $url, $options, $fullUrlTag));
+                        $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag));
 
                         return;
                     }
@@ -261,7 +261,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
                     if ($statusCode >= 500 && $statusCode < 600) {
                         if (Freshness::StaleButUsable === $freshness) {
                             $context->passthru();
-                            $context->replaceResponse($this->createResponseFromCache($cachedData, $method, $url, $options, $fullUrlTag));
+                            $context->replaceResponse($this->createResponseFromCache($metadataKey, $cachedData, $method, $url, $options, $fullUrlTag));
 
                             return;
                         }
@@ -310,7 +310,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
 
                     $metadataKey = self::getMetadataKey($requestHash, $options['normalized_headers'], $varyFields);
                     $maxAge = $this->determineMaxAge($headers, $cacheControl);
-                    $chunkKey = "{$metadataKey}_chunk_".bin2hex(random_bytes(8));
+                    $chunkKey = str_replace('/', '_', base64_encode(random_bytes(6)));
 
                     $this->cache->get($metadataKey, static function (ItemInterface $item) use ($context, $headers, $maxAge, $expiresAt, $fullUrlTag, $chunkKey): array {
                         $item->tag($fullUrlTag)->expiresAt($expiresAt);
@@ -321,7 +321,7 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
                             'initial_age' => (int) ($headers['age'][0] ?? 0),
                             'stored_at' => time(),
                             'expires_at' => self::calculateExpiresAt($maxAge),
-                            'first_chunk' => $chunkKey,
+                            'next_chunk' => $chunkKey,
                         ];
                     }, \INF);
 
@@ -330,8 +330,15 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
                     return;
                 }
 
+                if (null === $chunkKey) {
+                    // informational chunks
+                    yield $chunk;
+
+                    return;
+                }
+
                 if ($chunk->isLast()) {
-                    $this->cache->get($chunkKey, static function (ItemInterface $item) use ($expiresAt, $fullUrlTag, $chunk): array {
+                    $this->cache->get($metadataKey.'_'.$chunkKey, static function (ItemInterface $item) use ($expiresAt, $fullUrlTag, $chunk): array {
                         $item->tag($fullUrlTag)->expiresAt($expiresAt);
 
                         return [
@@ -345,8 +352,8 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
                     return;
                 }
 
-                $nextChunkKey = "{$metadataKey}_chunk_".bin2hex(random_bytes(8));
-                $this->cache->get($chunkKey, static function (ItemInterface $item) use ($expiresAt, $fullUrlTag, $chunk, $nextChunkKey): array {
+                $nextChunkKey = str_replace('/', '_', base64_encode(random_bytes(6)));
+                $this->cache->get($metadataKey.'_'.$chunkKey, static function (ItemInterface $item) use ($expiresAt, $fullUrlTag, $chunk, $nextChunkKey): array {
                     $item->tag($fullUrlTag)->expiresAt($expiresAt);
 
                     return [
@@ -700,9 +707,9 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
      * response headers and content. The constructed MockResponse is then
      * returned.
      *
-     * @param array{first_chunk: string, status_code: int, initial_age: int, headers: array<string, string|string[]>, stored_at: int} $cachedData
+     * @param array{next_chunk: string, status_code: int, initial_age: int, headers: array<string, string|string[]>, stored_at: int} $cachedData
      */
-    private function createResponseFromCache(array $cachedData, string $method, string $url, array $options, string $fullUrlTag): MockResponse
+    private function createResponseFromCache(string $metadataKey, array $cachedData, string $method, string $url, array $options, string $fullUrlTag): MockResponse
     {
         $cache = $this->cache;
         $callback = static function (ItemInterface $item) use ($cache, $fullUrlTag): never {
@@ -710,13 +717,13 @@ class CachingHttpClient implements HttpClientInterface, ResetInterface
 
             throw new ChunkCacheItemNotFoundException(\sprintf('Missing cache item for chunk with key "%s". This indicates an internal cache inconsistency.', $item->getKey()));
         };
-        $body = static function () use ($cache, $cachedData, $callback): \Generator {
-            $chunk = $cache->get($cachedData['first_chunk'], $callback, 0);
+        $body = static function () use ($cache, $cachedData, $metadataKey, $callback): \Generator {
+            $chunk = $cache->get($metadataKey.'_'.$cachedData['next_chunk'], $callback, 0);
 
             yield $chunk['content'];
 
             while (null !== $chunk['next_chunk']) {
-                $chunk = $cache->get($chunk['next_chunk'], $callback, 0);
+                $chunk = $cache->get($metadataKey.'_'.$chunk['next_chunk'], $callback, 0);
 
                 if ('' !== $chunk['content']) {
                     yield $chunk['content'];

BUT, the latest iteration changed the concurrency model: now, 2nd concurrent requests will hit the cache, and will inevitably fail, because consuming from the cache should be much faster than populating it from the 1st request.

I was more comfortable with the previous model that committed to the cache only when the last chunk was reached. Also because that plays better with canceled requests.

BTW, this makes me realize that if an error happens in the middle of populating the chunks, we should invalidate them all I suppose.

@Lctrs Lctrs force-pushed the rfc9111-http-client branch from 11b5c42 to 6962aba Compare September 28, 2025 22:11
@Lctrs
Copy link
Contributor Author
Lctrs commented Sep 28, 2025

@nicolas-grekas I reverted to commit the cache on last chunk. I also tweaked the cases when we want to clear the cache.

@nicolas-grekas
Copy link
Member

Thank you @Lctrs.

@nicolas-grekas nicolas-grekas merged commit 9c413a3 into symfony:7.4 Sep 29, 2025
11 of 12 checks passed
@nicolas-grekas
Copy link
Member
nicolas-grekas commented Sep 29, 2025

Months of work, congrats for the achievement!

@Lctrs Lctrs deleted the rfc9111-http-client branch September 29, 2025 07:33
This was referenced Oct 27, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

8 participants

0