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

paulk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 4119b4ad01e6e59a1fce02dc6140a7d74891ce04
Author: Paul King <[email protected]>
AuthorDate: Tue Apr 14 07:07:19 2026 +1000

    GROOVY-11924: Provide a minimal declarative http client for the new http 
builder module (better align feature parity between declarative and imperative 
sides)
---
 .../src/main/groovy/groovy/http/HttpBuilder.groovy |  90 +++++-
 .../groovy/http/HttpBuilderClientTransform.groovy  |  95 +++++-
 .../groovy/groovy/http/HttpClientHelper.groovy     | 137 +++++++--
 .../http/{HttpBuilderClient.java => BodyText.java} |  27 +-
 .../http/{HttpBuilderClient.java => Form.java}     |  27 +-
 .../main/java/groovy/http/HttpBuilderClient.java   |   9 +
 .../http/{HttpBuilderClient.java => Timeout.java}  |  29 +-
 .../src/spec/doc/http-builder.adoc                 | 215 +++++++++++++-
 .../groovy/http/HttpBuilderClientTest.groovy       | 321 +++++++++++++++++++++
 9 files changed, 839 insertions(+), 111 deletions(-)

diff --git 
a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy
 
b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy
index 8e8ba1e009..51ae82124e 100644
--- 
a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy
+++ 
b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy
@@ -30,6 +30,7 @@ import java.net.http.HttpRequest
 import java.net.http.HttpResponse
 import java.nio.charset.StandardCharsets
 import java.time.Duration
+import java.util.concurrent.CompletableFuture
 
 /**
  * Tiny DSL over JDK {@link HttpClient}.
@@ -49,6 +50,12 @@ final class HttpBuilder {
         if (config.followRedirects) {
             clientBuilder.followRedirects(HttpClient.Redirect.NORMAL)
         }
+        if (config.clientConfigurer != null) {
+            Closure<?> code = (Closure<?>) config.clientConfigurer.clone()
+            code.resolveStrategy = Closure.DELEGATE_FIRST
+            code.delegate = clientBuilder
+            code.call(clientBuilder)
+        }
         client = clientBuilder.build()
         baseUri = config.baseUri
         defaultHeaders = Collections.unmodifiableMap(new 
LinkedHashMap<>(config.headers))
@@ -97,10 +104,69 @@ final class HttpBuilder {
         return request('DELETE', uri, spec)
     }
 
+    HttpResult patch(final Object uri = null,
+                     @DelegatesTo(value = RequestSpec, strategy = 
Closure.DELEGATE_FIRST)
+                     final Closure<?> spec = null) {
+        return request('PATCH', uri, spec)
+    }
+
     HttpResult request(final String method,
                        final Object uri,
                        @DelegatesTo(value = RequestSpec, strategy = 
Closure.DELEGATE_FIRST)
                        final Closure<?> spec = null) {
+        def (HttpRequest httpRequest, HttpResponse.BodyHandler<String> 
bodyHandler) = buildRequest(method, uri, spec)
+        HttpResponse<String> response
+        try {
+            response = client.send(httpRequest, bodyHandler)
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt()
+            throw new RuntimeException("HTTP request " + method + " " + 
httpRequest.uri() + " was interrupted", e)
+        } catch (IOException e) {
+            throw new RuntimeException("I/O error during HTTP request " + 
method + " " + httpRequest.uri(), e)
+        }
+        return new HttpResult(response)
+    }
+
+    CompletableFuture<HttpResult> requestAsync(final String method,
+                                                final Object uri,
+                                                @DelegatesTo(value = 
RequestSpec, strategy = Closure.DELEGATE_FIRST)
+                                                final Closure<?> spec = null) {
+        def (HttpRequest httpRequest, HttpResponse.BodyHandler<String> 
bodyHandler) = buildRequest(method, uri, spec)
+        return client.sendAsync(httpRequest, bodyHandler)
+                .thenApply { HttpResponse<String> response -> new 
HttpResult(response) }
+    }
+
+    CompletableFuture<HttpResult> getAsync(final Object uri = null,
+                                            @DelegatesTo(value = RequestSpec, 
strategy = Closure.DELEGATE_FIRST)
+                                            final Closure<?> spec = null) {
+        return requestAsync('GET', uri, spec)
+    }
+
+    CompletableFuture<HttpResult> postAsync(final Object uri = null,
+                                             @DelegatesTo(value = RequestSpec, 
strategy = Closure.DELEGATE_FIRST)
+                                             final Closure<?> spec = null) {
+        return requestAsync('POST', uri, spec)
+    }
+
+    CompletableFuture<HttpResult> putAsync(final Object uri = null,
+                                            @DelegatesTo(value = RequestSpec, 
strategy = Closure.DELEGATE_FIRST)
+                                            final Closure<?> spec = null) {
+        return requestAsync('PUT', uri, spec)
+    }
+
+    CompletableFuture<HttpResult> deleteAsync(final Object uri = null,
+                                               @DelegatesTo(value = 
RequestSpec, strategy = Closure.DELEGATE_FIRST)
+                                               final Closure<?> spec = null) {
+        return requestAsync('DELETE', uri, spec)
+    }
+
+    CompletableFuture<HttpResult> patchAsync(final Object uri = null,
+                                              @DelegatesTo(value = 
RequestSpec, strategy = Closure.DELEGATE_FIRST)
+                                              final Closure<?> spec = null) {
+        return requestAsync('PATCH', uri, spec)
+    }
+
+    private List buildRequest(final String method, final Object uri, final 
Closure<?> spec) {
         RequestSpec requestSpec = new RequestSpec()
         if (spec != null) {
             Closure<?> code = (Closure<?>) spec.clone()
@@ -126,16 +192,7 @@ final class HttpBuilder {
 
         requestBuilder.method(method, bodyPublisher(method, requestSpec.body))
 
-        HttpResponse<String> response
-        try {
-            response = client.send(requestBuilder.build(), 
requestSpec.bodyHandler)
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt()
-            throw new RuntimeException("HTTP request " + method + " " + 
resolvedUri + " was interrupted", e)
-        } catch (IOException e) {
-            throw new RuntimeException("I/O error during HTTP request " + 
method + " " + resolvedUri, e)
-        }
-        return new HttpResult(response)
+        return [requestBuilder.build(), requestSpec.bodyHandler]
     }
 
     private URI resolveUri(final Object uri, final Map<String, Object> query) {
@@ -215,6 +272,7 @@ final class HttpBuilder {
         Duration requestTimeout
         boolean followRedirects
         final Map<String, String> headers = [:]
+        Closure<?> clientConfigurer
 
         void baseUri(final Object value) {
             URI candidate = value instanceof URI ? (URI) value : 
URI.create(value.toString())
@@ -240,6 +298,18 @@ final class HttpBuilder {
         void headers(final Map<String, ?> values) {
             values.each { String name, Object value -> header(name, value) }
         }
+
+        /**
+         * Provides direct access to the underlying {@code HttpClient.Builder}
+         * for advanced configuration (authenticator, SSL context, proxy, 
cookie handler, etc.).
+         *
+         * @param configurer a closure taking an {@code HttpClient.Builder}
+         */
+        void clientConfig(
+                @DelegatesTo(value = HttpClient.Builder, strategy = 
Closure.DELEGATE_FIRST)
+                final Closure<?> configurer) {
+            this.clientConfigurer = configurer
+        }
     }
 
     static final class RequestSpec {
diff --git 
a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy
 
b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy
index 93818040df..f424c5ad03 100644
--- 
a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy
+++ 
b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy
@@ -71,18 +71,24 @@ class HttpBuilderClientTransform extends 
AbstractASTTransformation {
             return
         }
 
+        int connectTimeout = getMemberIntValue(anno, 'connectTimeout')
+        int requestTimeout = getMemberIntValue(anno, 'requestTimeout')
+        boolean followRedirects = memberHasValue(anno, 'followRedirects', true)
+
         // Collect interface-level @Header annotations
         Map<String, String> interfaceHeaders = collectHeaders(interfaceNode)
 
         // Generate the implementation class
-        ClassNode implClass = generateImplClass(interfaceNode, baseUrl, 
interfaceHeaders)
+        ClassNode implClass = generateImplClass(interfaceNode, baseUrl, 
interfaceHeaders,
+                connectTimeout, requestTimeout, followRedirects)
         source.AST.addClass(implClass)
 
         // Add static create() factory method to the interface
         addCreateMethod(interfaceNode, implClass, baseUrl)
     }
 
