You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by ol...@apache.org on 2026/07/21 12:44:55 UTC

(httpcomponents-client) branch master updated (fbc962c10 -> b1defb311)

This is an automated email from the ASF dual-hosted git repository.

ok2c pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-client.git


    omit fbc962c10 HTTP cache support for the QUERY method (RFC 10008) (#852)
     new b1defb311 HTTP cache support for the QUERY method (RFC 10008) (#852)

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (fbc962c10)
            \
             N -- N -- N   refs/heads/master (b1defb311)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)


(httpcomponents-client) 01/01: HTTP cache support for the QUERY method (RFC 10008) (#852)

Posted by ol...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

ok2c pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-client.git

commit b1defb3113331aa11eece29d4504778e6a326304
Author: hunghhdev <hu...@gmail.com>
AuthorDate: Tue Jul 21 17:56:05 2026 +0700

    HTTP cache support for the QUERY method (RFC 10008) (#852)
    
    * HTTP cache support for the QUERY method (RFC 10008)
    
    * Do not buffer QUERY request content larger than 25 KB; require the enclosed entity to be repeatable
---
 README.md                                          |   1 +
 .../client5/http/impl/cache/AsyncCachingExec.java  |  90 ++++++++-
 .../client5/http/impl/cache/CacheKeyGenerator.java |  38 +++-
 .../http/impl/cache/CacheableRequestPolicy.java    |   2 +-
 .../hc/client5/http/impl/cache/CachingExec.java    |  30 ++-
 .../client5/http/impl/cache/CachingExecBase.java   |   7 +
 .../http/impl/cache/ResponseCachingPolicy.java     |   4 +-
 .../http/impl/cache/TestAsyncCachingExecQuery.java | 213 +++++++++++++++++++++
 .../http/impl/cache/TestCacheKeyGenerator.java     |  69 +++++++
 .../impl/cache/TestCacheableRequestPolicy.java     |  18 ++
 .../http/impl/cache/TestCachingExecChain.java      | 118 ++++++++++++
 .../http/impl/cache/TestResponseCachingPolicy.java |   7 +
 .../client5/http/examples/ClientQueryMethod.java   |  58 ++++++
 13 files changed, 638 insertions(+), 17 deletions(-)

diff --git a/README.md b/README.md
index 15bc97bb7..a1d08ca4e 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,7 @@ Protocol conformance
 - [RFC 2817](https://datatracker.ietf.org/doc/html/rfc2817) - Upgrading to TLS Within HTTP/1.1
 - [RFC 9218](https://datatracker.ietf.org/doc/html/rfc9218) - Extensible Prioritization Scheme for HTTP
 - [RFC 7804](https://datatracker.ietf.org/doc/html/rfc7804) - Salted Challenge Response HTTP Authentication Mechanism
+- [RFC 10008](https://datatracker.ietf.org/doc/html/rfc10008) - The HTTP QUERY Method
 
 Licensing
 ---------
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/AsyncCachingExec.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/AsyncCachingExec.java
index 6093a0f6d..a928ca75b 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/AsyncCachingExec.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/AsyncCachingExec.java
@@ -72,10 +72,12 @@
 import org.apache.hc.core5.http.HttpRequest;
 import org.apache.hc.core5.http.HttpResponse;
 import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Method;
 import org.apache.hc.core5.http.impl.BasicEntityDetails;
 import org.apache.hc.core5.http.nio.AsyncDataConsumer;
 import org.apache.hc.core5.http.nio.AsyncEntityProducer;
 import org.apache.hc.core5.http.nio.CapacityChannel;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
 import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityProducer;
 import org.apache.hc.core5.http.nio.entity.StringAsyncEntityProducer;
 import org.apache.hc.core5.net.URIAuthority;
@@ -412,12 +414,90 @@ public void cancelled() {
     }
 
     SimpleHttpRequest prepareRequest(final HttpRequest request,
-                                     final AsyncEntityProducer entityProducer) {
-        // To be revised when implementing QUERY support
-        if (entityProducer != null) {
-            return null;
+                                     final AsyncEntityProducer entityProducer) throws IOException {
+        if (entityProducer == null) {
+            return SimpleRequestBuilder.copy(request).build();
+        }
+        // QUERY content must be read in full in order to determine the cache key
+        if (Method.QUERY.isSame(request.getMethod())
+                && entityProducer.isRepeatable()
+                && entityProducer.getContentEncoding() == null
+                && entityProducer.getContentLength() <= MAX_BUFFERED_CONTENT_LENGTH) {
+            final byte[] content = drainEntityProducer(entityProducer);
+            if (content != null) {
+                final SimpleRequestBuilder builder = SimpleRequestBuilder.copy(request)
+                        .setBody(content, ContentType.parseLenient(entityProducer.getContentType()));
+                if (entityProducer.getContentType() != null) {
+                    builder.setHeader(HttpHeaders.CONTENT_TYPE, entityProducer.getContentType());
+                }
+                return builder.build();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Drains the given repeatable entity producer into a byte array without an I/O reactor.
+     * Returns {@code null} if the producer fails to make progress or produces more than
+     * {@link CachingExecBase#MAX_BUFFERED_CONTENT_LENGTH} bytes of content, in which case
+     * it is reset by {@link AsyncEntityProducer#releaseResources()} and can still be used
+     * to execute the request directly.
+     */
+    private static byte[] drainEntityProducer(final AsyncEntityProducer entityProducer) throws IOException {
+        final ByteArrayBuffer buf = new ByteArrayBuffer(1024);
+        final AtomicBoolean endStream = new AtomicBoolean();
+        final DataStreamChannel channel = new DataStreamChannel() {
+
+            @Override
+            public void requestOutput() {
+            }
+
+            @Override
+            public int write(final ByteBuffer src) {
+                final int len = src.remaining();
+                // Buffer at most one byte over the limit; the drain loop treats that as oversize
+                final int chunk = Math.min(len, MAX_BUFFERED_CONTENT_LENGTH + 1 - buf.length());
+                if (chunk > 0) {
+                    if (src.hasArray()) {
+                        buf.append(src.array(), src.arrayOffset() + src.position(), chunk);
+                    } else {
+                        final byte[] tmp = new byte[chunk];
+                        src.get(tmp);
+                        buf.append(tmp, 0, chunk);
+                    }
+                }
+                src.position(src.limit());
+                return len;
+            }
+
+            @Override
+            public void endStream() {
+                endStream.set(true);
+            }
+
+            @Override
+            public void endStream(final List<? extends Header> trailers) {
+                endStream.set(true);
+            }
+
+        };
+        try {
+            while (!endStream.get()) {
+                final int before = buf.length();
+                entityProducer.produce(channel);
+                if (buf.length() > MAX_BUFFERED_CONTENT_LENGTH) {
+                    return null;
+                }
+                if (!endStream.get() && buf.length() == before) {
+                    // The producer is not self-contained and cannot be drained
+                    // without an I/O reactor
+                    return null;
+                }
+            }
+        } finally {
+            entityProducer.releaseResources();
         }
-        return SimpleRequestBuilder.copy(request).build();
+        return buf.toByteArray();
     }
 
     void callChain(
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheKeyGenerator.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheKeyGenerator.java
index 171a420b0..2746f834f 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheKeyGenerator.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheKeyGenerator.java
@@ -29,6 +29,8 @@
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Iterator;
@@ -42,6 +44,7 @@
 import java.util.stream.StreamSupport;
 
 import org.apache.hc.client5.http.cache.HttpCacheEntry;
+import org.apache.hc.client5.http.utils.Hex;
 import org.apache.hc.core5.annotation.Contract;
 import org.apache.hc.core5.annotation.Internal;
 import org.apache.hc.core5.annotation.ThreadingBehavior;
@@ -151,6 +154,11 @@ public String generateKey(final HttpHost host, final HttpRequest request) {
     /**
      * Computes a root key for the given {@link HttpHost}, {@link HttpRequest} and
      * request body that can be used as a unique identifier for cached resources.
+     * <p>
+     * For requests with an enclosed body, such as {@literal QUERY}, the key also
+     * incorporates a digest of the request content and the value of the request
+     * {@literal Content-Type} header, as required by the HTTP QUERY method
+     * specification (RFC 10008).
      *
      * @param host The host for this request
      * @param request the {@link HttpRequest}
@@ -163,15 +171,37 @@ public <T extends HttpRequest> String generateKey(final HttpHost host,
                                                       final T request,
                                                       final Function<T, byte[]> bodyExtractor) {
         final String s = CacheSupport.requestUriRaw(host, request);
+        String rootKey;
+        try {
+            rootKey = generateKey(new URI(s));
+        } catch (final URISyntaxException ex) {
+            rootKey = s;
+        }
         final byte[] body = bodyExtractor != null ? bodyExtractor.apply(request) : null;
         if (body != null) {
-            throw new UnsupportedOperationException();
+            final Header contentType = request.getFirstHeader(HttpHeaders.CONTENT_TYPE);
+            return "{" + request.getMethod() + ":" + generateContentDigest(contentType, body) + "}" + rootKey;
         }
+        return rootKey;
+    }
+
+    /**
+     * Computes a digest of request content and its content type suitable
+     * for cache key generation.
+     */
+    static String generateContentDigest(final Header contentType, final byte[] content) {
+        final MessageDigest digester;
         try {
-            return generateKey(new URI(s));
-        } catch (final URISyntaxException ex) {
-            return s;
+            digester = MessageDigest.getInstance("SHA-256");
+        } catch (final NoSuchAlgorithmException ex) {
+            throw new IllegalStateException("SHA-256 message digest is not supported", ex);
+        }
+        if (contentType != null && contentType.getValue() != null) {
+            digester.update(contentType.getValue().getBytes(StandardCharsets.UTF_8));
         }
+        digester.update((byte) 0);
+        digester.update(content);
+        return Hex.encodeHexString(digester.digest());
     }
 
     /**
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java
index f1fbdb6a7..300622476 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CacheableRequestPolicy.java
@@ -57,7 +57,7 @@ public boolean canBeServedFromCache(final RequestCacheControl cacheControl, fina
             return false;
         }
 
-        if (!Method.GET.isSame(method) && !Method.HEAD.isSame(method)) {
+        if (!Method.GET.isSame(method) && !Method.HEAD.isSame(method) && !Method.QUERY.isSame(method)) {
             if (LOG.isDebugEnabled()) {
                 LOG.debug("{} request cannot be served from cache", method);
             }
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExec.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExec.java
index 24c1dadf9..1b745ffce 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExec.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExec.java
@@ -61,6 +61,7 @@
 import org.apache.hc.core5.http.HttpHost;
 import org.apache.hc.core5.http.HttpStatus;
 import org.apache.hc.core5.http.HttpVersion;
+import org.apache.hc.core5.http.Method;
 import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
 import org.apache.hc.core5.http.io.entity.EntityUtils;
 import org.apache.hc.core5.http.io.entity.StringEntity;
@@ -221,12 +222,31 @@ private static ClassicHttpResponse convert(final SimpleHttpResponse cacheRespons
         return response;
     }
 
-    SimpleHttpRequest prepareRequest(final ClassicHttpRequest request) {
-        // To be revised when implementing QUERY support
-        if (request.getEntity() != null) {
-            return null;
+    SimpleHttpRequest prepareRequest(final ClassicHttpRequest request) throws IOException {
+        final HttpEntity entity = request.getEntity();
+        if (entity == null) {
+            return SimpleRequestBuilder.copy(request).build();
+        }
+        // QUERY content must be read in full in order to determine the cache key
+        if (Method.QUERY.isSame(request.getMethod())
+                && entity.isRepeatable()
+                && entity.getContentEncoding() == null
+                && entity.getContentLength() <= MAX_BUFFERED_CONTENT_LENGTH) {
+            // Read one byte past the limit in order to distinguish an at-limit body from an oversized one
+            final byte[] content = EntityUtils.toByteArray(entity, MAX_BUFFERED_CONTENT_LENGTH + 1);
+            if (content != null && content.length > MAX_BUFFERED_CONTENT_LENGTH) {
+                return null;
+            }
+            final SimpleRequestBuilder builder = SimpleRequestBuilder.copy(request);
+            if (content != null) {
+                builder.setBody(content, ContentType.parseLenient(entity.getContentType()));
+                if (entity.getContentType() != null) {
+                    builder.setHeader(HttpHeaders.CONTENT_TYPE, entity.getContentType());
+                }
+            }
+            return builder.build();
         }
-        return SimpleRequestBuilder.copy(request).build();
+        return null;
     }
 
     ClassicHttpResponse callBackend(
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExecBase.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExecBase.java
index 5cef2ec22..53b6a92b6 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExecBase.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/CachingExecBase.java
@@ -42,6 +42,13 @@
 
 public class CachingExecBase {
 
+    /**
+     * Maximum size of a request content body that can be buffered in memory
+     * in order to compute a cache key. Requests with a larger content body
+     * are considered non-cacheable.
+     */
+    static final int MAX_BUFFERED_CONTENT_LENGTH = 25 * 1024;
+
     final AtomicLong cacheHits = new AtomicLong();
     final AtomicLong cacheMisses = new AtomicLong();
     final AtomicLong cacheUpdates = new AtomicLong();
diff --git a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java
index 81850a8fd..77b38a493 100644
--- a/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java
+++ b/httpclient5-cache/src/main/java/org/apache/hc/client5/http/impl/cache/ResponseCachingPolicy.java
@@ -106,9 +106,9 @@ public boolean isResponseCacheable(final RequestCacheControl requestCacheControl
             return false;
         }
 
-        // Presently only GET and HEAD methods are supported
+        // Presently only GET, HEAD and QUERY methods are supported
         final String httpMethod = request.getMethod();
-        if (!Method.GET.isSame(httpMethod) && !Method.HEAD.isSame(httpMethod)) {
+        if (!Method.GET.isSame(httpMethod) && !Method.HEAD.isSame(httpMethod) && !Method.QUERY.isSame(httpMethod)) {
             if (LOG.isDebugEnabled()) {
                 LOG.debug("{} method response is not cacheable", httpMethod);
             }
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestAsyncCachingExecQuery.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestAsyncCachingExecQuery.java
new file mode 100644
index 000000000..d50dc5ce9
--- /dev/null
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestAsyncCachingExecQuery.java
@@ -0,0 +1,213 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation.  For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+package org.apache.hc.client5.http.impl.cache;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Set;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.message.BasicHttpRequest;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+import org.apache.hc.core5.http.nio.DataStreamChannel;
+import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityProducer;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class TestAsyncCachingExecQuery {
+
+    private AsyncCachingExec impl;
+
+    @BeforeEach
+    void setUp() {
+        final HttpAsyncCache cache = new BasicHttpAsyncCache(
+                HeapResourceFactory.INSTANCE, new SimpleHttpAsyncCacheStorage());
+        impl = new AsyncCachingExec(cache, null, CacheConfig.DEFAULT);
+    }
+
+    @Test
+    void testPrepareRequestBuffersQueryContentFromRepeatableProducer() throws Exception {
+        final AsyncEntityProducer producer = new BasicAsyncEntityProducer(
+                "select a".getBytes(StandardCharsets.UTF_8), ContentType.TEXT_PLAIN);
+
+        final SimpleHttpRequest converted = impl.prepareRequest(
+                new BasicHttpRequest(Method.QUERY, "/stuff"), producer);
+
+        Assertions.assertNotNull(converted);
+        Assertions.assertNotNull(converted.getBody());
+        Assertions.assertEquals("select a",
+                new String(converted.getBody().getBodyBytes(), StandardCharsets.UTF_8));
+        Assertions.assertEquals(ContentType.TEXT_PLAIN.getMimeType(),
+                converted.getBody().getContentType().getMimeType());
+    }
+
+    @Test
+    void testPrepareRequestBypassesNonQueryRequestsWithContent() throws Exception {
+        final AsyncEntityProducer producer = new BasicAsyncEntityProducer(
+                "stuff".getBytes(StandardCharsets.UTF_8), ContentType.TEXT_PLAIN);
+
+        Assertions.assertNull(impl.prepareRequest(new BasicHttpRequest(Method.POST, "/stuff"), producer));
+    }
+
+    @Test
+    void testPrepareRequestBypassesQueryWithNonRepeatableProducer() throws Exception {
+        final StallingEntityProducer producer = new StallingEntityProducer(false);
+
+        Assertions.assertNull(impl.prepareRequest(new BasicHttpRequest(Method.QUERY, "/stuff"), producer));
+    }
+
+    @Test
+    void testPrepareRequestBypassesAndResetsStallingRepeatableProducer() throws Exception {
+        final StallingEntityProducer producer = new StallingEntityProducer(true);
+
+        Assertions.assertNull(impl.prepareRequest(new BasicHttpRequest(Method.QUERY, "/stuff"), producer));
+        Assertions.assertTrue(producer.released,
+                "a partially drained producer must be reset so that it can still be used");
+    }
+
+    @Test
+    void testPrepareRequestBypassesOversizedQueryContent() throws Exception {
+        final AsyncEntityProducer producer = new BasicAsyncEntityProducer(
+                new byte[CachingExecBase.MAX_BUFFERED_CONTENT_LENGTH + 1], ContentType.TEXT_PLAIN);
+
+        Assertions.assertNull(impl.prepareRequest(new BasicHttpRequest(Method.QUERY, "/stuff"), producer));
+    }
+
+    @Test
+    void testPrepareRequestBypassesAndResetsOversizedQueryContentOfUnknownLength() throws Exception {
+        final UnboundedEntityProducer producer = new UnboundedEntityProducer();
+
+        Assertions.assertNull(impl.prepareRequest(new BasicHttpRequest(Method.QUERY, "/stuff"), producer));
+        Assertions.assertTrue(producer.released,
+                "a partially drained producer must be reset so that it can still be used");
+    }
+
+    @Test
+    void testPrepareRequestBypassesOversizedQueryContentWrittenInSingleProduceCall() throws Exception {
+        final SingleWriteEntityProducer producer = new SingleWriteEntityProducer(
+                CachingExecBase.MAX_BUFFERED_CONTENT_LENGTH * 4);
+
+        Assertions.assertNull(impl.prepareRequest(new BasicHttpRequest(Method.QUERY, "/stuff"), producer));
+        Assertions.assertTrue(producer.released,
+                "a partially drained producer must be reset so that it can still be used");
+    }
+
+    static class StallingEntityProducer implements AsyncEntityProducer {
+
+        private final boolean repeatable;
+
+        boolean released;
+
+        StallingEntityProducer(final boolean repeatable) {
+            this.repeatable = repeatable;
+        }
+
+        @Override
+        public boolean isRepeatable() {
+            return repeatable;
+        }
+
+        @Override
+        public void failed(final Exception cause) {
+        }
+
+        @Override
+        public long getContentLength() {
+            return -1;
+        }
+
+        @Override
+        public String getContentType() {
+            return ContentType.TEXT_PLAIN.toString();
+        }
+
+        @Override
+        public String getContentEncoding() {
+            return null;
+        }
+
+        @Override
+        public boolean isChunked() {
+            return true;
+        }
+
+        @Override
+        public Set<String> getTrailerNames() {
+            return null;
+        }
+
+        @Override
+        public int available() {
+            return 0;
+        }
+
+        @Override
+        public void produce(final DataStreamChannel channel) throws IOException {
+        }
+
+        @Override
+        public void releaseResources() {
+            released = true;
+        }
+
+    }
+
+    static class UnboundedEntityProducer extends StallingEntityProducer {
+
+        UnboundedEntityProducer() {
+            super(true);
+        }
+
+        @Override
+        public void produce(final DataStreamChannel channel) throws IOException {
+            channel.write(ByteBuffer.wrap(new byte[8 * 1024]));
+        }
+
+    }
+
+    static class SingleWriteEntityProducer extends StallingEntityProducer {
+
+        private final int contentLength;
+
+        SingleWriteEntityProducer(final int contentLength) {
+            super(true);
+            this.contentLength = contentLength;
+        }
+
+        @Override
+        public void produce(final DataStreamChannel channel) throws IOException {
+            channel.write(ByteBuffer.wrap(new byte[contentLength]));
+            channel.endStream();
+        }
+
+    }
+
+}
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheKeyGenerator.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheKeyGenerator.java
index c4c5d99fa..e7c190554 100644
--- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheKeyGenerator.java
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheKeyGenerator.java
@@ -32,8 +32,11 @@
 import java.util.Iterator;
 import java.util.List;
 
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
 import org.apache.hc.client5.http.cache.HttpCacheEntry;
 import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.core5.http.ContentType;
 import org.apache.hc.core5.http.Header;
 import org.apache.hc.core5.http.HttpHeaders;
 import org.apache.hc.core5.http.HttpHost;
@@ -358,4 +361,70 @@ void testGetVariantKeyFromCachedResponse() {
         Assertions.assertEquals("{accept-encoding=text%2Fplain&user-agent=agent1}", extractor.generateVariantKey(request, entry2));
     }
 
+    @Test
+    void testQueryKeyIncorporatesRequestContent() {
+        final HttpHost host = new HttpHost("foo.example.com");
+        final SimpleHttpRequest query1 = SimpleRequestBuilder.query("http://foo.example.com/stuff")
+                .setBody("select a", ContentType.TEXT_PLAIN)
+                .setHeader(HttpHeaders.CONTENT_TYPE, ContentType.TEXT_PLAIN.toString())
+                .build();
+        final SimpleHttpRequest query2 = SimpleRequestBuilder.query("http://foo.example.com/stuff")
+                .setBody("select b", ContentType.TEXT_PLAIN)
+                .setHeader(HttpHeaders.CONTENT_TYPE, ContentType.TEXT_PLAIN.toString())
+                .build();
+        final SimpleHttpRequest query3 = SimpleRequestBuilder.query("http://foo.example.com/stuff")
+                .setBody("select a", ContentType.TEXT_PLAIN)
+                .setHeader(HttpHeaders.CONTENT_TYPE, ContentType.TEXT_PLAIN.toString())
+                .build();
+
+        Assertions.assertNotEquals(
+                extractor.generateKey(host, query1, SimpleHttpRequest::getBodyBytes),
+                extractor.generateKey(host, query2, SimpleHttpRequest::getBodyBytes));
+        Assertions.assertEquals(
+                extractor.generateKey(host, query1, SimpleHttpRequest::getBodyBytes),
+                extractor.generateKey(host, query3, SimpleHttpRequest::getBodyBytes));
+    }
+
+    @Test
+    void testQueryKeyIncorporatesContentType() {
+        final HttpHost host = new HttpHost("foo.example.com");
+        final SimpleHttpRequest query1 = SimpleRequestBuilder.query("http://foo.example.com/stuff")
+                .setBody("select a", ContentType.TEXT_PLAIN)
+                .setHeader(HttpHeaders.CONTENT_TYPE, ContentType.TEXT_PLAIN.toString())
+                .build();
+        final SimpleHttpRequest query2 = SimpleRequestBuilder.query("http://foo.example.com/stuff")
+                .setBody("select a", ContentType.APPLICATION_JSON)
+                .setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString())
+                .build();
+
+        Assertions.assertNotEquals(
+                extractor.generateKey(host, query1, SimpleHttpRequest::getBodyBytes),
+                extractor.generateKey(host, query2, SimpleHttpRequest::getBodyBytes));
+    }
+
+    @Test
+    void testQueryKeyDiffersFromUriKey() {
+        final HttpHost host = new HttpHost("foo.example.com");
+        final SimpleHttpRequest get = SimpleRequestBuilder.get("http://foo.example.com/stuff").build();
+        final SimpleHttpRequest query = SimpleRequestBuilder.query("http://foo.example.com/stuff")
+                .setBody("select a", ContentType.TEXT_PLAIN)
+                .setHeader(HttpHeaders.CONTENT_TYPE, ContentType.TEXT_PLAIN.toString())
+                .build();
+
+        Assertions.assertNotEquals(
+                extractor.generateKey(host, get, SimpleHttpRequest::getBodyBytes),
+                extractor.generateKey(host, query, SimpleHttpRequest::getBodyBytes));
+    }
+
+    @Test
+    void testQueryKeyWithoutContentMatchesUriKey() {
+        final HttpHost host = new HttpHost("foo.example.com");
+        final SimpleHttpRequest get = SimpleRequestBuilder.get("http://foo.example.com/stuff").build();
+        final SimpleHttpRequest query = SimpleRequestBuilder.query("http://foo.example.com/stuff").build();
+
+        Assertions.assertEquals(
+                extractor.generateKey(host, get, SimpleHttpRequest::getBodyBytes),
+                extractor.generateKey(host, query, SimpleHttpRequest::getBodyBytes));
+    }
+
 }
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheableRequestPolicy.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheableRequestPolicy.java
index 99b423e9a..655a742d6 100644
--- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheableRequestPolicy.java
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCacheableRequestPolicy.java
@@ -113,4 +113,22 @@ void testIsArbitraryMethodServableFromCache() {
 
     }
 
+    @Test
+    void testIsQueryServableFromCache() {
+        final BasicHttpRequest request = new BasicHttpRequest("QUERY", "someUri");
+        final RequestCacheControl cacheControl = RequestCacheControl.builder().build();
+
+        Assertions.assertTrue(policy.canBeServedFromCache(cacheControl, request));
+    }
+
+    @Test
+    void testIsQueryWithCacheControlServableFromCache() {
+        final BasicHttpRequest request = new BasicHttpRequest("QUERY", "someUri");
+        final RequestCacheControl cacheControl = RequestCacheControl.builder()
+                .setNoCache(true)
+                .build();
+
+        Assertions.assertFalse(policy.canBeServedFromCache(cacheControl, request));
+    }
+
 }
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java
index 61cc88570..2a05f7946 100644
--- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestCachingExecChain.java
@@ -29,6 +29,7 @@
 
 import static org.mockito.Mockito.mock;
 
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.SocketException;
@@ -55,12 +56,18 @@
 import org.apache.hc.client5.http.validator.ETag;
 import org.apache.hc.core5.http.ClassicHttpRequest;
 import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentType;
 import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
 import org.apache.hc.core5.http.HttpException;
 import org.apache.hc.core5.http.HttpHost;
 import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.Method;
+import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
 import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.entity.HttpEntityWrapper;
 import org.apache.hc.core5.http.io.entity.InputStreamEntity;
+import org.apache.hc.core5.http.io.entity.StringEntity;
 import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
 import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
 import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
@@ -1307,4 +1314,115 @@ void testNoCacheFieldsRevalidation() throws Exception {
         Mockito.verify(mockExecChain, Mockito.times(5)).proceed(Mockito.any(), Mockito.any());
     }
 
+    @Test
+    void testPrepareRequestBuffersQueryContent() throws Exception {
+        final ClassicHttpRequest query = new BasicClassicHttpRequest("QUERY", "/stuff");
+        query.setEntity(new StringEntity("select a", ContentType.TEXT_PLAIN));
+
+        final SimpleHttpRequest converted = impl.prepareRequest(query);
+
+        Assertions.assertNotNull(converted);
+        Assertions.assertNotNull(converted.getBody());
+        Assertions.assertEquals("select a", converted.getBody().getBodyText());
+        Assertions.assertEquals(ContentType.TEXT_PLAIN.getMimeType(),
+                converted.getBody().getContentType().getMimeType());
+    }
+
+    @Test
+    void testPrepareRequestBypassesNonQueryRequestsWithContent() throws Exception {
+        final ClassicHttpRequest post = new BasicClassicHttpRequest("POST", "/stuff");
+        post.setEntity(new StringEntity("stuff", ContentType.TEXT_PLAIN));
+
+        Assertions.assertNull(impl.prepareRequest(post));
+    }
+
+    @Test
+    void testPrepareRequestHandlesQueryEntityWithoutContent() throws Exception {
+        final HttpEntity entity = mock(HttpEntity.class);
+        Mockito.when(entity.isRepeatable()).thenReturn(true);
+        final ClassicHttpRequest query = new BasicClassicHttpRequest("QUERY", "/stuff");
+        query.setEntity(entity);
+
+        final SimpleHttpRequest converted = impl.prepareRequest(query);
+
+        Assertions.assertNotNull(converted);
+        Assertions.assertNull(converted.getBody());
+    }
+
+    @Test
+    void testPrepareRequestBypassesNonRepeatableQueryContent() throws Exception {
+        final ClassicHttpRequest query = new BasicClassicHttpRequest("QUERY", "/stuff");
+        query.setEntity(new InputStreamEntity(
+                new ByteArrayInputStream(new byte[16]), ContentType.TEXT_PLAIN));
+
+        Assertions.assertNull(impl.prepareRequest(query));
+    }
+
+    @Test
+    void testPrepareRequestBypassesOversizedQueryContent() throws Exception {
+        final ClassicHttpRequest query = new BasicClassicHttpRequest("QUERY", "/stuff");
+        query.setEntity(new ByteArrayEntity(
+                new byte[CachingExecBase.MAX_BUFFERED_CONTENT_LENGTH + 1], ContentType.TEXT_PLAIN));
+
+        Assertions.assertNull(impl.prepareRequest(query));
+    }
+
+    @Test
+    void testPrepareRequestBypassesOversizedQueryContentOfUnknownLength() throws Exception {
+        final ClassicHttpRequest query = new BasicClassicHttpRequest("QUERY", "/stuff");
+        query.setEntity(new HttpEntityWrapper(new ByteArrayEntity(
+                new byte[CachingExecBase.MAX_BUFFERED_CONTENT_LENGTH + 1], ContentType.TEXT_PLAIN)) {
+
+            @Override
+            public long getContentLength() {
+                return -1;
+            }
+
+        });
+
+        Assertions.assertNull(impl.prepareRequest(query));
+    }
+
+    private static ClassicHttpRequest makeQueryRequest(final String queryContent) {
+        final ClassicHttpRequest query = new BasicClassicHttpRequest("QUERY", "/stuff");
+        query.setEntity(new StringEntity(queryContent, ContentType.TEXT_PLAIN));
+        return query;
+    }
+
+    @Test
+    void testQueryResponseServedFromCache() throws Exception {
+        // The cache relies on QUERY being safe for entry invalidation purposes
+        Assertions.assertTrue(Method.QUERY.isSafe());
+
+        final ClassicHttpResponse resp1 = HttpTestUtils.make200Response();
+        resp1.setHeader("Cache-Control", "max-age=3600");
+
+        Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1);
+
+        final ClassicHttpResponse result1 = execute(makeQueryRequest("select a"));
+        EntityUtils.consume(result1.getEntity());
+        final ClassicHttpResponse result2 = execute(makeQueryRequest("select a"));
+
+        Assertions.assertEquals(HttpStatus.SC_OK, result2.getCode());
+        Mockito.verify(mockExecChain, Mockito.times(1)).proceed(Mockito.any(), Mockito.any());
+        Assertions.assertEquals(CacheResponseStatus.CACHE_HIT, context.getCacheResponseStatus());
+    }
+
+    @Test
+    void testQueryWithDifferentContentNotServedFromCache() throws Exception {
+        final ClassicHttpResponse resp1 = HttpTestUtils.make200Response();
+        resp1.setHeader("Cache-Control", "max-age=3600");
+        final ClassicHttpResponse resp2 = HttpTestUtils.make200Response();
+        resp2.setHeader("Cache-Control", "max-age=3600");
+
+        Mockito.when(mockExecChain.proceed(Mockito.any(), Mockito.any())).thenReturn(resp1, resp2);
+
+        final ClassicHttpResponse result1 = execute(makeQueryRequest("select a"));
+        EntityUtils.consume(result1.getEntity());
+        final ClassicHttpResponse result2 = execute(makeQueryRequest("select b"));
+        EntityUtils.consume(result2.getEntity());
+
+        Mockito.verify(mockExecChain, Mockito.times(2)).proceed(Mockito.any(), Mockito.any());
+    }
+
 }
diff --git a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java
index 8fdb3b684..12b0050e4 100644
--- a/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java
+++ b/httpclient5-cache/src/test/java/org/apache/hc/client5/http/impl/cache/TestResponseCachingPolicy.java
@@ -93,6 +93,13 @@ void testHeadCacheable() {
         Assertions.assertTrue(policy.isResponseCacheable(requestCacheControl, responseCacheControl, request, response));
     }
 
+    @Test
+    void testQueryCacheable() {
+        policy = new ResponseCachingPolicy(true, false, false);
+        request = new BasicHttpRequest(Method.QUERY, "/");
+        Assertions.assertTrue(policy.isResponseCacheable(requestCacheControl, responseCacheControl, request, response));
+    }
+
     @Test
     void testArbitraryMethodNotCacheable() {
         request = new BasicHttpRequest("PUT", "/");
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientQueryMethod.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientQueryMethod.java
new file mode 100644
index 000000000..7cbda480a
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientQueryMethod.java
@@ -0,0 +1,58 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation.  For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+package org.apache.hc.client5.http.examples;
+
+import org.apache.hc.client5.http.classic.methods.HttpQuery;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.message.StatusLine;
+
+/**
+ * Example of a safe and idempotent {@code QUERY} request (RFC 10008) with
+ * an enclosed request body.
+ */
+public class ClientQueryMethod {
+
+    public static void main(final String[] args) throws Exception {
+        try (final CloseableHttpClient httpclient = HttpClients.createDefault()) {
+            final HttpQuery httpQuery = new HttpQuery("http://httpbin.org/anything");
+            httpQuery.setEntity(new StringEntity("select a, b from things", ContentType.TEXT_PLAIN));
+
+            System.out.println("Executing request " + httpQuery.getMethod() + " " + httpQuery.getUri());
+            httpclient.execute(httpQuery, response -> {
+                System.out.println("----------------------------------------");
+                System.out.println(httpQuery + "->" + new StatusLine(response));
+                System.out.println(EntityUtils.toString(response.getEntity()));
+                return null;
+            });
+        }
+    }
+
+}