-    private ClassNode generateImplClass(ClassNode interfaceNode, String 
baseUrl, Map<String, String> interfaceHeaders) {
+    private ClassNode generateImplClass(ClassNode interfaceNode, String 
baseUrl, Map<String, String> interfaceHeaders,
+                                          int connectTimeout, int 
requestTimeout, boolean followRedirects) {
         String implName = interfaceNode.name + '$Client'
         ClassNode implClass = new ClassNode(implName, Opcodes.ACC_PUBLIC | 
Opcodes.ACC_SYNTHETIC,
                 OBJECT_TYPE, [interfaceNode.getPlainNodeReference()] as 
ClassNode[], null)
@@ -97,10 +103,27 @@ class HttpBuilderClientTransform extends 
AbstractASTTransformation {
         baseUrlParam.setInitialExpression(constX(baseUrl))
         BlockStatement ctorBody = block(
             assignS(fieldX(helperField),
-                ctorX(HELPER_TYPE, args(varX(baseUrlParam), 
buildHeadersMapExpression(interfaceHeaders))))
+                ctorX(HELPER_TYPE, args(
+                    varX(baseUrlParam),
+                    buildHeadersMapExpression(interfaceHeaders),
+                    constX(connectTimeout),
+                    constX(requestTimeout),
+                    constX(followRedirects)
+                )))
         )
         implClass.addConstructor(Opcodes.ACC_PUBLIC, params(baseUrlParam), 
ClassNode.EMPTY_ARRAY, ctorBody)
 
+        // Constructor: takes pre-built HttpBuilder (for create(Closure) 
factory)
+        Parameter httpBuilderParam = param(make(HttpBuilder), 'httpBuilder')
+        BlockStatement ctorBody2 = block(
+            assignS(fieldX(helperField),
+                ctorX(HELPER_TYPE, args(
+                    varX(httpBuilderParam),
+                    buildHeadersMapExpression(interfaceHeaders)
+                )))
+        )
+        implClass.addConstructor(Opcodes.ACC_PUBLIC, params(httpBuilderParam), 
ClassNode.EMPTY_ARRAY, ctorBody2)
+
         // Generate a method for each abstract interface method
         for (MethodNode method : interfaceNode.abstractMethods) {
             String httpMethod = null
@@ -121,8 +144,9 @@ class HttpBuilderClientTransform extends 
AbstractASTTransformation {
             }
 
             Map<String, String> methodHeaders = collectHeaders(method)
+            int methodTimeout = getMethodTimeout(method)
             MethodNode implMethod = generateMethod(method, httpMethod, 
urlTemplate,
-                    methodHeaders, helperField)
+                    methodHeaders, helperField, methodTimeout)
             implClass.addMethod(implMethod)
         }
 
@@ -130,14 +154,19 @@ class HttpBuilderClientTransform extends 
AbstractASTTransformation {
     }
 
     private MethodNode generateMethod(MethodNode method, String httpMethod, 
String urlTemplate,
-                                       Map<String, String> methodHeaders, 
FieldNode helperField) {
+                                       Map<String, String> methodHeaders, 
FieldNode helperField,
+                                       int methodTimeout) {
         Parameter[] params = method.parameters
         boolean isAsync = isAsyncReturn(method.returnType)
         String returnTypeName = resolveReturnTypeName(method.returnType)
+        boolean isForm = !method.getAnnotations(make(Form)).isEmpty()
+
+        // Determine body mode: 'json' (default), 'form', or 'text'
+        String bodyMode = isForm ? 'form' : 'json'
 
         // Build path params map: params whose names appear as {name} in the 
URL
         MapExpression pathParams = new MapExpression()
-        MapExpression queryParams = new MapExpression()
+        MapExpression queryOrFormParams = new MapExpression()
         Expression bodyExpr = constX(null)
 
         for (Parameter p : params) {
@@ -146,30 +175,42 @@ class HttpBuilderClientTransform extends 
AbstractASTTransformation {
                     new MapEntryExpression(constX(p.name), varX(p)))
             } else if (hasAnnotation(p, Body)) {
                 bodyExpr = varX(p)
+            } else if (hasAnnotation(p, BodyText)) {
+                bodyExpr = varX(p)
+                bodyMode = 'text'
             } else {
-                // Query parameter — use @Query name if specified, else param 
name
+                // Query parameter (or form field if @Form) — use @Query name 
if specified, else param name
                 String queryName = getQueryParamName(p)
-                queryParams.addMapEntryExpression(
+                queryOrFormParams.addMapEntryExpression(
                     new MapEntryExpression(constX(queryName), varX(p)))
             }
         }
 
+        // Error type from throws clause (first declared exception with 
HttpException-compatible constructor)
+        String errorTypeName = resolveErrorTypeName(method)
+
         String executeMethod = isAsync ? 'executeAsync' : 'execute'
         Expression callExpr = callX(fieldX(helperField), executeMethod, args(
             constX(httpMethod),
             constX(urlTemplate),
             constX(returnTypeName),
             pathParams,
-            queryParams,
+            queryOrFormParams,
             buildHeadersMapExpression(methodHeaders),
-            bodyExpr
+            bodyExpr,
+            constX(methodTimeout),
+            constX(bodyMode),
+            constX(errorTypeName)
         ))
 
         boolean isVoid = method.returnType == VOID_TYPE || 
method.returnType.name == 'void'
         Statement body = isVoid ? block(stmt(callExpr), returnS(constX(null))) 
: returnS(callExpr)
 
+        // Preserve declared exceptions on the generated method
+        ClassNode[] exceptions = method.exceptions ?: ClassNode.EMPTY_ARRAY
+
         return new MethodNode(method.name, Opcodes.ACC_PUBLIC, 
method.returnType,
-                cloneParams(params), ClassNode.EMPTY_ARRAY, body)
+                cloneParams(params), exceptions, body)
     }
 
     private void addCreateMethod(ClassNode interfaceNode, ClassNode implClass, 
String baseUrl) {
@@ -191,6 +232,19 @@ class HttpBuilderClientTransform extends 
AbstractASTTransformation {
                     params(baseUrlParam), ClassNode.EMPTY_ARRAY, withArgBody)
             interfaceNode.addMethod(createWithArg)
         }
+
+        // create(Closure config) — advanced configuration
+        // The closure delegates to HttpBuilder.Config, giving access to
+        // baseUri, headers, timeouts, redirects, clientConfig, etc.
+        ClassNode closureType = make(Closure).getPlainNodeReference()
+        Parameter configParam = param(closureType, 'config')
+        // HttpBuilder.http(config) returns an HttpBuilder; pass it to the 
HttpBuilder constructor
+        Expression httpBuilderExpr = callX(make(HttpBuilder), 'http', 
args(varX(configParam)))
+        Statement configBody = returnS(ctorX(implClass, args(httpBuilderExpr)))
+        MethodNode createWithConfig = new MethodNode('create',
+                Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, 
interfaceNode.getPlainNodeReference(),
+                params(configParam), ClassNode.EMPTY_ARRAY, configBody)
+        interfaceNode.addMethod(createWithConfig)
     }
 
     private static Map<String, String> collectHeaders(AnnotatedNode node) {
@@ -246,6 +300,25 @@ class HttpBuilderClientTransform extends 
AbstractASTTransformation {
         return p.name
     }
 
+    private static String resolveErrorTypeName(MethodNode method) {
+        ClassNode[] exceptions = method.exceptions
+        if (exceptions != null && exceptions.length > 0) {
+            return exceptions[0].name
+        }
+        return ''
+    }
+
+    private static int getMethodTimeout(MethodNode method) {
+        AnnotationNode timeoutAnno = 
method.getAnnotations(make(Timeout)).find()
+        if (timeoutAnno) {
+            Expression expr = timeoutAnno.getMember('value')
+            if (expr instanceof ConstantExpression) {
+                return ((Number) ((ConstantExpression) expr).value).intValue()
+            }
+        }
+        return 0
+    }
+
     private static boolean hasAnnotation(Parameter p, Class<?> annoType) {
         return !p.getAnnotations(make(annoType)).isEmpty()
     }
diff --git 
a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.groovy
 
b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.groovy
index 6a16845acc..64886e3b9f 100644
--- 
a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.groovy
+++ 
b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.groovy
@@ -20,6 +20,7 @@ package groovy.http
 
 import org.apache.groovy.lang.annotation.Incubating
 
+import java.time.Duration
 import java.util.concurrent.CompletableFuture
 
 /**
@@ -34,7 +35,27 @@ final class HttpClientHelper {
     private final Map<String, String> defaultHeaders
 
     HttpClientHelper(String baseUrl, Map<String, String> defaultHeaders) {
-        this.http = HttpBuilder.http(baseUrl)
+        this(baseUrl, defaultHeaders, 0, 0, false)
+    }
+
+    HttpClientHelper(String baseUrl, Map<String, String> defaultHeaders,
+                     int connectTimeoutSeconds, int requestTimeoutSeconds,
+                     boolean followRedirects) {
+        this.http = HttpBuilder.http {
+            baseUri(baseUrl)
+            if (connectTimeoutSeconds > 0) 
connectTimeout(Duration.ofSeconds(connectTimeoutSeconds))
+            if (requestTimeoutSeconds > 0) 
requestTimeout(Duration.ofSeconds(requestTimeoutSeconds))
+            if (followRedirects) delegate.followRedirects(true)
+        }
+        this.defaultHeaders = Collections.unmodifiableMap(new 
LinkedHashMap<>(defaultHeaders))
+    }
+
+    /**
+     * Constructor accepting a pre-configured HttpBuilder instance.
+     * Used by the create(Closure) factory for advanced configuration.
+     */
+    HttpClientHelper(HttpBuilder http, Map<String, String> defaultHeaders) {
+        this.http = http
         this.defaultHeaders = Collections.unmodifiableMap(new 
LinkedHashMap<>(defaultHeaders))
     }
 
@@ -51,16 +72,29 @@ final class HttpClientHelper {
      * @return the converted response
      */
     Object execute(String method, String urlTemplate, String returnTypeName,
-                   Map<String, Object> pathParams, Map<String, Object> 
queryParams,
-                   Map<String, String> headers, Object body) {
+                   Map<String, Object> pathParams, Map<String, Object> 
queryOrFormParams,
+                   Map<String, String> headers, Object body, int 
timeoutSeconds = 0,
+                   String bodyMode = 'json', String errorTypeName = '') {
         String url = resolveUrl(urlTemplate, pathParams)
+        boolean isForm = bodyMode == 'form'
         HttpResult result = http.request(method, url) {
             defaultHeaders.each { k, v -> header(k, v) }
             headers.each { k, v -> header(k, v) }
-            queryParams.each { k, v -> query(k, v) }
-            if (body != null) { json(body) }
+            if (isForm) {
+                form(queryOrFormParams)
+            } else {
+                queryOrFormParams.each { k, v -> query(k, v) }
+            }
+            if (timeoutSeconds > 0) timeout(Duration.ofSeconds(timeoutSeconds))
+            if (body != null) {
+                switch (bodyMode) {
+                    case 'text': text(body); break
+                    case 'form': break // already handled above
+                    default: json(body)
+                }
+            }
         }
-        return convertResult(result, returnTypeName)
+        return convertResult(result, returnTypeName, errorTypeName)
     }
 
     /**
@@ -69,10 +103,29 @@ final class HttpClientHelper {
      * @return a CompletableFuture containing the converted response
      */
     CompletableFuture<Object> executeAsync(String method, String urlTemplate, 
String returnTypeName,
-                                           Map<String, Object> pathParams, 
Map<String, Object> queryParams,
-                                           Map<String, String> headers, Object 
body) {
-        CompletableFuture.supplyAsync {
-            execute(method, urlTemplate, returnTypeName, pathParams, 
queryParams, headers, body)
+                                           Map<String, Object> pathParams, 
Map<String, Object> queryOrFormParams,
+                                           Map<String, String> headers, Object 
body, int timeoutSeconds = 0,
+                                           String bodyMode = 'json', String 
errorTypeName = '') {
+        String url = resolveUrl(urlTemplate, pathParams)
+        boolean isForm = bodyMode == 'form'
+        return http.requestAsync(method, url) {
+            defaultHeaders.each { k, v -> header(k, v) }
+            headers.each { k, v -> header(k, v) }
+            if (isForm) {
+                form(queryOrFormParams)
+            } else {
+                queryOrFormParams.each { k, v -> query(k, v) }
+            }
+            if (timeoutSeconds > 0) timeout(Duration.ofSeconds(timeoutSeconds))
+            if (body != null) {
+                switch (bodyMode) {
+                    case 'text': text(body); break
+                    case 'form': break
+                    default: json(body)
+                }
+            }
+        }.thenApply { HttpResult result ->
+            convertResult(result, returnTypeName, errorTypeName)
         }
     }
 
@@ -84,14 +137,64 @@ final class HttpClientHelper {
         url
     }
 
-    private static Object convertResult(HttpResult result, String 
returnTypeName) {
+    private static Object convertResult(HttpResult result, String 
returnTypeName, String errorTypeName) {
         if (result.status() >= 400) {
-            throw new RuntimeException("HTTP ${result.status()}: 
${result.body()}")
+            handleError(result, errorTypeName)
+        }
+        switch (returnTypeName) {
+            case 'void': return null
+            case String.name:
+            case 'java.lang.String': return result.body()
+            case HttpResult.name: return result
+            case 'groovy.xml.slurpersupport.GPathResult': return result.xml
+            case 'org.jsoup.nodes.Document': return result.html
+            case Map.name:
+            case 'java.util.Map':
+            case List.name:
+            case 'java.util.List':
+            case Object.name: return result.json
+            default:
+                // Typed response — parse JSON then coerce to target type
+                def json = result.json
+                try {
+                    return json.asType(Class.forName(returnTypeName))
+                } catch (Exception e) {
+                    return json // fallback: return raw parsed JSON
+                }
+        }
+    }
+
+    private static void handleError(HttpResult result, String errorTypeName) {
+        if (errorTypeName) {
+            try {
+                Class<?> errorType = Class.forName(errorTypeName)
+                Exception error = createError(errorType, result)
+                if (error != null) throw error
+            } catch (ClassNotFoundException ignored) {
+                // fall through to default
+            }
         }
-        if (returnTypeName == HttpResult.name) return result
-        if (returnTypeName == String.name || returnTypeName == 
'java.lang.String') return result.body()
-        if (returnTypeName == 'void') return null
-        // Map, List, Object — parse as JSON
-        return result.json
+        throw new RuntimeException("HTTP ${result.status()}: ${result.body()}")
+    }
+
+    private static Exception createError(Class<?> errorType, HttpResult 
result) {
+        String message = "HTTP ${result.status()}: ${result.body()}"
+        // Try constructor(int status, String body)
+        try {
+            return (Exception) errorType.getConstructor(int, 
String).newInstance(result.status(), result.body())
+        } catch (ReflectiveOperationException ignored) {}
+        // Try constructor(Integer status, String body)
+        try {
+            return (Exception) errorType.getConstructor(Integer, 
String).newInstance(result.status(), result.body())
+        } catch (ReflectiveOperationException ignored) {}
+        // Try constructor(String message)
+        try {
+            return (Exception) 
errorType.getConstructor(String).newInstance(message)
+        } catch (ReflectiveOperationException ignored) {}
+        // Try no-arg constructor
+        try {
+            return (Exception) errorType.getDeclaredConstructor().newInstance()
+        } catch (ReflectiveOperationException ignored) {}
+        return null
     }
 }
diff --git 
a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
 b/subprojects/groovy-http-builder/src/main/java/groovy/http/BodyText.java
similarity index 60%
copy from 
subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
copy to subprojects/groovy-http-builder/src/main/java/groovy/http/BodyText.java
index 4282a41929..1b31797b42 100644
--- 
a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
+++ b/subprojects/groovy-http-builder/src/main/java/groovy/http/BodyText.java
@@ -19,7 +19,6 @@
 package groovy.http;
 
 import org.apache.groovy.lang.annotation.Incubating;
-import org.codehaus.groovy.transform.GroovyASTTransformationClass;
 
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
@@ -27,29 +26,13 @@ import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
 /**
- * Marks an interface as a declarative HTTP client. An implementation class
- * is generated at compile time via AST transform, using {@link HttpBuilder}
- * for request execution.
- * <p>
- * Example:
- * <pre>
- * {@code @HttpBuilderClient}('https://api.example.com')
- * interface MyApi {
- *     {@code @Get}('/users/{id}')
- *     Map getUser(String id)
- * }
- *
- * def api = MyApi.create()
- * def user = api.getUser('123')
- * </pre>
+ * Marks a method parameter as a plain text request body.
+ * Unlike {@link Body}, the parameter is sent as-is without JSON serialization.
  *
  * @since 6.0.0
  */
 @Incubating
-@Retention(RetentionPolicy.SOURCE)
-@Target(ElementType.TYPE)
-@GroovyASTTransformationClass("groovy.http.HttpBuilderClientTransform")
-public @interface HttpBuilderClient {
-    /** The base URL for all requests. */
-    String value();
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.PARAMETER)
+public @interface BodyText {
 }
diff --git 
a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
 b/subprojects/groovy-http-builder/src/main/java/groovy/http/Form.java
similarity index 60%
copy from 
subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
copy to subprojects/groovy-http-builder/src/main/java/groovy/http/Form.java
index 4282a41929..bcbb96aacd 100644
--- 
a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
+++ b/subprojects/groovy-http-builder/src/main/java/groovy/http/Form.java
@@ -19,7 +19,6 @@
 package groovy.http;
 
 import org.apache.groovy.lang.annotation.Incubating;
-import org.codehaus.groovy.transform.GroovyASTTransformationClass;
 
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
@@ -27,29 +26,13 @@ import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
 /**
- * Marks an interface as a declarative HTTP client. An implementation class
- * is generated at compile time via AST transform, using {@link HttpBuilder}
- * for request execution.
- * <p>
- * Example:
- * <pre>
- * {@code @HttpBuilderClient}('https://api.example.com')
- * interface MyApi {
- *     {@code @Get}('/users/{id}')
- *     Map getUser(String id)
- * }
- *
- * def api = MyApi.create()
- * def user = api.getUser('123')
- * </pre>
+ * Marks a method as sending a form-encoded POST body.
+ * All non-path parameters become form fields instead of query parameters.
  *
  * @since 6.0.0
  */
 @Incubating
-@Retention(RetentionPolicy.SOURCE)
-@Target(ElementType.TYPE)
-@GroovyASTTransformationClass("groovy.http.HttpBuilderClientTransform")
-public @interface HttpBuilderClient {
-    /** The base URL for all requests. */
-    String value();
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface Form {
 }
diff --git 
a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
 
b/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
index 4282a41929..858639e76d 100644
--- 
a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
+++ 
b/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
@@ -52,4 +52,13 @@ import java.lang.annotation.Target;
 public @interface HttpBuilderClient {
     /** The base URL for all requests. */
     String value();
+
+    /** Connection timeout in seconds. Default 0 means no timeout. */
+    int connectTimeout() default 0;
+
+    /** Request timeout in seconds. Default 0 means no timeout. */
+    int requestTimeout() default 0;
+
+    /** Whether to follow HTTP redirects. Default is false. */
+    boolean followRedirects() default false;
 }
diff --git 
a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
 b/subprojects/groovy-http-builder/src/main/java/groovy/http/Timeout.java
similarity index 60%
copy from 
subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
copy to subprojects/groovy-http-builder/src/main/java/groovy/http/Timeout.java
index 4282a41929..17729ab3d6 100644
--- 
a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java
+++ b/subprojects/groovy-http-builder/src/main/java/groovy/http/Timeout.java
@@ -19,7 +19,6 @@
 package groovy.http;
 
 import org.apache.groovy.lang.annotation.Incubating;
-import org.codehaus.groovy.transform.GroovyASTTransformationClass;
 
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
@@ -27,29 +26,15 @@ import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
 /**
- * Marks an interface as a declarative HTTP client. An implementation class
- * is generated at compile time via AST transform, using {@link HttpBuilder}
- * for request execution.
- * <p>
- * Example:
- * <pre>
- * {@code @HttpBuilderClient}('https://api.example.com')
- * interface MyApi {
- *     {@code @Get}('/users/{id}')
- *     Map getUser(String id)
- * }
- *
- * def api = MyApi.create()
- * def user = api.getUser('123')
- * </pre>
+ * Overrides the request timeout for a specific method in an
+ * {@link HttpBuilderClient} interface. The value is in seconds.
  *
  * @since 6.0.0
  */
 @Incubating
-@Retention(RetentionPolicy.SOURCE)
-@Target(ElementType.TYPE)
-@GroovyASTTransformationClass("groovy.http.HttpBuilderClientTransform")
-public @interface HttpBuilderClient {
-    /** The base URL for all requests. */
-    String value();
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface Timeout {
+    /** Request timeout in seconds. */
+    int value();
 }
diff --git a/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc 
b/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc
index f97e748a66..cf483532c2 100644
--- a/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc
+++ b/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc
@@ -180,6 +180,62 @@ 
include::../test/HttpBuilderSpecTest.groovy[tags=html_login,indent=0]
 | raw string body
 |===
 
+== Advanced Client Configuration
+
+The `clientConfig` hook gives direct access to the JDK `HttpClient.Builder`
+for advanced configuration — authenticator, SSL context, proxy, cookie handler:
+
+[source,groovy]
+----
+def http = HttpBuilder.http {
+    baseUri 'https://api.example.com'
+    header 'Authorization', "Bearer ${token}"
+    clientConfig { builder ->
+        builder.authenticator(myAuthenticator)
+               .sslContext(mySSLContext)
+               .proxy(ProxySelector.of(new InetSocketAddress('proxy.corp', 
8080)))
+    }
+}
+----
+
+The `clientConfig` closure receives the `HttpClient.Builder` before `build()`
+is called, so any JDK-supported configuration is available.
+
+== Async Requests
+
+Every HTTP method has an async variant that returns a 
`CompletableFuture<HttpResult>`
+using the JDK `HttpClient.sendAsync()` natively (no extra threads):
+
+[source,groovy]
+----
+def http = HttpBuilder.http('https://api.example.com')
+
+def future = http.getAsync('/users/alice')
+// ... do other work while the request is in flight ...
+def result = future.get()
+assert result.json.name == 'alice'
+----
+
+Available methods: `getAsync`, `postAsync`, `putAsync`, `deleteAsync`,
+`patchAsync`, and the generic `requestAsync(method, uri, spec)`.
+
+These compose naturally with `CompletableFuture` methods:
+
+[source,groovy]
+----
+http.getAsync('/data')
+    .thenApply { it.json }
+    .thenAccept { data -> println "Got: $data" }
+----
+
+If the Groovy async module is on the classpath, these futures are
+automatically `await`-able:
+
+[source,groovy]
+----
+def result = await http.getAsync('/data')
+----
+
 == Declarative HTTP Clients
 
 For APIs with multiple endpoints, you can define a typed interface and let
@@ -209,33 +265,107 @@ def book = api.getBook('978-0-321-12521-7')
 ----
 
 The AST transform generates an implementation class that uses `HttpBuilder`
-under the hood. A `create()` factory method is added to the interface, with
-an overload `create(String baseUrl)` for overriding the base URL at runtime.
+under the hood. Three `create()` factory methods are added to the interface:
+
+- `create()` — uses the annotation URL and default settings
+- `create(String baseUrl)` — overrides the base URL at runtime
+- `create(Closure config)` — full control over the underlying `HttpBuilder`,
+  including base URL, headers, timeouts, redirects, and `clientConfig` for
+  JDK-level settings (authenticator, SSL, proxy)
+
+[source,groovy]
+----
+// Runtime auth token
+def api = MyApi.create {
+    baseUri 'https://api.example.com'
+    header 'Authorization', "Bearer ${token}"
+}
+
+// Full JDK client customization
+def api = MyApi.create {
+    baseUri 'https://internal.corp'
+    clientConfig { builder ->
+        builder.sslContext(mySSLContext)
+    }
+}
+----
 
 === Parameter Mapping
 
-Method parameters are mapped automatically:
+Method parameters are mapped automatically by convention — no annotation
+is needed for the common case:
 
 [cols="1,2"]
 |===
 | Condition | Mapping
 
 | Name matches `{placeholder}` in URL
-| Path variable — substituted into the URL
+| Path variable — substituted into the URL (URL-encoded)
 
-| `@Body` annotation
+| Annotated with `@Body`
 | Request body — serialized as JSON
 
-| `@Query` annotation (or no match)
-| Query parameter
+| Everything else
+| Query parameter — the parameter name is used as the query key
 
 |===
 
+The `@Query` annotation is only needed when the query parameter name differs
+from the method parameter name:
+
+[source,groovy]
+----
+@Get('/search')
+List search(String q)                    // ?q=...  (implied)
+
+@Get('/search')
+List search(@Query('q') String query)    // ?q=...  (explicit, different name)
+----
+
+=== HTTP Methods
+
+All standard HTTP methods are supported via annotations:
+`@Get`, `@Post`, `@Put`, `@Delete`, `@Patch`.
+
+[source,groovy]
+----
+@Patch('/items/{id}')
+Map patchItem(String id, @Body Map updates)
+----
+
 === Headers
 
 `@Header` annotations can be placed on the interface (applies to all methods)
 or on individual methods. Method-level headers are merged with interface-level 
headers.
 
+=== Timeouts and Redirects
+
+Connection timeout, default request timeout, and redirect following can be
+configured on the `@HttpBuilderClient` annotation:
+
+[source,groovy]
+----
+@HttpBuilderClient(value = 'https://api.example.com',
+                   connectTimeout = 5,
+                   requestTimeout = 10,
+                   followRedirects = true)
+interface MyApi {
+    @Get('/users/{id}')
+    Map getUser(String id)              // uses default 10s timeout
+
+    @Get('/reports/generate')
+    @Timeout(60)
+    Map generateReport()                // overrides to 60s for this method
+}
+----
+
+- `connectTimeout` -- how long to wait for the TCP connection (client-level, 
in seconds)
+- `requestTimeout` -- default request timeout applied to all methods (in 
seconds)
+- `@Timeout(value)` -- per-method override of the request timeout (in seconds)
+- `followRedirects` -- whether to follow HTTP redirects
+
+Default `0` means no timeout.
+
 === Return Types
 
 The return type of each method determines how the response is processed:
@@ -247,6 +377,15 @@ The return type of each method determines how the response 
is processed:
 | `Map` or `List`
 | Response parsed as JSON
 
+| Typed class (e.g. `User`)
+| Response parsed as JSON, then coerced to the target type
+
+| `GPathResult`
+| Response parsed as XML (via `XmlSlurper`)
+
+| jsoup `Document`
+| Response parsed as HTML (requires jsoup on classpath)
+
 | `String`
 | Raw response body
 
@@ -261,6 +400,68 @@ The return type of each method determines how the response 
is processed:
 
 |===
 
+For typed responses, the JSON is parsed and coerced to the target class
+using Groovy's `as` coercion:
+
+[source,groovy]
+----
+class User {
+    String name
+    String bio
+}
+
+@HttpBuilderClient('https://api.example.com')
+interface UserApi {
+    @Get('/users/{id}')
+    User getUser(String id)
+}
+----
+
+=== Request Bodies
+
+By default, `@Body` parameters are serialized as JSON. Additional body
+modes are available:
+
+[cols="1,2"]
+|===
+| Annotation | Behaviour
+
+| `@Body`
+| JSON body (default)
+
+| `@BodyText`
+| Plain text body (sent as-is)
+
+| `@Form` (on method)
+| All non-path parameters become form-encoded fields 
(`application/x-www-form-urlencoded`)
+
+|===
+
+[source,groovy]
+----
+@Post('/login')
+@Form
+Map login(String username, String password)    // form-encoded POST
+
+@Post('/notes')
+Map createNote(@BodyText String content)       // plain text body
+----
+
+=== Error Handling
+
+By default, HTTP 4xx/5xx responses throw a `RuntimeException`. You can
+declare a specific exception type in the method's `throws` clause, and
+the generated client will throw that type instead:
+
+[source,groovy]
+----
+@Get('/users/{id}')
+Map getUser(String id) throws NotFoundException
+----
+
+The exception class is instantiated by trying constructors in order:
+`(int status, String body)`, then `(String message)`, then no-arg.
+
 === Async
 
 Methods returning `CompletableFuture` execute asynchronously:
diff --git 
a/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderClientTest.groovy
 
b/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderClientTest.groovy
index 4448f20ce4..98c5a86fe6 100644
--- 
a/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderClientTest.groovy
+++ 
b/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderClientTest.groovy
@@ -75,11 +75,50 @@ class HttpBuilderClientTest {
                 respond(exchange, 200, body)
             } else if (method == 'DELETE' && path =~ '/items/\\d+') {
                 respond(exchange, 204, null)
+            } else if (method == 'PATCH' && path =~ '/items/\\d+') {
+                def body = new 
JsonSlurper().parseText(exchange.requestBody.text)
+                body.patched = true
+                respond(exchange, 200, body)
             } else {
                 respond(exchange, 404, [error: 'not found'])
             }
         }
 
+        server.createContext('/form-echo') { HttpExchange exchange ->
+            if (exchange.requestMethod == 'POST') {
+                String body = exchange.requestBody.text
+                // Parse form-encoded body
+                def params = body.split('&').collectEntries { String pair ->
+                    def parts = pair.split('=', 2)
+                    [(URLDecoder.decode(parts[0], 'UTF-8')): parts.length > 1 
? URLDecoder.decode(parts[1], 'UTF-8') : '']
+                }
+                respond(exchange, 200, params)
+            } else {
+                respond(exchange, 405, [error: 'method not allowed'])
+            }
+        }
+
+        server.createContext('/text-echo') { HttpExchange exchange ->
+            if (exchange.requestMethod == 'POST') {
+                String body = exchange.requestBody.text
+                respond(exchange, 200, [text: body])
+            } else {
+                respond(exchange, 405, [error: 'method not allowed'])
+            }
+        }
+
+        server.createContext('/xml-data') { HttpExchange exchange ->
+            exchange.responseHeaders.set('Content-Type', 'application/xml')
+            byte[] xml = 
'<root><name>Groovy</name><version>6</version></root>'.bytes
+            exchange.sendResponseHeaders(200, xml.length)
+            exchange.responseBody.write(xml)
+            exchange.close()
+        }
+
+        server.createContext('/not-found') { HttpExchange exchange ->
+            respond(exchange, 404, [error: 'resource not found', code: 404])
+        }
+
         server.start()
     }
 
@@ -226,4 +265,286 @@ class HttpBuilderClientTest {
             api.deleteItem('123')
         """
     }
+
+    @Test
+    void testImpliedQueryParams() {
+        // No @Query annotation needed — non-path, non-body params are implied 
query params
+        assertScript """
+            import groovy.http.*
+
+            @HttpBuilderClient('http://127.0.0.1:${port}')
+            interface ImpliedApi {
+                @Get('/users')
+                List searchUsers(String name)
+            }
+
+            def api = ImpliedApi.create()
+            def users = api.searchUsers('Alice')
+            assert users.size() == 1
+            assert users[0].name == 'Alice'
+        """
+    }
+
+    @Test
+    void testPatch() {
+        assertScript """
+            import groovy.http.*
+
+            @HttpBuilderClient('http://127.0.0.1:${port}')
+            interface PatchApi {
+                @Patch('/items/{id}')
+                Map patchItem(String id, @Body Map updates)
+            }
+
+            def api = PatchApi.create()
+            def result = api.patchItem('42', [name: 'Updated'])
+            assert result.name == 'Updated'
+            assert result.patched == true
+        """
+    }
+
+    @Test
+    void testPatchOnBuilder() {
+        def http = HttpBuilder.http("http://127.0.0.1:${port}";)
+        def result = http.patch('/items/42') {
+            json([name: 'Patched'])
+        }
+        assert result.json.patched == true
+    }
+
+    @Test
+    void testTimeoutAndRedirectConfig() {
+        // Verify that the timeout and redirect attributes compile and don't 
break anything
+        assertScript """
+            import groovy.http.*
+
+            @HttpBuilderClient(value = 'http://127.0.0.1:${port}',
+                               connectTimeout = 5,
+                               requestTimeout = 10,
+                               followRedirects = true)
+            interface ConfiguredApi {
+                @Get('/users/{username}')
+                Map getUser(String username)
+            }
+
+            def api = ConfiguredApi.create()
+            def user = api.getUser('alice')
+            assert user.name == 'alice'
+        """
+    }
+
+    @Test
+    void testFormPost() {
+        assertScript """
+            import groovy.http.*
+
+            @HttpBuilderClient('http://127.0.0.1:${port}')
+            interface FormApi {
+                @Post('/form-echo')
+                @Form
+                Map login(String username, String password)
+            }
+
+            def api = FormApi.create()
+            def result = api.login('alice', 's3cret')
+            assert result.username == 'alice'
+            assert result.password == 's3cret'
+        """
+    }
+
+    @Test
+    void testBodyText() {
+        assertScript """
+            import groovy.http.*
+
+            @HttpBuilderClient('http://127.0.0.1:${port}')
+            interface TextApi {
+                @Post('/text-echo')
+                Map sendText(@BodyText String content)
+            }
+
+            def api = TextApi.create()
+            def result = api.sendText('Hello, World!')
+            assert result.text.contains('Hello')
+        """
+    }
+
+    @Test
+    void testXmlResponse() {
+        assertScript """
+            import groovy.http.*
+            import groovy.xml.slurpersupport.GPathResult
+
+            @HttpBuilderClient('http://127.0.0.1:${port}')
+            interface XmlApi {
+                @Get('/xml-data')
+                GPathResult getData()
+            }
+
+            def api = XmlApi.create()
+            def xml = api.getData()
+            assert xml instanceof GPathResult
+            assert xml.name.text() == 'Groovy'
+            assert xml.version.text() == '6'
+        """
+    }
+
+    @Test
+    void testTypedResponse() {
+        assertScript """
+            import groovy.http.*
+
+            class UserInfo {
+                String name
+                String bio
+            }
+
+            @HttpBuilderClient('http://127.0.0.1:${port}')
+            interface TypedApi {
+                @Get('/users/{username}')
+                UserInfo getUser(String username)
+            }
+
+            def api = TypedApi.create()
+            def user = api.getUser('alice')
+            assert user instanceof UserInfo
+            assert user.name == 'alice'
+            assert user.bio == 'Bio of alice'
+        """
+    }
+
+    @Test
+    void testErrorMappingViaThrows() {
+        // Error mapping uses the throws clause to determine exception type.
+        // The exception class must be visible to the helper's classloader,
+        // so we test with a RuntimeException subclass (always loadable).
+        assertScript """
+            import groovy.http.*
+
+            @HttpBuilderClient('http://127.0.0.1:${port}')
+            interface ErrorApi {
+                @Get('/not-found')
+                Map getData() throws IllegalStateException
+            }
+
+            def api = ErrorApi.create()
+            try {
+                api.getData()
+                assert false, 'should have thrown'
+            } catch (IllegalStateException e) {
+                assert e.message.contains('404')
+            }
+        """
+    }
+
+    @Test
+    void testErrorDefaultsToRuntimeException() {
+        assertScript """
+            import groovy.http.*
+
+            @HttpBuilderClient('http://127.0.0.1:${port}')
+            interface DefaultErrorApi {
+                @Get('/not-found')
+                Map getData()
+            }
+
+            def api = DefaultErrorApi.create()
+            try {
+                api.getData()
+                assert false, 'should have thrown'
+            } catch (RuntimeException e) {
+                assert e.message.contains('404')
+            }
+        """
+    }
+
+    @Test
+    void testImperativeAsync() {
+        def http = HttpBuilder.http("http://127.0.0.1:${port}";)
+        def future = http.getAsync('/users/alice')
+        assert future instanceof java.util.concurrent.CompletableFuture
+        def result = future.get(5, java.util.concurrent.TimeUnit.SECONDS)
+        assert result.json.name == 'alice'
+    }
+
+    @Test
+    void testClientConfig() {
+        // Imperative: clientConfig gives access to HttpClient.Builder
+        def http = HttpBuilder.http {
+            baseUri "http://127.0.0.1:${port}";
+            clientConfig { builder ->
+                // Can set authenticator, SSL, proxy, etc.
+                
builder.followRedirects(java.net.http.HttpClient.Redirect.NORMAL)
+            }
+        }
+        def result = http.get('/users/alice')
+        assert result.json.name == 'alice'
+    }
+
+    @Test
+    void testDeclarativeCreateWithClosure() {
+        assertScript """
+            import groovy.http.*
+
+            @HttpBuilderClient('http://wrong-host:9999')
+            interface ConfigApi {
+                @Get('/users/{username}')
+                Map getUser(String username)
+            }
+
+            // create(Closure) overrides everything — base URL, headers, etc.
+            def api = ConfigApi.create {
+                baseUri 'http://127.0.0.1:${port}'
+                header 'X-Custom', 'from-closure'
+            }
+            def user = api.getUser('alice')
+            assert user.name == 'alice'
+        """
+    }
+
+    @Test
+    void testDeclarativeCreateWithAuth() {
+        assertScript """
+            import groovy.http.*
+
+            @HttpBuilderClient('http://127.0.0.1:${port}')
+            interface AuthApi {
+                @Get('/echo-headers')
+                Map echoHeaders()
+            }
+
+            def api = AuthApi.create {
+                baseUri 'http://127.0.0.1:${port}'
+                header 'Authorization', 'Bearer my-secret-token'
+            }
+            def headers = api.echoHeaders()
+            def lc = headers.collectKeys(String::toLowerCase)
+            assert lc['authorization'] == 'Bearer my-secret-token'
+        """
+    }
+
+    @Test
+    void testPerMethodTimeout() {
+        assertScript """
+            import groovy.http.*
+
+            @HttpBuilderClient(value = 'http://127.0.0.1:${port}',
+                               requestTimeout = 5)
+            interface TimeoutApi {
+                @Get('/users/{username}')
+                Map getUser(String username)              // uses default 5s
+
+                @Get('/users/{username}')
+                @Timeout(30)
+                Map getUserSlow(String username)           // overrides to 30s
+            }
+
+            def api = TimeoutApi.create()
+            def user = api.getUser('alice')
+            assert user.name == 'alice'
+
+            def slow = api.getUserSlow('bob')
+            assert slow.name == 'bob'
+        """
+    }
 }

Reply via email to