From 7dccab644b24661d8d3ac6276d65074c70cb0437 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Wed, 18 Dec 2024 23:09:07 +0900 Subject: [PATCH] poc --- .../client/AbstractWebClientBuilder.java | 12 +- .../armeria/client/ClientBuilder.java | 15 +++ .../armeria/client/ClientBuilderParams.java | 7 ++ .../client/ClientBuilderParamsBuilder.java | 117 ++++++++++++++++++ .../armeria/client/ClientFactory.java | 3 +- .../armeria/client/ClientPreprocessors.java | 4 + .../com/linecorp/armeria/client/Clients.java | 72 ++++++++++- .../client/DecoratingClientFactory.java | 17 +-- .../client/DefaultClientBuilderParams.java | 27 ++-- .../armeria/client/DefaultWebClient.java | 25 ++-- .../client/DefaultWebClientPreprocessor.java | 79 ++++++++++++ .../armeria/client/HttpClientDelegate.java | 13 ++ .../armeria/client/HttpClientFactory.java | 13 ++ .../client/PreClientRequestContext.java | 6 + .../armeria/client/RedirectingClient.java | 7 +- .../linecorp/armeria/client/RestClient.java | 30 +++++ .../armeria/client/RestClientBuilder.java | 4 + .../linecorp/armeria/client/WebClient.java | 29 +++++ .../armeria/client/WebClientBuilder.java | 4 + .../client/retry/RetryingRpcClient.java | 2 +- .../websocket/WebSocketClientBuilder.java | 7 +- .../armeria/common/SessionProtocol.java | 4 +- .../client/ClientBuilderParamsUtil.java | 99 +++++++++++++++ .../armeria/internal/client/ClientUtil.java | 3 +- .../internal/client/TailPreClient.java | 16 +++ .../client/endpoint/FailingEndpointGroup.java | 102 +++++++++++++++ .../common/NonWrappingRequestContext.java | 13 +- .../armeria/client/ClientBuilderTest.java | 19 +++ .../client/ClientFactoryBuilderTest.java | 18 +++ .../client/ClientMaxConnectionAgeTest.java | 8 +- .../armeria/client/DefaultWebClientTest.java | 105 ++++++++++++++++ .../armeria/client/DerivedClientTest.java | 43 ++++++- .../client/HttpClientNoKeepAliveTest.java | 2 +- .../client/HttpClientRequestPathTest.java | 12 +- .../armeria/client/HttpPreprocessorTest.java | 14 +++ .../armeria/client/PendingExceptionTest.java | 2 +- .../armeria/client/PreferHttp1Test.java | 2 +- .../armeria/client/RestClientTest.java | 102 +++++++++++---- .../WebClientAdditionalAuthorityTest.java | 3 +- .../armeria/client/WebClientBuilderTest.java | 22 ++++ .../common/KeepAliveMaxNumRequestsTest.java | 1 + .../armeria/server/HeadMethodLeakTest.java | 2 +- .../server/HttpServerNoKeepAliveTest.java | 2 +- .../client/eureka/EurekaEndpointGroup.java | 29 +++++ .../eureka/EurekaEndpointGroupBuilder.java | 4 + .../server/eureka/EurekaUpdatingListener.java | 30 +++++ .../eureka/EurekaUpdatingListenerBuilder.java | 5 + .../eureka/EurekaEndpointGroupTest.java | 14 +++ .../eureka/EurekaUpdatingListenerTest.java | 38 ++++-- .../grpc/protocol/UnaryGrpcClientFactory.java | 7 +- .../client/grpc/GrpcClientBuilder.java | 12 ++ .../armeria/client/grpc/GrpcClients.java | 34 +++++ .../client/grpc/GrpcClientFactory.java | 17 ++- .../client/grpc/GrpcClientBuilderTest.java | 106 ++++++++++++++++ .../armeria/client/grpc/GrpcClientTest.java | 10 ++ .../client/grpc/GrpcServicePathTest.java | 15 +++ .../client/retrofit2/ArmeriaRetrofit.java | 29 +++++ .../retrofit2/ArmeriaRetrofitBuilder.java | 11 +- .../retrofit2/ArmeriaCallFactoryTest.java | 38 +++++- .../client/scala/ScalaRestClientFactory.scala | 14 +-- .../client/thrift/ThriftClientBuilder.java | 12 ++ .../armeria/client/thrift/ThriftClients.java | 43 +++++++ .../client/thrift/THttpClientFactory.java | 20 ++- .../thrift/ThriftClientBuilderTest.java | 64 ++++++++++ .../thrift/ThriftClientExchangeTypeTest.java | 57 --------- .../client/thrift/ThriftClientTest.java | 109 ++++++++++++++++ 66 files changed, 1614 insertions(+), 191 deletions(-) create mode 100644 core/src/main/java/com/linecorp/armeria/client/ClientBuilderParamsBuilder.java create mode 100644 core/src/main/java/com/linecorp/armeria/client/DefaultWebClientPreprocessor.java create mode 100644 core/src/main/java/com/linecorp/armeria/internal/client/ClientBuilderParamsUtil.java create mode 100644 core/src/main/java/com/linecorp/armeria/internal/client/endpoint/FailingEndpointGroup.java delete mode 100644 thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientExchangeTypeTest.java create mode 100644 thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientTest.java diff --git a/core/src/main/java/com/linecorp/armeria/client/AbstractWebClientBuilder.java b/core/src/main/java/com/linecorp/armeria/client/AbstractWebClientBuilder.java index f75ff6ec13b..58261df1323 100644 --- a/core/src/main/java/com/linecorp/armeria/client/AbstractWebClientBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/AbstractWebClientBuilder.java @@ -17,6 +17,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.linecorp.armeria.common.SessionProtocol.httpAndHttpsValues; +import static com.linecorp.armeria.internal.client.ClientBuilderParamsUtil.preprocessorToUri; import static com.linecorp.armeria.internal.client.ClientUtil.UNDEFINED_URI; import static java.util.Objects.requireNonNull; @@ -28,6 +29,7 @@ import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.client.ClientBuilderParamsUtil; /** * A skeletal builder implementation for {@link WebClient}. @@ -72,6 +74,14 @@ protected AbstractWebClientBuilder(SessionProtocol sessionProtocol, EndpointGrou requireNonNull(endpointGroup, "endpointGroup"), path); } + /** + * Creates a new instance. + */ + protected AbstractWebClientBuilder(HttpPreprocessor httpPreprocessor, @Nullable String path) { + this(preprocessorToUri(httpPreprocessor, path), null, null, null); + preprocessor(httpPreprocessor); + } + /** * Creates a new instance. */ @@ -87,7 +97,7 @@ protected AbstractWebClientBuilder(@Nullable URI uri, @Nullable Scheme scheme, private static URI validateUri(URI uri) { requireNonNull(uri, "uri"); - if (Clients.isUndefinedUri(uri)) { + if (ClientBuilderParamsUtil.isInternalUri(uri)) { return uri; } final String givenScheme = requireNonNull(uri, "uri").getScheme(); diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientBuilder.java b/core/src/main/java/com/linecorp/armeria/client/ClientBuilder.java index f621681a9ec..3f8e84f26ef 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientBuilder.java @@ -29,6 +29,8 @@ import com.linecorp.armeria.client.redirect.RedirectConfig; import com.linecorp.armeria.common.RequestId; import com.linecorp.armeria.common.Scheme; +import com.linecorp.armeria.common.SerializationFormat; +import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.SuccessFunction; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; @@ -36,6 +38,7 @@ import com.linecorp.armeria.common.auth.BasicToken; import com.linecorp.armeria.common.auth.OAuth1aToken; import com.linecorp.armeria.common.auth.OAuth2Token; +import com.linecorp.armeria.internal.client.ClientBuilderParamsUtil; /** * Creates a new client that connects to the specified {@link URI} using the builder pattern. Use the factory @@ -95,6 +98,18 @@ public final class ClientBuilder extends AbstractClientOptionsBuilder { this.scheme = scheme; } + ClientBuilder(SerializationFormat serializationFormat, + ClientPreprocessors preprocessors, @Nullable String path) { + checkArgument(!preprocessors.isEmpty(), + "At least one preprocessor must be set in ClientPreprocessors."); + endpointGroup = null; + this.path = path; + scheme = Scheme.of(serializationFormat, SessionProtocol.UNDEFINED); + uri = ClientBuilderParamsUtil.preprocessorToUri(scheme, preprocessors, path); + preprocessors.preprocessors().forEach(this::preprocessor); + preprocessors.rpcPreprocessors().forEach(this::rpcPreprocessor); + } + /** * Returns a newly-created client which implements the specified {@code clientType}, based on the * properties of this builder. diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientBuilderParams.java b/core/src/main/java/com/linecorp/armeria/client/ClientBuilderParams.java index 3c85fc6f976..fdbe14abc37 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientBuilderParams.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientBuilderParams.java @@ -80,4 +80,11 @@ static ClientBuilderParams of(Scheme scheme, EndpointGroup endpointGroup, * Returns the options of the client. */ ClientOptions options(); + + /** + * TBU. + */ + default ClientBuilderParamsBuilder paramsBuilder() { + return new ClientBuilderParamsBuilder(this); + } } diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientBuilderParamsBuilder.java b/core/src/main/java/com/linecorp/armeria/client/ClientBuilderParamsBuilder.java new file mode 100644 index 00000000000..02925e3b1a1 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/ClientBuilderParamsBuilder.java @@ -0,0 +1,117 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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. + */ + +package com.linecorp.armeria.client; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static com.linecorp.armeria.internal.client.ClientBuilderParamsUtil.nullOrEmptyToSlash; + +import java.net.URI; +import java.net.URISyntaxException; + +import com.linecorp.armeria.common.Scheme; +import com.linecorp.armeria.common.SerializationFormat; +import com.linecorp.armeria.common.annotation.Nullable; + +/** + * TBU. + */ +public final class ClientBuilderParamsBuilder { + + private final ClientBuilderParams params; + + @Nullable + private SerializationFormat serializationFormat; + @Nullable + private String absolutePathRef; + @Nullable + private Class type; + @Nullable + private ClientOptions options; + + ClientBuilderParamsBuilder(ClientBuilderParams params) { + this.params = params; + } + + /** + * TBU. + */ + public ClientBuilderParamsBuilder serializationFormat(SerializationFormat serializationFormat) { + this.serializationFormat = serializationFormat; + return this; + } + + /** + * TBU. + */ + public ClientBuilderParamsBuilder absolutePathRef(String absolutePathRef) { + this.absolutePathRef = absolutePathRef; + return this; + } + + /** + * TBU. + */ + public ClientBuilderParamsBuilder clientType(Class type) { + this.type = type; + return this; + } + + /** + * TBU. + */ + public ClientBuilderParamsBuilder options(ClientOptions options) { + this.options = options; + return this; + } + + /** + * TBU. + */ + public ClientBuilderParams build() { + final Scheme scheme; + if (serializationFormat != null) { + scheme = Scheme.of(serializationFormat, params.scheme().sessionProtocol()); + } else { + scheme = params.scheme(); + } + final String schemeStr; + if (scheme.serializationFormat() == SerializationFormat.NONE) { + schemeStr = scheme.sessionProtocol().uriText(); + } else { + schemeStr = scheme.uriText(); + } + + final String path; + if (absolutePathRef != null) { + path = nullOrEmptyToSlash(absolutePathRef); + } else { + path = params.absolutePathRef(); + } + + final URI prevUri = params.uri(); + final URI uri; + try { + uri = new URI(schemeStr, prevUri.getRawAuthority(), path, + prevUri.getRawQuery(), prevUri.getRawFragment()); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + return new DefaultClientBuilderParams(scheme, params.endpointGroup(), uri.getRawPath(), + uri, firstNonNull(type, params.clientType()), + firstNonNull(options, params.options())); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientFactory.java b/core/src/main/java/com/linecorp/armeria/client/ClientFactory.java index dc0b203afd2..fa8202dabcb 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientFactory.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientFactory.java @@ -45,6 +45,7 @@ import com.linecorp.armeria.common.util.ReleasableHolder; import com.linecorp.armeria.common.util.ShutdownHooks; import com.linecorp.armeria.common.util.Unwrappable; +import com.linecorp.armeria.internal.client.ClientBuilderParamsUtil; import io.micrometer.core.instrument.MeterRegistry; import io.netty.channel.EventLoop; @@ -291,7 +292,7 @@ default ClientFactory unwrap() { default URI validateUri(URI uri) { requireNonNull(uri, "uri"); - if (Clients.isUndefinedUri(uri)) { + if (ClientBuilderParamsUtil.isInternalUri(uri)) { // We use a special singleton marker URI for clients that do not explicitly define a // host or scheme at construction time. // As this isn't created by users, we don't need to normalize it. diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientPreprocessors.java b/core/src/main/java/com/linecorp/armeria/client/ClientPreprocessors.java index e86f4310bbe..41f16a47ea6 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientPreprocessors.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientPreprocessors.java @@ -114,6 +114,10 @@ public RpcPreClient rpcDecorate(RpcPreClient execution) { return execution; } + boolean isEmpty() { + return preprocessors.isEmpty() && rpcPreprocessors.isEmpty(); + } + @Override public boolean equals(Object object) { if (this == object) { diff --git a/core/src/main/java/com/linecorp/armeria/client/Clients.java b/core/src/main/java/com/linecorp/armeria/client/Clients.java index 490ad660606..f7e53ca9691 100644 --- a/core/src/main/java/com/linecorp/armeria/client/Clients.java +++ b/core/src/main/java/com/linecorp/armeria/client/Clients.java @@ -15,7 +15,6 @@ */ package com.linecorp.armeria.client; -import static com.linecorp.armeria.internal.client.ClientUtil.UNDEFINED_URI; import static java.util.Objects.requireNonNull; import java.net.URI; @@ -35,7 +34,9 @@ import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.util.SafeCloseable; import com.linecorp.armeria.common.util.Unwrappable; +import com.linecorp.armeria.internal.client.ClientBuilderParamsUtil; import com.linecorp.armeria.internal.client.ClientThreadLocalState; +import com.linecorp.armeria.internal.client.ClientUtil; /** * Creates a new client that connects to a specified {@link URI}. @@ -172,6 +173,29 @@ public static T newClient(SessionProtocol protocol, EndpointGroup endpointGr return builder(protocol, endpointGroup, path).build(clientType); } + /** + * TBU. + */ + public static T newClient(ClientPreprocessors preprocessors, Class clientType) { + return builder(preprocessors).build(clientType); + } + + /** + * TBU. + */ + public static T newClient(SerializationFormat serializationFormat, ClientPreprocessors preprocessors, + Class clientType) { + return builder(serializationFormat, preprocessors).build(clientType); + } + + /** + * TBU. + */ + public static T newClient(SerializationFormat serializationFormat, ClientPreprocessors preprocessors, + Class clientType, String path) { + return builder(serializationFormat, preprocessors, path).build(clientType); + } + /** * Returns a new {@link ClientBuilder} that builds the client that connects to the specified {@code uri}. * @@ -252,6 +276,35 @@ public static ClientBuilder builder(Scheme scheme, EndpointGroup endpointGroup, return new ClientBuilder(scheme, endpointGroup, path); } + /** + * TBU. + */ + public static ClientBuilder builder(ClientPreprocessors preprocessors) { + requireNonNull(preprocessors, "preprocessors"); + return new ClientBuilder(SerializationFormat.NONE, preprocessors, null); + } + + /** + * TBU. + */ + public static ClientBuilder builder(SerializationFormat serializationFormat, + ClientPreprocessors preprocessors) { + requireNonNull(serializationFormat, "serializationFormat"); + requireNonNull(preprocessors, "preprocessors"); + return new ClientBuilder(serializationFormat, preprocessors, null); + } + + /** + * TBU. + */ + public static ClientBuilder builder(SerializationFormat serializationFormat, + ClientPreprocessors preprocessors, String path) { + requireNonNull(serializationFormat, "serializationFormat"); + requireNonNull(preprocessors, "preprocessors"); + requireNonNull(path, "path"); + return new ClientBuilder(serializationFormat, preprocessors, path); + } + /** * Creates a new derived client that connects to the same {@link URI} with the specified {@code client} * and the specified {@code additionalOptions}. @@ -271,12 +324,17 @@ public static T newDerivedClient(T client, ClientOptionValue... additiona * @see ClientBuilder ClientBuilder, for more information about how the base options and * additional options are merged when a derived client is created. */ + @SuppressWarnings("unchecked") public static T newDerivedClient(T client, Iterable> additionalOptions) { final ClientBuilderParams params = builderParams(client); - final ClientBuilder builder = newDerivedBuilder(params, true); - builder.options(additionalOptions); - - return newDerivedClient(builder, params.clientType()); + final ClientOptions newOptions = ClientOptions.builder() + .options(params.options()) + .options(additionalOptions) + .build(); + final ClientBuilderParams newParams = params.paramsBuilder() + .options(newOptions) + .build(); + return (T) newOptions.factory().newClient(newParams); } /** @@ -604,7 +662,9 @@ public static ClientRequestContextCaptor newContextCaptor() { * {@code isUndefinedUri(WebClient.of().uri())} will return {@code true}. */ public static boolean isUndefinedUri(URI uri) { - return uri == UNDEFINED_URI; + return (uri == ClientUtil.UNDEFINED_URI || + ClientBuilderParamsUtil.UNDEFINED_URI_AUTHORITY.equals(uri.getAuthority())) && + uri.getPort() == 1; } private Clients() {} diff --git a/core/src/main/java/com/linecorp/armeria/client/DecoratingClientFactory.java b/core/src/main/java/com/linecorp/armeria/client/DecoratingClientFactory.java index 5b083dc1da8..c3044389099 100644 --- a/core/src/main/java/com/linecorp/armeria/client/DecoratingClientFactory.java +++ b/core/src/main/java/com/linecorp/armeria/client/DecoratingClientFactory.java @@ -16,7 +16,6 @@ package com.linecorp.armeria.client; -import java.net.URI; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; @@ -51,17 +50,13 @@ protected DecoratingClientFactory(ClientFactory delegate) { * {@link SerializationFormat} are always {@code "/"} and {@link SerializationFormat#NONE}. */ protected final HttpClient newHttpClient(ClientBuilderParams params) { - final URI uri = params.uri(); - final ClientBuilderParams newParams; final ClientOptions newOptions = params.options().toBuilder().factory(unwrap()).build(); - if (Clients.isUndefinedUri(uri)) { - newParams = ClientBuilderParams.of(uri, HttpClient.class, newOptions); - } else { - final Scheme newScheme = Scheme.of(SerializationFormat.NONE, params.scheme().sessionProtocol()); - newParams = ClientBuilderParams.of(newScheme, params.endpointGroup(), - null, HttpClient.class, newOptions); - } - + final ClientBuilderParams newParams = + params.paramsBuilder() + .serializationFormat(SerializationFormat.NONE) + .options(newOptions) + .clientType(HttpClient.class) + .build(); return (HttpClient) unwrap().newClient(newParams); } diff --git a/core/src/main/java/com/linecorp/armeria/client/DefaultClientBuilderParams.java b/core/src/main/java/com/linecorp/armeria/client/DefaultClientBuilderParams.java index 28b6dcc8db8..d92c83edabc 100644 --- a/core/src/main/java/com/linecorp/armeria/client/DefaultClientBuilderParams.java +++ b/core/src/main/java/com/linecorp/armeria/client/DefaultClientBuilderParams.java @@ -15,18 +15,19 @@ */ package com.linecorp.armeria.client; -import static com.google.common.base.Preconditions.checkArgument; +import static com.linecorp.armeria.internal.client.ClientBuilderParamsUtil.nullOrEmptyToSlash; import static java.util.Objects.requireNonNull; import java.net.URI; import com.google.common.base.MoreObjects; -import com.google.common.base.Strings; import com.linecorp.armeria.client.endpoint.EndpointGroup; import com.linecorp.armeria.common.Scheme; import com.linecorp.armeria.common.SerializationFormat; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.client.ClientBuilderParamsUtil; +import com.linecorp.armeria.internal.client.endpoint.FailingEndpointGroup; import com.linecorp.armeria.internal.common.util.TemporaryThreadLocals; /** @@ -51,7 +52,11 @@ final class DefaultClientBuilderParams implements ClientBuilderParams { this.options = options; scheme = factory.validateScheme(Scheme.parse(uri.getScheme())); - endpointGroup = Endpoint.parse(uri.getRawAuthority()); + if (ClientBuilderParamsUtil.isInternalUri(uri)) { + endpointGroup = FailingEndpointGroup.of(); + } else { + endpointGroup = Endpoint.parse(uri.getRawAuthority()); + } try (TemporaryThreadLocals tempThreadLocals = TemporaryThreadLocals.acquire()) { final StringBuilder buf = tempThreadLocals.stringBuilder(); @@ -98,14 +103,14 @@ final class DefaultClientBuilderParams implements ClientBuilderParams { this.absolutePathRef = normalizedAbsolutePathRef; } - private static String nullOrEmptyToSlash(@Nullable String absolutePathRef) { - if (Strings.isNullOrEmpty(absolutePathRef)) { - return "/"; - } - - checkArgument(absolutePathRef.charAt(0) == '/', - "absolutePathRef: %s (must start with '/')", absolutePathRef); - return absolutePathRef; + DefaultClientBuilderParams(Scheme scheme, EndpointGroup endpointGroup, String absolutePathRef, + URI uri, Class type, ClientOptions options) { + this.scheme = options.factory().validateScheme(scheme); + this.endpointGroup = endpointGroup; + this.absolutePathRef = absolutePathRef; + this.uri = uri; + this.type = type; + this.options = options; } @Override diff --git a/core/src/main/java/com/linecorp/armeria/client/DefaultWebClient.java b/core/src/main/java/com/linecorp/armeria/client/DefaultWebClient.java index b013fb07e57..04c8b8312ee 100644 --- a/core/src/main/java/com/linecorp/armeria/client/DefaultWebClient.java +++ b/core/src/main/java/com/linecorp/armeria/client/DefaultWebClient.java @@ -83,22 +83,23 @@ public HttpResponse execute(HttpRequest req, RequestOptions requestOptions) { } else { scheme = req.scheme(); authority = req.authority(); + } - if (scheme == null || authority == null) { + if (authority != null) { + endpointGroup = Endpoint.parse(authority); + } else { + endpointGroup = EndpointGroup.of(); + } + if (scheme == null) { + protocol = SessionProtocol.UNDEFINED; + } else { + try { + protocol = Scheme.parse(scheme).sessionProtocol(); + } catch (Exception e) { return abortRequestAndReturnFailureResponse(req, new IllegalArgumentException( - "Scheme and authority must be specified in \":path\" or " + - "in \":scheme\" and \":authority\". :path=" + - originalPath + ", :scheme=" + req.scheme() + ", :authority=" + req.authority())); + "Failed to parse a scheme: " + reqTarget.scheme(), e)); } } - - endpointGroup = Endpoint.parse(authority); - try { - protocol = Scheme.parse(scheme).sessionProtocol(); - } catch (Exception e) { - return abortRequestAndReturnFailureResponse(req, new IllegalArgumentException( - "Failed to parse a scheme: " + reqTarget.scheme(), e)); - } } else { if (reqTarget.form() == RequestTargetForm.ABSOLUTE) { return abortRequestAndReturnFailureResponse(req, new IllegalArgumentException( diff --git a/core/src/main/java/com/linecorp/armeria/client/DefaultWebClientPreprocessor.java b/core/src/main/java/com/linecorp/armeria/client/DefaultWebClientPreprocessor.java new file mode 100644 index 00000000000..7083221864d --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/client/DefaultWebClientPreprocessor.java @@ -0,0 +1,79 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation 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: + * + * https://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. + */ + +package com.linecorp.armeria.client; + +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.RequestTarget; +import com.linecorp.armeria.common.RequestTargetForm; +import com.linecorp.armeria.common.Scheme; +import com.linecorp.armeria.common.SessionProtocol; + +final class DefaultWebClientPreprocessor implements HttpPreprocessor { + + static final DefaultWebClientPreprocessor INSTANCE = new DefaultWebClientPreprocessor(); + + @Override + public HttpResponse execute(PreClient delegate, + PreClientRequestContext ctx, HttpRequest req) throws Exception { + if (ctx.sessionProtocol() != SessionProtocol.UNDEFINED && ctx.endpointGroup() != null) { + // values are all already set, so no need to fill in values from the request/context + return delegate.execute(ctx, req); + } + + final RequestTarget reqTarget = ctx.requestTarget(); + + final String scheme; + final String authority; + if (reqTarget.form() == RequestTargetForm.ABSOLUTE) { + scheme = reqTarget.scheme(); + authority = reqTarget.authority(); + assert scheme != null; + assert authority != null; + } else { + scheme = req.scheme(); + authority = req.authority(); + + if (scheme == null || authority == null) { + return abortRequestAndReturnFailureResponse(req, new IllegalArgumentException( + "Scheme and authority must be specified in \":path\" or " + + "in \":scheme\" and \":authority\". :path=" + + req.path() + ", :scheme=" + req.scheme() + ", :authority=" + req.authority()), ctx); + } + } + + if (ctx.endpointGroup() == null) { + ctx.endpointGroup(Endpoint.parse(authority)); + } + if (ctx.sessionProtocol() == SessionProtocol.UNDEFINED) { + try { + ctx.sessionProtocol(Scheme.parse(scheme).sessionProtocol()); + } catch (Exception e) { + return abortRequestAndReturnFailureResponse(req, new IllegalArgumentException( + "Failed to parse a scheme: " + reqTarget.scheme(), e), ctx); + } + } + return delegate.execute(ctx, req); + } + + static HttpResponse abortRequestAndReturnFailureResponse( + HttpRequest req, IllegalArgumentException cause, ClientRequestContext ctx) { + req.abort(cause); + ctx.cancel(cause); + return HttpResponse.ofFailure(cause); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpClientDelegate.java b/core/src/main/java/com/linecorp/armeria/client/HttpClientDelegate.java index f3d60aea270..a411a590288 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpClientDelegate.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpClientDelegate.java @@ -26,6 +26,7 @@ import com.linecorp.armeria.client.proxy.HAProxyConfig; import com.linecorp.armeria.client.proxy.ProxyConfig; import com.linecorp.armeria.client.proxy.ProxyType; +import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.SerializationFormat; @@ -71,6 +72,18 @@ public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Ex "did you forget to call ctx.updateRequest() in your decorator?"), ctx); } + if (ctx.sessionProtocol() == SessionProtocol.UNDEFINED) { + return earlyFailedResponse( + new IllegalArgumentException( + "ctx.sessionProtocol() cannot be '" + ctx.sessionProtocol() + "'. " + + "It must be one of '" + SessionProtocol.httpAndHttpsValues() + "'."), ctx); + } + if (ctx.method() == HttpMethod.UNKNOWN) { + return earlyFailedResponse( + new IllegalArgumentException("ctx.method() cannot be '" + ctx.method() + + "'. It must be one of '" + HttpMethod.knownMethods() + "'."), + ctx); + } final Endpoint endpoint = ctx.endpoint(); if (endpoint == null) { diff --git a/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java b/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java index 35ad761e790..e87ff6abb78 100644 --- a/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java +++ b/core/src/main/java/com/linecorp/armeria/client/HttpClientFactory.java @@ -429,6 +429,19 @@ private static Class validateClientType(Class clientType) { return clientType; } + @Override + public ClientBuilderParams validateParams(ClientBuilderParams params) { + if (params.scheme().sessionProtocol() == SessionProtocol.UNDEFINED && + params.options().clientPreprocessors().preprocessors().isEmpty()) { + if (params.clientType() != HttpClient.class && !Clients.isUndefinedUri(params.uri())) { + throw new IllegalArgumentException( + "At least one preprocessor must be specified for http-based clients " + + "with sessionProtocol '" + params.scheme().sessionProtocol() + "'."); + } + } + return ClientFactory.super.validateParams(params); + } + @Override public boolean isClosing() { return closeable.isClosing(); diff --git a/core/src/main/java/com/linecorp/armeria/client/PreClientRequestContext.java b/core/src/main/java/com/linecorp/armeria/client/PreClientRequestContext.java index 2c3b4b9b4c0..165f2390262 100644 --- a/core/src/main/java/com/linecorp/armeria/client/PreClientRequestContext.java +++ b/core/src/main/java/com/linecorp/armeria/client/PreClientRequestContext.java @@ -18,6 +18,7 @@ import com.linecorp.armeria.client.endpoint.EndpointGroup; import com.linecorp.armeria.common.Request; +import com.linecorp.armeria.common.RequestTarget; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.annotation.UnstableApi; @@ -50,4 +51,9 @@ public interface PreClientRequestContext extends ClientRequestContext { * @see EventLoopScheduler */ void eventLoop(EventLoop eventLoop); + + /** + * TBU. + */ + RequestTarget requestTarget(); } diff --git a/core/src/main/java/com/linecorp/armeria/client/RedirectingClient.java b/core/src/main/java/com/linecorp/armeria/client/RedirectingClient.java index 073389bde43..3021d61e489 100644 --- a/core/src/main/java/com/linecorp/armeria/client/RedirectingClient.java +++ b/core/src/main/java/com/linecorp/armeria/client/RedirectingClient.java @@ -77,8 +77,9 @@ final class RedirectingClient extends SimpleDecoratingHttpClient { static Function newDecorator( ClientBuilderParams params, RedirectConfig redirectConfig) { final boolean undefinedUri = Clients.isUndefinedUri(params.uri()); + final boolean undefinedScheme = params.scheme().sessionProtocol() == SessionProtocol.UNDEFINED; final Set allowedProtocols = - allowedProtocols(undefinedUri, redirectConfig.allowedProtocols(), + allowedProtocols(undefinedScheme, redirectConfig.allowedProtocols(), params.scheme().sessionProtocol()); final BiPredicate domainFilter = domainFilter(undefinedUri, redirectConfig.domainFilter()); @@ -86,10 +87,10 @@ static Function newDecorator( redirectConfig.maxRedirects()); } - private static Set allowedProtocols(boolean undefinedUri, + private static Set allowedProtocols(boolean undefinedScheme, @Nullable Set allowedProtocols, SessionProtocol usedProtocol) { - if (undefinedUri) { + if (undefinedScheme) { if (allowedProtocols != null) { return allowedProtocols; } diff --git a/core/src/main/java/com/linecorp/armeria/client/RestClient.java b/core/src/main/java/com/linecorp/armeria/client/RestClient.java index caf0c3bef31..0332826843a 100644 --- a/core/src/main/java/com/linecorp/armeria/client/RestClient.java +++ b/core/src/main/java/com/linecorp/armeria/client/RestClient.java @@ -144,6 +144,20 @@ static RestClient of(SessionProtocol protocol, EndpointGroup endpointGroup, Stri return builder(protocol, endpointGroup, path).build(); } + /** + * TBU. + */ + static RestClient of(HttpPreprocessor httpPreprocessor) { + return builder(httpPreprocessor).build(); + } + + /** + * TBU. + */ + static RestClient of(HttpPreprocessor httpPreprocessor, String path) { + return builder(httpPreprocessor, path).build(); + } + /** * Returns a new {@link RestClientBuilder} created without a base {@link URI}. */ @@ -229,6 +243,22 @@ static RestClientBuilder builder(SessionProtocol protocol, EndpointGroup endpoin return new RestClientBuilder(protocol, endpointGroup, path); } + /** + * TBU. + */ + static RestClientBuilder builder(HttpPreprocessor httpPreprocessor) { + return new RestClientBuilder(httpPreprocessor, null); + } + + /** + * TBU. + */ + static RestClientBuilder builder(HttpPreprocessor httpPreprocessor, String path) { + requireNonNull(httpPreprocessor, "httpPreprocessor"); + requireNonNull(path, "path"); + return new RestClientBuilder(httpPreprocessor, path); + } + /** * Sets an {@link HttpMethod#GET} and the {@code path} and returns a fluent request builder. *
{@code
diff --git a/core/src/main/java/com/linecorp/armeria/client/RestClientBuilder.java b/core/src/main/java/com/linecorp/armeria/client/RestClientBuilder.java
index d13f0441f7a..399fe338173 100644
--- a/core/src/main/java/com/linecorp/armeria/client/RestClientBuilder.java
+++ b/core/src/main/java/com/linecorp/armeria/client/RestClientBuilder.java
@@ -70,6 +70,10 @@ public final class RestClientBuilder extends AbstractWebClientBuilder {
         super(sessionProtocol, endpointGroup, path);
     }
 
+    RestClientBuilder(HttpPreprocessor preprocessor, @Nullable String path) {
+        super(preprocessor, path);
+    }
+
     /**
      * Returns a newly-created web client based on the properties of this builder.
      *
diff --git a/core/src/main/java/com/linecorp/armeria/client/WebClient.java b/core/src/main/java/com/linecorp/armeria/client/WebClient.java
index c7a3ca8df9d..7427788e9e0 100644
--- a/core/src/main/java/com/linecorp/armeria/client/WebClient.java
+++ b/core/src/main/java/com/linecorp/armeria/client/WebClient.java
@@ -145,6 +145,20 @@ static WebClient of(SessionProtocol protocol, EndpointGroup endpointGroup, Strin
         return builder(protocol, endpointGroup, path).build();
     }
 
+    /**
+     * TBU.
+     */
+    static WebClient of(HttpPreprocessor httpPreprocessor) {
+        return builder(httpPreprocessor).build();
+    }
+
+    /**
+     * TBU.
+     */
+    static WebClient of(HttpPreprocessor httpPreprocessor, String path) {
+        return builder(httpPreprocessor, path).build();
+    }
+
     /**
      * Returns a new {@link WebClientBuilder} created without a base {@link URI}.
      */
@@ -230,6 +244,21 @@ static WebClientBuilder builder(SessionProtocol protocol, EndpointGroup endpoint
         return new WebClientBuilder(protocol, endpointGroup, path);
     }
 
+    /**
+     * TBU.
+     */
+    static WebClientBuilder builder(HttpPreprocessor httpPreprocessor) {
+        return new WebClientBuilder(httpPreprocessor, null);
+    }
+
+    /**
+     * TBU.
+     */
+    static WebClientBuilder builder(HttpPreprocessor httpPreprocessor, String path) {
+        return new WebClientBuilder(requireNonNull(httpPreprocessor, "httpPreprocessor"),
+                                    requireNonNull(path, "path"));
+    }
+
     /**
      * Sends the specified HTTP request.
      */
diff --git a/core/src/main/java/com/linecorp/armeria/client/WebClientBuilder.java b/core/src/main/java/com/linecorp/armeria/client/WebClientBuilder.java
index 1e9ec4ff4a8..6a36afce33e 100644
--- a/core/src/main/java/com/linecorp/armeria/client/WebClientBuilder.java
+++ b/core/src/main/java/com/linecorp/armeria/client/WebClientBuilder.java
@@ -66,6 +66,10 @@ public final class WebClientBuilder extends AbstractWebClientBuilder {
         super(sessionProtocol, endpointGroup, path);
     }
 
+    WebClientBuilder(HttpPreprocessor httpPreprocessor, @Nullable String path) {
+        super(httpPreprocessor, path);
+    }
+
     /**
      * Returns a newly-created web client based on the properties of this builder.
      *
diff --git a/core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java b/core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java
index 996e3550a9a..780249d7794 100644
--- a/core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java
+++ b/core/src/main/java/com/linecorp/armeria/client/retry/RetryingRpcClient.java
@@ -163,7 +163,7 @@ private void doExecute0(ClientRequestContext ctx, RpcRequest req,
             return;
         }
 
-        final ClientRequestContext derivedCtx = newDerivedContext(ctx, null, req, initialAttempt);
+        final ClientRequestContext derivedCtx = newDerivedContext(ctx, ctx.request(), req, initialAttempt);
 
         if (!initialAttempt) {
             derivedCtx.mutateAdditionalRequestHeaders(
diff --git a/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilder.java b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilder.java
index 8955165bdbf..dfeb09aad5d 100644
--- a/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilder.java
+++ b/core/src/main/java/com/linecorp/armeria/client/websocket/WebSocketClientBuilder.java
@@ -40,7 +40,6 @@
 import com.linecorp.armeria.client.ClientOptionValue;
 import com.linecorp.armeria.client.ClientOptions;
 import com.linecorp.armeria.client.ClientRequestContext;
-import com.linecorp.armeria.client.Clients;
 import com.linecorp.armeria.client.DecoratingHttpClientFunction;
 import com.linecorp.armeria.client.DecoratingRpcClientFunction;
 import com.linecorp.armeria.client.Endpoint;
@@ -65,6 +64,7 @@
 import com.linecorp.armeria.common.auth.OAuth1aToken;
 import com.linecorp.armeria.common.auth.OAuth2Token;
 import com.linecorp.armeria.common.websocket.WebSocketFrameType;
+import com.linecorp.armeria.internal.client.ClientBuilderParamsUtil;
 
 /**
  * Builds a {@link WebSocketClient}.
@@ -97,7 +97,7 @@ public final class WebSocketClientBuilder extends AbstractWebClientBuilder {
     }
 
     private static URI validateUri(URI uri) {
-        if (Clients.isUndefinedUri(uri)) {
+        if (ClientBuilderParamsUtil.isInternalUri(uri)) {
             return uri;
         }
         final String givenScheme = requireNonNull(uri, "uri").getScheme();
@@ -408,8 +408,7 @@ public WebSocketClientBuilder responseTimeoutMode(ResponseTimeoutMode responseTi
     @Override
     @Deprecated
     public WebSocketClientBuilder preprocessor(HttpPreprocessor preprocessor) {
-        throw new UnsupportedOperationException(
-                "RPC preprocessor cannot be added to the websocket client builder.");
+        throw new UnsupportedOperationException("WebSocketClientBuilder does not support preprocessor.");
     }
 
     @Override
diff --git a/core/src/main/java/com/linecorp/armeria/common/SessionProtocol.java b/core/src/main/java/com/linecorp/armeria/common/SessionProtocol.java
index 67412447ce2..008f4df42ed 100644
--- a/core/src/main/java/com/linecorp/armeria/common/SessionProtocol.java
+++ b/core/src/main/java/com/linecorp/armeria/common/SessionProtocol.java
@@ -60,7 +60,9 @@ public enum SessionProtocol {
     /**
      * PROXY protocol - v1 or v2.
      */
-    PROXY("proxy", false, false, 0);
+    PROXY("proxy", false, false, 0),
+
+    UNDEFINED("undefined", false, false, 0);
 
     private static final Set HTTP_VALUES = Sets.immutableEnumSet(HTTP, H1C, H2C);
 
diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/ClientBuilderParamsUtil.java b/core/src/main/java/com/linecorp/armeria/internal/client/ClientBuilderParamsUtil.java
new file mode 100644
index 00000000000..ce8679d43d1
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/internal/client/ClientBuilderParamsUtil.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2024 LINE Corporation
+ *
+ * LINE Corporation 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:
+ *
+ *   https://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.
+ */
+
+package com.linecorp.armeria.internal.client;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
+
+import java.net.URI;
+
+import com.google.common.base.Strings;
+
+import com.linecorp.armeria.client.ClientPreprocessors;
+import com.linecorp.armeria.client.Clients;
+import com.linecorp.armeria.client.Preprocessor;
+import com.linecorp.armeria.common.Scheme;
+import com.linecorp.armeria.common.SerializationFormat;
+import com.linecorp.armeria.common.SessionProtocol;
+import com.linecorp.armeria.common.annotation.Nullable;
+
+public final class ClientBuilderParamsUtil {
+
+    private static final String INTERNAL_PREFIX = "armeria-";
+    private static final String PREPROCESSOR_PREFIX = "armeria-preprocessor-";
+    public static final String UNDEFINED_URI_AUTHORITY = "armeria-undefined:1";
+
+    public static URI preprocessorToUri(Preprocessor preprocessor, @Nullable String absolutePathRef) {
+        return preprocessorToUri(Scheme.of(SerializationFormat.NONE, SessionProtocol.UNDEFINED),
+                                 preprocessor, absolutePathRef);
+    }
+
+    public static URI preprocessorToUri(Scheme scheme, ClientPreprocessors preprocessors,
+                                        @Nullable String absolutePathRef) {
+        return preprocessorToUri(scheme, generateHashCode(preprocessors), absolutePathRef);
+    }
+
+    public static URI preprocessorToUri(Scheme scheme, Preprocessor preprocessor,
+                                        @Nullable String absolutePathRef) {
+        return preprocessorToUri(scheme, generateHashCode(preprocessor), absolutePathRef);
+    }
+
+    private static URI preprocessorToUri(Scheme scheme, String hashCode, @Nullable String absolutePathRef) {
+        final String schemeStr;
+        if (scheme.serializationFormat() == SerializationFormat.NONE) {
+            schemeStr = scheme.sessionProtocol().uriText();
+        } else {
+            schemeStr = scheme.uriText();
+        }
+
+        final String normalizedAbsolutePathRef = nullOrEmptyToSlash(absolutePathRef);
+        return URI.create(schemeStr + "://" + PREPROCESSOR_PREFIX + hashCode +
+                          ":1" + normalizedAbsolutePathRef);
+    }
+
+    static String generateHashCode(Object obj) {
+        return Integer.toHexString(System.identityHashCode(requireNonNull(obj, "obj")));
+    }
+
+    public static String nullOrEmptyToSlash(@Nullable String absolutePathRef) {
+        if (Strings.isNullOrEmpty(absolutePathRef)) {
+            return "/";
+        }
+
+        checkArgument(absolutePathRef.charAt(0) == '/',
+                      "absolutePathRef: %s (must start with '/')", absolutePathRef);
+        return absolutePathRef;
+    }
+
+    /**
+     * Returns {@code true} if the specified {@code uri} was generated by armeria.
+     */
+    public static boolean isInternalUri(URI uri) {
+        if (Clients.isUndefinedUri(uri)) {
+            return true;
+        }
+        final String authority = uri.getAuthority();
+        return authority != null && authority.startsWith(INTERNAL_PREFIX) && uri.getPort() == 1;
+    }
+
+    public static boolean isPreprocessorUri(URI uri) {
+        final String authority = uri.getAuthority();
+        return authority != null && authority.startsWith(PREPROCESSOR_PREFIX) && uri.getPort() == 1;
+    }
+
+    private ClientBuilderParamsUtil() {}
+}
diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/ClientUtil.java b/core/src/main/java/com/linecorp/armeria/internal/client/ClientUtil.java
index 35df66717e9..d7e6e64c47a 100644
--- a/core/src/main/java/com/linecorp/armeria/internal/client/ClientUtil.java
+++ b/core/src/main/java/com/linecorp/armeria/internal/client/ClientUtil.java
@@ -49,7 +49,8 @@ public final class ClientUtil {
     /**
      * An undefined {@link URI} to create {@link WebClient} without specifying {@link URI}.
      */
-    public static final URI UNDEFINED_URI = URI.create("http://undefined");
+    public static final URI UNDEFINED_URI =
+            URI.create("undefined://" + ClientBuilderParamsUtil.UNDEFINED_URI_AUTHORITY);
 
     public static >
     O initContextAndExecuteWithFallback(
diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/TailPreClient.java b/core/src/main/java/com/linecorp/armeria/internal/client/TailPreClient.java
index d4572f5b322..f452743505f 100644
--- a/core/src/main/java/com/linecorp/armeria/internal/client/TailPreClient.java
+++ b/core/src/main/java/com/linecorp/armeria/internal/client/TailPreClient.java
@@ -28,12 +28,15 @@
 import com.linecorp.armeria.client.PreClientRequestContext;
 import com.linecorp.armeria.client.RpcClient;
 import com.linecorp.armeria.client.RpcPreClient;
+import com.linecorp.armeria.client.UnprocessedRequestException;
+import com.linecorp.armeria.common.HttpMethod;
 import com.linecorp.armeria.common.HttpRequest;
 import com.linecorp.armeria.common.HttpResponse;
 import com.linecorp.armeria.common.Request;
 import com.linecorp.armeria.common.Response;
 import com.linecorp.armeria.common.RpcRequest;
 import com.linecorp.armeria.common.RpcResponse;
+import com.linecorp.armeria.common.SessionProtocol;
 
 public final class TailPreClient implements PreClient {
 
@@ -69,6 +72,19 @@ public static RpcPreClient ofRpc(
 
     @Override
     public O execute(PreClientRequestContext ctx, I req) {
+        if (ctx.sessionProtocol() == SessionProtocol.UNDEFINED) {
+            final UnprocessedRequestException e = UnprocessedRequestException.of(
+                    new IllegalArgumentException(
+                            "ctx.sessionProtocol() cannot be '" + ctx.sessionProtocol() + "'. " +
+                            "It must be one of '" + SessionProtocol.httpAndHttpsValues() + "'."));
+            return errorResponseFactory.apply(ctx, e);
+        }
+        if (ctx.method() == HttpMethod.UNKNOWN) {
+            final UnprocessedRequestException e = UnprocessedRequestException.of(
+                    new IllegalArgumentException("ctx.method() cannot be '" + ctx.method() +
+                                                 "'. It must be one of '" + HttpMethod.knownMethods() + "'."));
+            return errorResponseFactory.apply(ctx, e);
+        }
         final ClientRequestContextExtension ctxExt = ctx.as(ClientRequestContextExtension.class);
         assert ctxExt != null;
         return ClientUtil.initContextAndExecuteWithFallback(delegate, ctxExt,
diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/FailingEndpointGroup.java b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/FailingEndpointGroup.java
new file mode 100644
index 00000000000..5b339bec318
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/internal/client/endpoint/FailingEndpointGroup.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024 LINE Corporation
+ *
+ * LINE Corporation 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:
+ *
+ *   https://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.
+ */
+
+package com.linecorp.armeria.internal.client.endpoint;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ScheduledExecutorService;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+
+import com.linecorp.armeria.client.ClientRequestContext;
+import com.linecorp.armeria.client.Endpoint;
+import com.linecorp.armeria.client.UnprocessedRequestException;
+import com.linecorp.armeria.client.endpoint.EndpointGroup;
+import com.linecorp.armeria.client.endpoint.EndpointSelectionStrategy;
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.common.util.UnmodifiableFuture;
+
+public final class FailingEndpointGroup implements EndpointGroup {
+
+    private static final FailingEndpointGroup INSTANCE =
+            new FailingEndpointGroup(new IllegalArgumentException(
+                    "An endpointGroup has not been specified. Specify an endpointGroup by " +
+                    "1) building a client with a URI or EndpointGroup e.g. 'WebClient.of(uri)', " +
+                    "2) sending a request with the authority 'client.execute(requestWithAuthority)', or " +
+                    "3) setting the endpointGroup directly inside a Preprocessor via 'ctx.endpointGroup()'."));
+
+    public static FailingEndpointGroup of() {
+        return INSTANCE;
+    }
+
+    private final RuntimeException exception;
+    private final CompletableFuture failedFuture;
+
+    private FailingEndpointGroup(Throwable throwable) {
+        exception = UnprocessedRequestException.of(throwable);
+        failedFuture = UnmodifiableFuture.exceptionallyCompletedFuture(exception);
+    }
+
+    @Override
+    public List endpoints() {
+        return ImmutableList.of();
+    }
+
+    @Override
+    public EndpointSelectionStrategy selectionStrategy() {
+        return EndpointSelectionStrategy.roundRobin();
+    }
+
+    @Override
+    @Nullable
+    public Endpoint selectNow(ClientRequestContext ctx) {
+        throw exception;
+    }
+
+    @Override
+    public CompletableFuture select(ClientRequestContext ctx, ScheduledExecutorService executor,
+                                              long timeoutMillis) {
+        return failedFuture;
+    }
+
+    @Override
+    public long selectionTimeoutMillis() {
+        return 0;
+    }
+
+    @Override
+    public CompletableFuture> whenReady() {
+        return UnmodifiableFuture.completedFuture(null);
+    }
+
+    @Override
+    public CompletableFuture closeAsync() {
+        return UnmodifiableFuture.completedFuture(null);
+    }
+
+    @Override
+    public void close() {
+    }
+
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+                          .add("exception", exception)
+                          .toString();
+    }
+}
diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/NonWrappingRequestContext.java b/core/src/main/java/com/linecorp/armeria/internal/common/NonWrappingRequestContext.java
index 61ec14b5e1b..da7ed466a8b 100644
--- a/core/src/main/java/com/linecorp/armeria/internal/common/NonWrappingRequestContext.java
+++ b/core/src/main/java/com/linecorp/armeria/internal/common/NonWrappingRequestContext.java
@@ -121,6 +121,7 @@ public final RpcRequest rpcRequest() {
     public final void updateRequest(HttpRequest req) {
         requireNonNull(req, "req");
         final RequestHeaders headers = req.headers();
+        final RequestTarget prevReqTarget = reqTarget;
         final RequestTarget reqTarget = validateHeaders(headers);
 
         if (reqTarget == null) {
@@ -130,9 +131,17 @@ public final void updateRequest(HttpRequest req) {
             throw new IllegalArgumentException("invalid path: " + headers.path() +
                                                " (must not contain scheme or authority)");
         }
+        if (prevReqTarget.form() == RequestTargetForm.ABSOLUTE) {
+            this.reqTarget = DefaultRequestTarget.createWithoutValidation(
+                    RequestTargetForm.ABSOLUTE, prevReqTarget.scheme(), prevReqTarget.authority(),
+                    prevReqTarget.host(), prevReqTarget.port(), reqTarget.path(),
+                    reqTarget.maybePathWithMatrixVariables(),
+                    reqTarget.rawPath(), reqTarget.query(), reqTarget.fragment());
+        } else {
+            this.reqTarget = reqTarget;
+        }
 
         this.req = req;
-        this.reqTarget = reqTarget;
         decodedPath = null;
     }
 
@@ -171,7 +180,7 @@ public final String path() {
         return reqTarget.path();
     }
 
-    protected final RequestTarget requestTarget() {
+    public final RequestTarget requestTarget() {
         return reqTarget;
     }
 
diff --git a/core/src/test/java/com/linecorp/armeria/client/ClientBuilderTest.java b/core/src/test/java/com/linecorp/armeria/client/ClientBuilderTest.java
index ec037f74eed..a06403f07e8 100644
--- a/core/src/test/java/com/linecorp/armeria/client/ClientBuilderTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/ClientBuilderTest.java
@@ -20,6 +20,11 @@
 
 import org.junit.jupiter.api.Test;
 
+import com.linecorp.armeria.common.SessionProtocol;
+import com.linecorp.armeria.internal.client.ClientBuilderParamsUtil;
+import com.linecorp.armeria.internal.client.endpoint.FailingEndpointGroup;
+import com.linecorp.armeria.internal.testing.ImmediateEventLoop;
+
 /**
  * Test for {@link ClientBuilder}.
  */
@@ -50,4 +55,18 @@ void endpointWithPath() {
                                         .build(WebClient.class);
         assertThat(client.uri().toString()).isEqualTo("http://127.0.0.1/foo");
     }
+
+    @Test
+    void preprocessor() {
+        final Endpoint endpoint = Endpoint.of("127.0.0.1");
+        final HttpPreprocessor preprocessor =
+                HttpPreprocessor.of(SessionProtocol.HTTP, endpoint, ImmediateEventLoop.INSTANCE);
+        final ClientPreprocessors preprocessors =
+                ClientPreprocessors.of(preprocessor);
+        final WebClient client = Clients.newClient(preprocessors, WebClient.class);
+        assertThat(Clients.isUndefinedUri(client.uri())).isFalse();
+        assertThat(ClientBuilderParamsUtil.isInternalUri(client.uri())).isTrue();
+        assertThat(client.endpointGroup()).isInstanceOf(FailingEndpointGroup.class);
+        assertThat(client.scheme().sessionProtocol()).isEqualTo(SessionProtocol.UNDEFINED);
+    }
 }
diff --git a/core/src/test/java/com/linecorp/armeria/client/ClientFactoryBuilderTest.java b/core/src/test/java/com/linecorp/armeria/client/ClientFactoryBuilderTest.java
index 48af198a48b..7ef594ffe54 100644
--- a/core/src/test/java/com/linecorp/armeria/client/ClientFactoryBuilderTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/ClientFactoryBuilderTest.java
@@ -35,6 +35,7 @@
 import org.junit.jupiter.params.provider.CsvSource;
 
 import com.linecorp.armeria.common.Flags;
+import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.common.util.TransportType;
 import com.linecorp.armeria.internal.common.util.MinifiedBouncyCastleProvider;
 
@@ -299,4 +300,21 @@ void defaultKeepAliveOnPingSet() {
             assertThat(factory.options().keepAliveOnPing()).isTrue();
         }
     }
+
+    @Test
+    void emptyProcessorValidation() {
+        final ClientPreprocessors preprocessors = ClientPreprocessors.of();
+        assertThatThrownBy(() -> Clients.newClient(preprocessors, WebClient.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one preprocessor must be set");
+    }
+
+    @Test
+    void undefinedUriClientNeedsOneHttpPreprocessor() {
+        final ClientPreprocessors preprocessors =
+                ClientPreprocessors.ofRpc(RpcPreprocessor.of(SessionProtocol.HTTP, Endpoint.of("1.2.3.4")));
+        assertThatThrownBy(() -> Clients.newClient(preprocessors, WebClient.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one preprocessor must be specified for http-based clients");
+    }
 }
diff --git a/core/src/test/java/com/linecorp/armeria/client/ClientMaxConnectionAgeTest.java b/core/src/test/java/com/linecorp/armeria/client/ClientMaxConnectionAgeTest.java
index 9c27038401a..c8853e16787 100644
--- a/core/src/test/java/com/linecorp/armeria/client/ClientMaxConnectionAgeTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/ClientMaxConnectionAgeTest.java
@@ -96,7 +96,7 @@ public void connectionClosed(SessionProtocol protocol, InetSocketAddress remoteA
         };
     }
 
-    @EnumSource(value = SessionProtocol.class, names = "PROXY", mode = Mode.EXCLUDE)
+    @EnumSource(value = SessionProtocol.class, names = {"PROXY", "UNDEFINED"}, mode = Mode.EXCLUDE)
     @ParameterizedTest
     void maxConnectionAge(SessionProtocol protocol) {
         final int maxClosedConnection = 5;
@@ -152,7 +152,7 @@ void maxConnectionAge(SessionProtocol protocol) {
         clientFactory.closeAsync();
     }
 
-    @EnumSource(value = SessionProtocol.class, names = "PROXY", mode = Mode.EXCLUDE)
+    @EnumSource(value = SessionProtocol.class, names = {"PROXY", "UNDEFINED"}, mode = Mode.EXCLUDE)
     @ParameterizedTest
     void shouldCloseIdleConnectionByMaxConnectionAge(SessionProtocol protocol) {
         try (ClientFactory factory = ClientFactory.builder()
@@ -172,7 +172,7 @@ void shouldCloseIdleConnectionByMaxConnectionAge(SessionProtocol protocol) {
         }
     }
 
-    @EnumSource(value = SessionProtocol.class, names = "PROXY", mode = Mode.EXCLUDE)
+    @EnumSource(value = SessionProtocol.class, names = {"PROXY", "UNDEFINED"}, mode = Mode.EXCLUDE)
     @ParameterizedTest
     void shouldCloseConnectionAfterLongRequest(SessionProtocol protocol) throws Exception {
         try (ClientFactory factory = ClientFactory.builder()
@@ -193,7 +193,7 @@ void shouldCloseConnectionAfterLongRequest(SessionProtocol protocol) throws Exce
         }
     }
 
-    @EnumSource(value = SessionProtocol.class, names = "PROXY", mode = Mode.EXCLUDE)
+    @EnumSource(value = SessionProtocol.class, names = {"PROXY", "UNDEFINED"}, mode = Mode.EXCLUDE)
     @ParameterizedTest
     void shouldCloseConnectionAfterLongRequestTimeout(SessionProtocol protocol) throws Exception {
         try (ClientFactory factory = ClientFactory.builder()
diff --git a/core/src/test/java/com/linecorp/armeria/client/DefaultWebClientTest.java b/core/src/test/java/com/linecorp/armeria/client/DefaultWebClientTest.java
index 013b0dfe196..20c75f2ec3f 100644
--- a/core/src/test/java/com/linecorp/armeria/client/DefaultWebClientTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/DefaultWebClientTest.java
@@ -15,17 +15,30 @@
  */
 package com.linecorp.armeria.client;
 
+import static com.linecorp.armeria.common.SessionProtocol.HTTP;
 import static com.linecorp.armeria.internal.client.ClientUtil.UNDEFINED_URI;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.awaitility.Awaitility.await;
 
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 
 import com.linecorp.armeria.client.endpoint.EndpointGroup;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
 import com.linecorp.armeria.common.HttpMethod;
 import com.linecorp.armeria.common.HttpRequest;
+import com.linecorp.armeria.common.HttpResponse;
 import com.linecorp.armeria.common.QueryParams;
 import com.linecorp.armeria.common.RequestHeaders;
+import com.linecorp.armeria.common.SessionProtocol;
+import com.linecorp.armeria.internal.testing.ImmediateEventLoop;
+
+import io.netty.channel.EventLoop;
 
 class DefaultWebClientTest {
 
@@ -130,4 +143,96 @@ void testWithQueryParams() {
             assertThat(captor.get().request().path()).isEqualTo("/helloWorld/test?q1=foo");
         }
     }
+
+    @ParameterizedTest
+    @CsvSource({
+            "/, HTTP, false",
+            "/, UNDEFINED, false",
+            "/prefix, HTTP, false",
+            "/prefix, UNDEFINED, false",
+            "/, HTTP, true",
+    })
+    void preprocessorBuilder(String prefix, SessionProtocol protocol, boolean isDefault) {
+        final Endpoint endpoint = Endpoint.of("127.0.0.1");
+        final EventLoop eventLoop = ImmediateEventLoop.INSTANCE;
+        final WebClientBuilder builder;
+        final HttpPreprocessor preprocessor = HttpPreprocessor.of(protocol, endpoint, eventLoop);
+        if (isDefault) {
+            builder = WebClient.builder().preprocessor(preprocessor);
+        } else if ("/".equals(prefix)) {
+            builder = WebClient.builder(preprocessor);
+        } else {
+            builder = WebClient.builder(preprocessor, prefix);
+        }
+
+        final WebClient client =
+                builder.decorator((delegate, ctx, req) -> {
+                           if ("/".equals(prefix)) {
+                               assertThat(req.path()).isEqualTo("/hello");
+                           } else {
+                               assertThat(req.path()).isEqualTo("/prefix/hello");
+                           }
+                           assertThat(ctx.sessionProtocol()).isEqualTo(protocol);
+                           assertThat(ctx.endpointGroup()).isEqualTo(endpoint);
+                           assertThat(ctx.eventLoop().withoutContext()).isEqualTo(eventLoop);
+                           return HttpResponse.of(200);
+                       })
+                       .build();
+        final CompletableFuture cf = client.get("/hello").aggregate();
+        if (SessionProtocol.httpAndHttpsValues().contains(protocol)) {
+            final AggregatedHttpResponse res = cf.join();
+            assertThat(res.status().code()).isEqualTo(200);
+        } else {
+            assertThatThrownBy(cf::join)
+                    .isInstanceOf(CompletionException.class)
+                    .cause()
+                    .isInstanceOf(UnprocessedRequestException.class)
+                    .cause()
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("ctx.sessionProtocol() cannot be 'undefined");
+        }
+    }
+
+    @Test
+    void ctorPreprocessorDerivation() {
+        final HttpPreprocessor http1 = HttpPreprocessor.of(HTTP, Endpoint.of("127.0.0.1", 8080));
+        final HttpPreprocessor http2 = HttpPreprocessor.of(HTTP, Endpoint.of("127.0.0.1", 8081));
+
+        ClientPreprocessors clientPreprocessors = WebClient.of().options().clientPreprocessors();
+        assertThat(clientPreprocessors.preprocessors()).isEmpty();
+        assertThat(clientPreprocessors.rpcPreprocessors()).isEmpty();
+
+        clientPreprocessors = WebClient.builder().preprocessor(http1).build().options().clientPreprocessors();
+        assertThat(clientPreprocessors.preprocessors()).containsExactly(http1);
+        assertThat(clientPreprocessors.rpcPreprocessors()).isEmpty();
+
+        clientPreprocessors = WebClient.of(http1).options().clientPreprocessors();
+        assertThat(clientPreprocessors.preprocessors()).containsExactly(http1);
+        assertThat(clientPreprocessors.rpcPreprocessors()).isEmpty();
+
+        clientPreprocessors = WebClient.builder(http1).preprocessor(http2).build()
+                                       .options().clientPreprocessors();
+        assertThat(clientPreprocessors.preprocessors()).containsExactly(http1, http2);
+        assertThat(clientPreprocessors.rpcPreprocessors()).isEmpty();
+    }
+
+    @Test
+    void rpcPreprocessorNotAllowed() {
+        assertThatThrownBy(() -> WebClient.builder().rpcPreprocessor(
+                RpcPreprocessor.of(HTTP, Endpoint.of("127.0.0.1"))))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("RPC preprocessor cannot be added");
+    }
+
+    @Test
+    void exceptionsAreHandled() {
+        final RuntimeException exception = new RuntimeException();
+        final WebClient webClient = WebClient.of((delegate, ctx, req) -> {
+            throw exception;
+        });
+        final CompletableFuture cf = webClient.get("/hello").aggregate();
+        assertThatThrownBy(cf::join).isInstanceOf(CompletionException.class)
+                                    .cause()
+                                    .isSameAs(exception);
+    }
 }
diff --git a/core/src/test/java/com/linecorp/armeria/client/DerivedClientTest.java b/core/src/test/java/com/linecorp/armeria/client/DerivedClientTest.java
index 0a1f0cc4ee0..bd4a7b9fa44 100644
--- a/core/src/test/java/com/linecorp/armeria/client/DerivedClientTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/DerivedClientTest.java
@@ -17,12 +17,18 @@
 package com.linecorp.armeria.client;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.linecorp.armeria.common.SessionProtocol.HTTP;
+import static com.linecorp.armeria.common.SessionProtocol.HTTPS;
 import static org.assertj.core.api.Assertions.assertThat;
 
 import java.util.List;
+import java.util.stream.Stream;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import com.google.common.collect.ImmutableList;
 
@@ -30,7 +36,6 @@
 import com.linecorp.armeria.common.HttpHeaders;
 import com.linecorp.armeria.common.HttpResponse;
 import com.linecorp.armeria.common.HttpStatus;
-import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.common.SuccessFunction;
 import com.linecorp.armeria.server.ServerBuilder;
 import com.linecorp.armeria.testing.junit5.server.ServerExtension;
@@ -85,7 +90,7 @@ void shouldCopyEndpoint() throws InterruptedException {
                           return Endpoint.of("127.0.0.1", serverPort.localAddress().getPort());
                       }).collect(toImmutableList());
         final EndpointGroup endpointGroup = EndpointGroup.of(endpoints);
-        final BlockingWebClient client = WebClient.builder(SessionProtocol.HTTP, endpointGroup)
+        final BlockingWebClient client = WebClient.builder(HTTP, endpointGroup)
                                                   .factory(ClientFactory.insecure())
                                                   .build()
                                                   .blocking();
@@ -120,4 +125,38 @@ void shouldCopyEndpoint() throws InterruptedException {
         assertThat(derivedWithAdditionalOptions.get("/").status()).isEqualTo(HttpStatus.OK);
         assertThat(server.requestContextCaptor().take().request().headers().contains("foo", "3")).isTrue();
     }
+
+    private static Stream derivationDoesNotAddPreprocessors_args() {
+        final Endpoint endpoint = Endpoint.of("127.0.0.1");
+        return Stream.of(
+                Arguments.of(WebClient.of()),
+                Arguments.of(WebClient.of((delegate, ctx, req) -> HttpResponse.of(200))),
+                Arguments.of(WebClient.builder((delegate, ctx, req) -> HttpResponse.of(200), "/prefix")
+                                      .build()),
+                Arguments.of(WebClient.builder(HttpPreprocessor.of(HTTP, endpoint), "/prefix")
+                                      .preprocessor(HttpPreprocessor.of(HTTPS, endpoint))
+                                      .build()),
+                Arguments.of(Clients.builder(ClientPreprocessors.builder()
+                                                                .add(HttpPreprocessor.of(HTTP, endpoint))
+                                                                .addRpc(RpcPreprocessor.of(HTTP, endpoint))
+                                                                .build())
+                                    .preprocessor((delegate, ctx, req) -> HttpResponse.of(201))
+                                    .build(WebClient.class))
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("derivationDoesNotAddPreprocessors_args")
+    void derivationDoesNotAddPreprocessors(WebClient webClient) {
+        final ClientPreprocessors origPreprocessors = webClient.options().clientPreprocessors();
+        final List httpPreprocessors = origPreprocessors.preprocessors();
+        final List rpcPreprocessors = origPreprocessors.rpcPreprocessors();
+
+        final WebClient derivedClient =
+                Clients.newDerivedClient(webClient,
+                                         ClientOptions.RESPONSE_TIMEOUT_MILLIS.newValue(1L));
+        final ClientPreprocessors newPreprocessors = derivedClient.options().clientPreprocessors();
+        assertThat(httpPreprocessors).containsExactlyElementsOf(newPreprocessors.preprocessors());
+        assertThat(rpcPreprocessors).containsExactlyElementsOf(newPreprocessors.rpcPreprocessors());
+    }
 }
diff --git a/core/src/test/java/com/linecorp/armeria/client/HttpClientNoKeepAliveTest.java b/core/src/test/java/com/linecorp/armeria/client/HttpClientNoKeepAliveTest.java
index bcf3eb2e3f5..1ef447d4e07 100644
--- a/core/src/test/java/com/linecorp/armeria/client/HttpClientNoKeepAliveTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/HttpClientNoKeepAliveTest.java
@@ -120,7 +120,7 @@ void shouldCloseConnectionWhenNoPingAck(long idleTimeoutMillis) throws Exception
         }
     }
 
-    @EnumSource(value = SessionProtocol.class, mode = Mode.EXCLUDE, names = "PROXY")
+    @EnumSource(value = SessionProtocol.class, mode = Mode.EXCLUDE, names = {"PROXY", "UNDEFINED"})
     @ParameterizedTest
     void shouldDisconnectWhenConnectionCloseHeaderIsIncluded(SessionProtocol protocol) {
         final CountingConnectionPoolListener countingPoolListener = new CountingConnectionPoolListener();
diff --git a/core/src/test/java/com/linecorp/armeria/client/HttpClientRequestPathTest.java b/core/src/test/java/com/linecorp/armeria/client/HttpClientRequestPathTest.java
index 7ba83d52755..eb755dd74b0 100644
--- a/core/src/test/java/com/linecorp/armeria/client/HttpClientRequestPathTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/HttpClientRequestPathTest.java
@@ -106,7 +106,7 @@ void default_withInvalidScheme() {
     }
 
     @ParameterizedTest
-    @EnumSource(value = SessionProtocol.class, mode = Mode.EXCLUDE, names = "PROXY")
+    @EnumSource(value = SessionProtocol.class, mode = Mode.EXCLUDE, names = {"PROXY", "UNDEFINED"})
     void default_withScheme(SessionProtocol protocol) {
         final HttpRequest request = HttpRequest.of(HttpMethod.GET, server2.uri(protocol) + "/simple-client");
         try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
@@ -123,12 +123,16 @@ void default_withRelativePath() {
         final HttpRequest request = HttpRequest.of(HttpMethod.GET, "/simple-client");
         final HttpResponse response = WebClient.of().execute(request);
         assertThatThrownBy(() -> response.aggregate().join())
-                .hasCauseInstanceOf(IllegalArgumentException.class)
-                .hasMessageContaining("Scheme and authority must be specified");
+                .cause()
+                .isInstanceOf(UnprocessedRequestException.class)
+                .cause()
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("ctx.sessionProtocol() cannot be 'undefined'");
     }
 
     @ParameterizedTest
-    @EnumSource(value = SessionProtocol.class, mode = Mode.EXCLUDE, names = { "HTTP", "HTTPS", "PROXY"})
+    @EnumSource(value = SessionProtocol.class, mode = Mode.EXCLUDE,
+            names = { "HTTP", "HTTPS", "PROXY", "UNDEFINED"})
     void default_withRetryClient(SessionProtocol protocol) {
         final HttpRequest request = HttpRequest.of(HttpMethod.GET, server2.uri(protocol) + "/retry");
         final WebClient client = WebClient.builder()
diff --git a/core/src/test/java/com/linecorp/armeria/client/HttpPreprocessorTest.java b/core/src/test/java/com/linecorp/armeria/client/HttpPreprocessorTest.java
index 216061e5061..780bab3022f 100644
--- a/core/src/test/java/com/linecorp/armeria/client/HttpPreprocessorTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/HttpPreprocessorTest.java
@@ -17,9 +17,11 @@
 package com.linecorp.armeria.client;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CompletionException;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
@@ -35,6 +37,18 @@ class HttpPreprocessorTest {
     @RegisterExtension
     static final EventLoopExtension eventLoop = new EventLoopExtension();
 
+    @Test
+    void invalidSessionProtocol() {
+        final WebClient client = WebClient.of(PreClient::execute);
+        assertThatThrownBy(() -> client.get("/").aggregate().join())
+                .isInstanceOf(CompletionException.class)
+                .cause()
+                .isInstanceOf(UnprocessedRequestException.class)
+                .cause()
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("ctx.sessionProtocol() cannot be 'undefined'");
+    }
+
     @Test
     void overwriteByCustomPreprocessor() {
         final HttpPreprocessor preprocessor =
diff --git a/core/src/test/java/com/linecorp/armeria/client/PendingExceptionTest.java b/core/src/test/java/com/linecorp/armeria/client/PendingExceptionTest.java
index e753f6c5e32..bb81f94f99d 100644
--- a/core/src/test/java/com/linecorp/armeria/client/PendingExceptionTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/PendingExceptionTest.java
@@ -60,7 +60,7 @@ protected void configure(ServerBuilder sb) {
         }
     };
 
-    @EnumSource(value = SessionProtocol.class, mode = Mode.EXCLUDE, names = "PROXY")
+    @EnumSource(value = SessionProtocol.class, mode = Mode.EXCLUDE, names = {"PROXY", "UNDEFINED"})
     @ParameterizedTest
     void shouldPropagatePendingException(SessionProtocol protocol) {
         final AnticipatedException pendingException = new AnticipatedException();
diff --git a/core/src/test/java/com/linecorp/armeria/client/PreferHttp1Test.java b/core/src/test/java/com/linecorp/armeria/client/PreferHttp1Test.java
index 07ffa636ffc..4dd3ae39e21 100644
--- a/core/src/test/java/com/linecorp/armeria/client/PreferHttp1Test.java
+++ b/core/src/test/java/com/linecorp/armeria/client/PreferHttp1Test.java
@@ -42,7 +42,7 @@ protected void configure(ServerBuilder sb) {
         }
     };
 
-    @EnumSource(value = SessionProtocol.class, names = "PROXY", mode = EnumSource.Mode.EXCLUDE)
+    @EnumSource(value = SessionProtocol.class, names = {"PROXY", "UNDEFINED"}, mode = EnumSource.Mode.EXCLUDE)
     @ParameterizedTest
     void shouldPreferHttp1(SessionProtocol protocol) throws InterruptedException {
         try (ClientFactory factory = ClientFactory.builder()
diff --git a/core/src/test/java/com/linecorp/armeria/client/RestClientTest.java b/core/src/test/java/com/linecorp/armeria/client/RestClientTest.java
index 57e242eeb9f..0339cd08ecc 100644
--- a/core/src/test/java/com/linecorp/armeria/client/RestClientTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/RestClientTest.java
@@ -17,6 +17,7 @@
 package com.linecorp.armeria.client;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
@@ -29,6 +30,7 @@
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.ArgumentsProvider;
 import org.junit.jupiter.params.provider.ArgumentsSource;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.reflections.ReflectionUtils;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
@@ -36,11 +38,11 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 
-import com.linecorp.armeria.client.logging.LoggingClient;
 import com.linecorp.armeria.common.Cookie;
 import com.linecorp.armeria.common.HttpMethod;
 import com.linecorp.armeria.common.HttpRequest;
 import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace;
 import com.linecorp.armeria.server.ServerBuilder;
 import com.linecorp.armeria.server.ServiceRequestContext;
@@ -56,6 +58,7 @@
 import com.linecorp.armeria.testing.junit5.server.ServerExtension;
 
 class RestClientTest {
+
     @RegisterExtension
     static ServerExtension server = new ServerExtension() {
         @Override
@@ -112,25 +115,56 @@ void restApi(RestClient restClient) {
                     break;
             }
             assertThat(preparation).isNotNull();
-            final RestResponse response =
-                    preparation.content("content")
-                               .header("x-header", "header-value")
-                               .cookie(Cookie.ofSecure("cookie", "cookie-value"))
-                               .pathParam("id", "1")
-                               .queryParam("query", "query-value")
-                               .execute(RestResponse.class)
-                               .join()
-                               .content();
-
-            assertThat(response.getId()).isEqualTo("1");
-            assertThat(response.getMethod()).isEqualTo(method.toString());
-            assertThat(response.getQuery()).isEqualTo("query-value");
-            assertThat(response.getHeader()).isEqualTo("header-value");
-            assertThat(response.getCookie()).isEqualTo("cookie-value");
-            assertThat(response.getContent()).isEqualTo("content");
+            validatePreparation(method, preparation);
         }
     }
 
+    private static void validatePreparation(HttpMethod method, RestClientPreparation preparation) {
+        final RestResponse response =
+                preparation.content("content")
+                           .header("x-header", "header-value")
+                           .cookie(Cookie.ofSecure("cookie", "cookie-value"))
+                           .pathParam("id", "1")
+                           .queryParam("query", "query-value")
+                           .execute(RestResponse.class)
+                           .join()
+                           .content();
+
+        assertThat(response.getId()).isEqualTo("1");
+        assertThat(response.getMethod()).isEqualTo(method.toString());
+        assertThat(response.getQuery()).isEqualTo("query-value");
+        assertThat(response.getHeader()).isEqualTo("header-value");
+        assertThat(response.getCookie()).isEqualTo("cookie-value");
+        assertThat(response.getContent()).isEqualTo("content");
+    }
+
+    @ArgumentsSource(RestClientProvider.class)
+    @ParameterizedTest
+    void derivedClients(RestClient restClient) {
+        final ClientOptionValue option = ClientOptions.MAX_RESPONSE_LENGTH.doNewValue(Long.MAX_VALUE);
+        final RestClient derivedClient = Clients.newDerivedClient(restClient, option);
+        final RestClientPreparation preparation = derivedClient.get("/rest/{id}");
+        validatePreparation(HttpMethod.GET, preparation);
+        assertThat(restClient.options().clientPreprocessors().preprocessors())
+                .containsExactlyElementsOf(derivedClient.options().clientPreprocessors().preprocessors());
+    }
+
+    public static Stream preprocessorWithPrefix_args() {
+        final HttpPreprocessor preprocessor = HttpPreprocessor.of(SessionProtocol.HTTP, server.httpEndpoint());
+        return Stream.of(
+                Arguments.of(RestClient.of(preprocessor, "/rest")),
+                Arguments.of(WebClient.of(preprocessor, "/rest")
+                                      .asRestClient())
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("preprocessorWithPrefix_args")
+    void preprocessorWithPrefix(RestClient restClient) {
+        final RestClientPreparation preparation = restClient.get("/{id}");
+        validatePreparation(HttpMethod.GET, preparation);
+    }
+
     @Test
     void returnType_RestClientPreparation() throws NoSuchMethodException {
         for (final Method method : ReflectionUtils.getMethods(RequestPreparationSetters.class)) {
@@ -157,17 +191,37 @@ void returnType_RestClientBuilder() throws NoSuchMethodException {
         }
     }
 
+    @Test
+    void notAllowRpcPreprocessor() {
+        final RpcPreprocessor rpcPreprocessor =
+                RpcPreprocessor.of(SessionProtocol.HTTP, Endpoint.of("1.2.3.4"));
+        assertThatThrownBy(() -> RestClient.builder().rpcPreprocessor(rpcPreprocessor))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("RPC preprocessor cannot be added");
+    }
+
+    @Test
+    void atLeastOneHttpPreprocessorNeeded() {
+        final RpcPreprocessor rpcPreprocessor =
+                RpcPreprocessor.of(SessionProtocol.HTTP, Endpoint.of("1.2.3.4"));
+        assertThatThrownBy(() -> Clients.newClient(ClientPreprocessors.ofRpc(rpcPreprocessor),
+                                                   RestClient.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one preprocessor must be specified");
+    }
+
     private static class RestClientProvider implements ArgumentsProvider {
 
         @Override
         public Stream provideArguments(ExtensionContext context) throws Exception {
-            return Stream.of(RestClient.of(server.httpUri()),
-                             RestClient.of(server.webClient()),
-                             RestClient.of("http://127.0.0.1:" + server.httpPort()),
-                             server.webClient().asRestClient(),
-                             RestClient.builder(server.httpUri())
-                                       .decorator(LoggingClient.newDecorator())
-                                       .build())
+            final HttpPreprocessor httpPreprocessor = HttpPreprocessor.of(SessionProtocol.HTTP,
+                                                                          server.httpEndpoint());
+            return Stream.of(
+                             WebClient.builder()
+                                      .preprocessor(httpPreprocessor)
+                                      .build()
+                                      .asRestClient()
+                         )
                          .map(Arguments::of);
         }
     }
diff --git a/core/src/test/java/com/linecorp/armeria/client/WebClientAdditionalAuthorityTest.java b/core/src/test/java/com/linecorp/armeria/client/WebClientAdditionalAuthorityTest.java
index 07ab1ac55f1..0232b7656bb 100644
--- a/core/src/test/java/com/linecorp/armeria/client/WebClientAdditionalAuthorityTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/WebClientAdditionalAuthorityTest.java
@@ -283,8 +283,9 @@ void shouldNotUseAuthorityAsEndpointWithBaseUriWebClient(String protocol, String
     void noAuthority() {
         final HttpRequest request = HttpRequest.of(RequestHeaders.of(HttpMethod.GET, "/"));
         assertThatThrownBy(() -> client.execute(request))
+                .cause()
                 .isInstanceOf(IllegalArgumentException.class)
-                .hasMessageContaining("Scheme and authority must be specified in");
+                .hasMessageContaining("ctx.sessionProtocol() cannot be 'undefined'");
     }
 
     @Test
diff --git a/core/src/test/java/com/linecorp/armeria/client/WebClientBuilderTest.java b/core/src/test/java/com/linecorp/armeria/client/WebClientBuilderTest.java
index df61361f9ca..fb36befb6f7 100644
--- a/core/src/test/java/com/linecorp/armeria/client/WebClientBuilderTest.java
+++ b/core/src/test/java/com/linecorp/armeria/client/WebClientBuilderTest.java
@@ -17,6 +17,7 @@
 package com.linecorp.armeria.client;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Supplier;
@@ -33,6 +34,7 @@
 import com.linecorp.armeria.common.MediaType;
 import com.linecorp.armeria.common.RequestHeaders;
 import com.linecorp.armeria.common.RequestHeadersBuilder;
+import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.server.ServerBuilder;
 import com.linecorp.armeria.testing.junit5.server.ServerExtension;
 
@@ -210,4 +212,24 @@ void contextHook() {
         assertThat(response.contentUtf8()).isEqualTo("Hello Armeria");
         assertThat(popped.get()).isGreaterThan(1);
     }
+
+    @Test
+    void undefinedProtocol() {
+        assertThatThrownBy(() -> Clients.newClient(SessionProtocol.UNDEFINED, Endpoint.of("1.2.3.4"),
+                                                   WebClient.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one preprocessor must be specified");
+
+        assertThatThrownBy(() -> Clients.newClient("undefined://1.2.3.4", WebClient.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one preprocessor must be specified");
+
+        assertThatThrownBy(() -> WebClient.of(SessionProtocol.UNDEFINED, Endpoint.of("1.2.3.4")))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("scheme: undefined (expected: one of");
+
+        assertThatThrownBy(() -> WebClient.of("undefined://1.2.3.4"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("scheme: undefined (expected: one of");
+    }
 }
diff --git a/core/src/test/java/com/linecorp/armeria/common/KeepAliveMaxNumRequestsTest.java b/core/src/test/java/com/linecorp/armeria/common/KeepAliveMaxNumRequestsTest.java
index db2ffdec988..e343ce3cbb2 100644
--- a/core/src/test/java/com/linecorp/armeria/common/KeepAliveMaxNumRequestsTest.java
+++ b/core/src/test/java/com/linecorp/armeria/common/KeepAliveMaxNumRequestsTest.java
@@ -157,6 +157,7 @@ private static final class ProtocolProvider implements ArgumentsProvider {
         public Stream provideArguments(ExtensionContext context) throws Exception {
             return Arrays.stream(SessionProtocol.values())
                          .filter(protocol -> protocol != SessionProtocol.PROXY)
+                         .filter(protocol -> protocol != SessionProtocol.UNDEFINED)
                          .flatMap(protocol -> Stream.of(Arguments.of(protocol, false),
                                                         Arguments.of(protocol, true)));
         }
diff --git a/core/src/test/java/com/linecorp/armeria/server/HeadMethodLeakTest.java b/core/src/test/java/com/linecorp/armeria/server/HeadMethodLeakTest.java
index 57b76a7269c..a77a9ca949c 100644
--- a/core/src/test/java/com/linecorp/armeria/server/HeadMethodLeakTest.java
+++ b/core/src/test/java/com/linecorp/armeria/server/HeadMethodLeakTest.java
@@ -132,7 +132,7 @@ public Stream provideArguments(ExtensionContext context) th
             final Stream.Builder builder = Stream.builder();
             for (int i = 0; i < 20; i++) {
                 for (SessionProtocol protocol : SessionProtocol.values()) {
-                    if (protocol == SessionProtocol.PROXY) {
+                    if (protocol == SessionProtocol.PROXY || protocol == SessionProtocol.UNDEFINED) {
                         continue;
                     }
                     for (ExchangeType exchangeType : ExchangeType.values()) {
diff --git a/core/src/test/java/com/linecorp/armeria/server/HttpServerNoKeepAliveTest.java b/core/src/test/java/com/linecorp/armeria/server/HttpServerNoKeepAliveTest.java
index b4a75df3131..63fc50c4a0a 100644
--- a/core/src/test/java/com/linecorp/armeria/server/HttpServerNoKeepAliveTest.java
+++ b/core/src/test/java/com/linecorp/armeria/server/HttpServerNoKeepAliveTest.java
@@ -54,7 +54,7 @@ protected void configure(ServerBuilder sb) {
         }
     };
 
-    @EnumSource(value = SessionProtocol.class, mode = Mode.EXCLUDE, names = "PROXY")
+    @EnumSource(value = SessionProtocol.class, mode = Mode.EXCLUDE, names = {"PROXY", "UNDEFINED"})
     @ParameterizedTest
     void shouldDisconnectWhenConnectionCloseIsIncluded(SessionProtocol protocol) {
         final CountingConnectionPoolListener poolListener = new CountingConnectionPoolListener();
diff --git a/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroup.java b/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroup.java
index 631817fd1a7..ca36d8ceece 100644
--- a/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroup.java
+++ b/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroup.java
@@ -42,6 +42,7 @@
 import com.linecorp.armeria.client.ClientRequestContextCaptor;
 import com.linecorp.armeria.client.Clients;
 import com.linecorp.armeria.client.Endpoint;
+import com.linecorp.armeria.client.HttpPreprocessor;
 import com.linecorp.armeria.client.WebClient;
 import com.linecorp.armeria.client.endpoint.DynamicEndpointGroup;
 import com.linecorp.armeria.client.endpoint.EndpointGroup;
@@ -124,6 +125,20 @@ public static EurekaEndpointGroup of(
                 sessionProtocol, endpointGroup, requireNonNull(path, "path")).build();
     }
 
+    /**
+     * TBU.
+     */
+    public static EurekaEndpointGroup of(HttpPreprocessor preprocessor) {
+        return new EurekaEndpointGroupBuilder(preprocessor, null).build();
+    }
+
+    /**
+     * TBU.
+     */
+    public static EurekaEndpointGroup of(HttpPreprocessor preprocessor, String path) {
+        return new EurekaEndpointGroupBuilder(preprocessor, requireNonNull(path, "path")).build();
+    }
+
     /**
      * Returns a new {@link EurekaEndpointGroupBuilder} created with the specified {@code eurekaUri}.
      */
@@ -156,6 +171,20 @@ public static EurekaEndpointGroupBuilder builder(
         return new EurekaEndpointGroupBuilder(sessionProtocol, endpointGroup, requireNonNull(path, "path"));
     }
 
+    /**
+     * TBU.
+     */
+    public static EurekaEndpointGroupBuilder builder(HttpPreprocessor preprocessor) {
+        return new EurekaEndpointGroupBuilder(preprocessor, null);
+    }
+
+    /**
+     * TBU.
+     */
+    public static EurekaEndpointGroupBuilder builder(HttpPreprocessor preprocessor, String path) {
+        return new EurekaEndpointGroupBuilder(preprocessor, requireNonNull(path, "path"));
+    }
+
     private final long registryFetchIntervalMillis;
 
     private final RequestHeaders requestHeaders;
diff --git a/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroupBuilder.java b/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroupBuilder.java
index 2ecaa12e71d..88019b06053 100644
--- a/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroupBuilder.java
+++ b/eureka/src/main/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroupBuilder.java
@@ -107,6 +107,10 @@ public final class EurekaEndpointGroupBuilder extends AbstractWebClientBuilder
         super(sessionProtocol, endpointGroup, path);
     }
 
+    EurekaEndpointGroupBuilder(HttpPreprocessor httpPreprocessor, @Nullable String path) {
+        super(httpPreprocessor, path);
+    }
+
     /**
      * Sets the {@link EndpointSelectionStrategy} of the {@link EurekaEndpointGroup}.
      */
diff --git a/eureka/src/main/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListener.java b/eureka/src/main/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListener.java
index d5f1a93f13b..24ae8f5d9e1 100644
--- a/eureka/src/main/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListener.java
+++ b/eureka/src/main/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListener.java
@@ -30,6 +30,7 @@
 import com.linecorp.armeria.client.ClientRequestContext;
 import com.linecorp.armeria.client.ClientRequestContextCaptor;
 import com.linecorp.armeria.client.Clients;
+import com.linecorp.armeria.client.HttpPreprocessor;
 import com.linecorp.armeria.client.endpoint.EndpointGroup;
 import com.linecorp.armeria.common.HttpResponse;
 import com.linecorp.armeria.common.HttpStatus;
@@ -99,6 +100,21 @@ public static EurekaUpdatingListener of(
                 sessionProtocol, endpointGroup, requireNonNull(path, "path")).build();
     }
 
+    /**
+     * TBU.
+     */
+    public static EurekaUpdatingListener of(HttpPreprocessor preprocessor) {
+        return new EurekaUpdatingListenerBuilder(preprocessor, null).build();
+    }
+
+    /**
+     * TBU.
+     */
+    public static EurekaUpdatingListener of(HttpPreprocessor preprocessor, String path) {
+        return new EurekaUpdatingListenerBuilder(preprocessor, requireNonNull(path, "path"))
+                .build();
+    }
+
     /**
      * Returns a new {@link EurekaUpdatingListenerBuilder} created with the specified {@code eurekaUri}.
      */
@@ -131,6 +147,20 @@ public static EurekaUpdatingListenerBuilder builder(
         return new EurekaUpdatingListenerBuilder(sessionProtocol, endpointGroup, requireNonNull(path, "path"));
     }
 
+    /**
+     * TBU.
+     */
+    public static EurekaUpdatingListenerBuilder builder(HttpPreprocessor preprocessor) {
+        return new EurekaUpdatingListenerBuilder(preprocessor, null);
+    }
+
+    /**
+     * TBU.
+     */
+    public static EurekaUpdatingListenerBuilder builder(HttpPreprocessor preprocessor, String path) {
+        return new EurekaUpdatingListenerBuilder(preprocessor, requireNonNull(path, "path"));
+    }
+
     private final EurekaWebClient client;
     private final InstanceInfo initialInstanceInfo;
     @Nullable
diff --git a/eureka/src/main/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListenerBuilder.java b/eureka/src/main/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListenerBuilder.java
index ecd9855636a..e2f8808a90f 100644
--- a/eureka/src/main/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListenerBuilder.java
+++ b/eureka/src/main/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListenerBuilder.java
@@ -103,6 +103,11 @@ public final class EurekaUpdatingListenerBuilder extends AbstractWebClientBuilde
         instanceInfoBuilder = new InstanceInfoBuilder();
     }
 
+    EurekaUpdatingListenerBuilder(HttpPreprocessor preprocessor, @Nullable String path) {
+        super(preprocessor, path);
+        instanceInfoBuilder = new InstanceInfoBuilder();
+    }
+
     /**
      * Sets the interval between renewal. {@value DEFAULT_LEASE_RENEWAL_INTERVAL_SECONDS} seconds is used
      * by default and it's not recommended to modify this value. Eureka protocol stores this value in seconds
diff --git a/eureka/src/test/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroupTest.java b/eureka/src/test/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroupTest.java
index 16da4366265..5c559250ec7 100644
--- a/eureka/src/test/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroupTest.java
+++ b/eureka/src/test/java/com/linecorp/armeria/client/eureka/EurekaEndpointGroupTest.java
@@ -33,9 +33,11 @@
 import com.netflix.discovery.util.InstanceInfoGenerator;
 
 import com.linecorp.armeria.client.Endpoint;
+import com.linecorp.armeria.client.HttpPreprocessor;
 import com.linecorp.armeria.common.HttpResponse;
 import com.linecorp.armeria.common.HttpStatus;
 import com.linecorp.armeria.common.MediaType;
+import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace;
 import com.linecorp.armeria.server.ServerBuilder;
 import com.linecorp.armeria.testing.junit5.server.ServerExtension;
@@ -77,4 +79,16 @@ void upStatusInstancesAreChosen() {
         // Created 6 instances but 1 is down, so 5 instances.
         assertThat(endpointsCaptor.join()).hasSize(5);
     }
+
+    @Test
+    void preprocessor() {
+        try (EurekaEndpointGroup eurekaEndpointGroup = EurekaEndpointGroup.builder(
+                HttpPreprocessor.of(SessionProtocol.HTTP, eurekaServer.httpEndpoint())).build()) {
+            final CompletableFuture> endpointsCaptor = new CompletableFuture<>();
+            eurekaEndpointGroup.addListener(endpointsCaptor::complete);
+
+            // Created 6 instances but 1 is down, so 5 instances.
+            assertThat(endpointsCaptor.join()).hasSize(5);
+        }
+    }
 }
diff --git a/eureka/src/test/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListenerTest.java b/eureka/src/test/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListenerTest.java
index 1f05dbd24f8..217574c43fd 100644
--- a/eureka/src/test/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListenerTest.java
+++ b/eureka/src/test/java/com/linecorp/armeria/server/eureka/EurekaUpdatingListenerTest.java
@@ -25,16 +25,21 @@
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
 
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import com.fasterxml.jackson.annotation.JsonInclude.Include;
 import com.fasterxml.jackson.databind.DeserializationFeature;
 import com.fasterxml.jackson.databind.ObjectMapper;
 
+import com.linecorp.armeria.client.HttpPreprocessor;
 import com.linecorp.armeria.common.HttpData;
 import com.linecorp.armeria.common.HttpMethod;
 import com.linecorp.armeria.common.HttpResponse;
@@ -63,7 +68,7 @@ class EurekaUpdatingListenerTest {
     private static final AtomicReference registerContentCaptor = new AtomicReference<>();
     private static final AtomicInteger registerCounter = new AtomicInteger();
 
-    private static final CompletableFuture heartBeatHeadersCaptor = new CompletableFuture<>();
+    private static CompletableFuture heartBeatHeadersCaptor;
     private static final AtomicInteger heartBeatRequestCounter = new AtomicInteger();
     private static final CompletableFuture deregisterHeadersCaptor = new CompletableFuture<>();
 
@@ -106,15 +111,28 @@ protected void configure(ServerBuilder sb) throws Exception {
         }
     };
 
-    @Test
-    void registerHeartBeatAndDeregisterAreSent() throws IOException {
-        final EurekaUpdatingListener listener =
-                EurekaUpdatingListener.builder(eurekaServer.httpUri())
-                                      .instanceId(INSTANCE_ID)
-                                      .renewalIntervalMillis(2000)
-                                      .leaseDurationMillis(10000)
-                                      .appName(APP_NAME)
-                                      .build();
+    static Stream registerHeartBeatAndDeregisterAreSent_args() {
+        return Stream.of(
+                Arguments.of(EurekaUpdatingListener.builder(eurekaServer.httpUri())),
+                Arguments.of(EurekaUpdatingListener.builder(
+                        HttpPreprocessor.of(SessionProtocol.HTTP, eurekaServer.httpEndpoint())))
+        );
+    }
+
+    @BeforeEach
+    void beforeEach() {
+        heartBeatHeadersCaptor = new CompletableFuture<>();
+    }
+
+    @ParameterizedTest
+    @MethodSource("registerHeartBeatAndDeregisterAreSent_args")
+    void registerHeartBeatAndDeregisterAreSent(EurekaUpdatingListenerBuilder builder) throws IOException {
+        final EurekaUpdatingListener listener = builder
+                .instanceId(INSTANCE_ID)
+                .renewalIntervalMillis(2000)
+                .leaseDurationMillis(10000)
+                .appName(APP_NAME)
+                .build();
 
         final Server application = Server.builder()
                                          .http(0)
diff --git a/grpc-protocol/src/main/java/com/linecorp/armeria/internal/client/grpc/protocol/UnaryGrpcClientFactory.java b/grpc-protocol/src/main/java/com/linecorp/armeria/internal/client/grpc/protocol/UnaryGrpcClientFactory.java
index 7f3a8a37d5a..4b29edec7fa 100644
--- a/grpc-protocol/src/main/java/com/linecorp/armeria/internal/client/grpc/protocol/UnaryGrpcClientFactory.java
+++ b/grpc-protocol/src/main/java/com/linecorp/armeria/internal/client/grpc/protocol/UnaryGrpcClientFactory.java
@@ -63,9 +63,10 @@ public boolean isClientTypeSupported(Class clientType) {
     public Object newClient(ClientBuilderParams params) {
         final Scheme scheme = params.scheme();
         final SerializationFormat serializationFormat = scheme.serializationFormat();
-        final ClientBuilderParams newParams = ClientBuilderParams.of(
-                Scheme.of(SerializationFormat.NONE, scheme.sessionProtocol()), params.endpointGroup(),
-                params.absolutePathRef(), WebClient.class, params.options());
+        final ClientBuilderParams newParams = params.paramsBuilder()
+                                                    .serializationFormat(SerializationFormat.NONE)
+                                                    .clientType(WebClient.class)
+                                                    .build();
         final WebClient webClient = (WebClient) unwrap().newClient(newParams);
         return new UnaryGrpcClient(webClient, serializationFormat);
     }
diff --git a/grpc/src/main/java/com/linecorp/armeria/client/grpc/GrpcClientBuilder.java b/grpc/src/main/java/com/linecorp/armeria/client/grpc/GrpcClientBuilder.java
index dedcd2f49fc..5a0d4d6c020 100644
--- a/grpc/src/main/java/com/linecorp/armeria/client/grpc/GrpcClientBuilder.java
+++ b/grpc/src/main/java/com/linecorp/armeria/client/grpc/GrpcClientBuilder.java
@@ -28,6 +28,7 @@
 import static com.linecorp.armeria.client.grpc.GrpcClientOptions.MAX_OUTBOUND_MESSAGE_SIZE_BYTES;
 import static com.linecorp.armeria.client.grpc.GrpcClientOptions.UNSAFE_WRAP_RESPONSE_BUFFERS;
 import static com.linecorp.armeria.client.grpc.GrpcClientOptions.USE_METHOD_MARSHALLER;
+import static com.linecorp.armeria.internal.client.ClientBuilderParamsUtil.preprocessorToUri;
 import static java.util.Objects.requireNonNull;
 
 import java.net.URI;
@@ -65,6 +66,7 @@
 import com.linecorp.armeria.common.RequestId;
 import com.linecorp.armeria.common.Scheme;
 import com.linecorp.armeria.common.SerializationFormat;
+import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.common.SuccessFunction;
 import com.linecorp.armeria.common.annotation.Nullable;
 import com.linecorp.armeria.common.annotation.UnstableApi;
@@ -126,6 +128,16 @@ public final class GrpcClientBuilder extends AbstractClientOptionsBuilder {
         this.endpointGroup = endpointGroup;
     }
 
+    GrpcClientBuilder(SerializationFormat serializationFormat, HttpPreprocessor httpPreprocessor) {
+        requireNonNull(serializationFormat, "serializationFormat");
+        requireNonNull(httpPreprocessor, "httpPreprocessor");
+        endpointGroup = null;
+        scheme = Scheme.of(serializationFormat, SessionProtocol.UNDEFINED);
+        uri = preprocessorToUri(scheme, httpPreprocessor, null);
+        validateOrSetSerializationFormat();
+        preprocessor(httpPreprocessor);
+    }
+
     private void validateOrSetSerializationFormat() {
         if (scheme.serializationFormat() == SerializationFormat.NONE) {
             // If not set, gRPC protobuf is used as a default serialization format.
diff --git a/grpc/src/main/java/com/linecorp/armeria/client/grpc/GrpcClients.java b/grpc/src/main/java/com/linecorp/armeria/client/grpc/GrpcClients.java
index a6f1543eca5..e62c71de2f3 100644
--- a/grpc/src/main/java/com/linecorp/armeria/client/grpc/GrpcClients.java
+++ b/grpc/src/main/java/com/linecorp/armeria/client/grpc/GrpcClients.java
@@ -21,6 +21,7 @@
 import java.net.URI;
 
 import com.linecorp.armeria.client.ClientFactory;
+import com.linecorp.armeria.client.HttpPreprocessor;
 import com.linecorp.armeria.client.endpoint.EndpointGroup;
 import com.linecorp.armeria.common.Scheme;
 import com.linecorp.armeria.common.SerializationFormat;
@@ -119,6 +120,21 @@ public static  T newClient(SessionProtocol protocol, EndpointGroup endpointGr
         return builder(protocol, endpointGroup).build(clientType);
     }
 
+    /**
+     * TBU.
+     */
+    public static  T newClient(HttpPreprocessor httpPreprocessor, Class clientType) {
+        return newClient(SerializationFormat.NONE, httpPreprocessor, clientType);
+    }
+
+    /**
+     * TBU.
+     */
+    public static  T newClient(SerializationFormat serializationFormat, HttpPreprocessor httpPreprocessor,
+                                  Class clientType) {
+        return builder(serializationFormat, httpPreprocessor).build(clientType);
+    }
+
     /**
      * Returns a new {@link GrpcClientBuilder} that builds the client that connects to the specified
      * {@code uri}.
@@ -183,5 +199,23 @@ public static GrpcClientBuilder builder(Scheme scheme, EndpointGroup endpointGro
         return new GrpcClientBuilder(scheme, endpointGroup);
     }
 
+    /**
+     * TBU.
+     */
+    public static GrpcClientBuilder builder(HttpPreprocessor httpPreprocessor) {
+        requireNonNull(httpPreprocessor, "httpPreprocessor");
+        return builder(SerializationFormat.NONE, httpPreprocessor);
+    }
+
+    /**
+     * TBU.
+     */
+    public static GrpcClientBuilder builder(SerializationFormat serializationFormat,
+                                            HttpPreprocessor httpPreprocessor) {
+        requireNonNull(serializationFormat, "serializationFormat");
+        requireNonNull(httpPreprocessor, "httpPreprocessor");
+        return new GrpcClientBuilder(serializationFormat, httpPreprocessor);
+    }
+
     private GrpcClients() {}
 }
diff --git a/grpc/src/main/java/com/linecorp/armeria/internal/client/grpc/GrpcClientFactory.java b/grpc/src/main/java/com/linecorp/armeria/internal/client/grpc/GrpcClientFactory.java
index 1e04f8db90d..7a8f4cb2dff 100644
--- a/grpc/src/main/java/com/linecorp/armeria/internal/client/grpc/GrpcClientFactory.java
+++ b/grpc/src/main/java/com/linecorp/armeria/internal/client/grpc/GrpcClientFactory.java
@@ -186,6 +186,17 @@ public Object newClient(ClientBuilderParams params) {
         return clientStub;
     }
 
+    @Override
+    public ClientBuilderParams validateParams(ClientBuilderParams params) {
+        if (params.scheme().sessionProtocol() == SessionProtocol.UNDEFINED &&
+            params.options().clientPreprocessors().preprocessors().isEmpty()) {
+            throw new IllegalArgumentException(
+                    "At least one preprocessor must be specified for http-based clients " +
+                    "with sessionProtocol '" + params.scheme().sessionProtocol() + "'.");
+        }
+        return super.validateParams(params);
+    }
+
     /**
      * Adds the {@link GrpcWebTrailersExtractor} if the specified {@link SerializationFormat} is a gRPC-Web and
      * {@link RetryingClient} exists in the {@link ClientDecoration}.
@@ -223,9 +234,9 @@ private static ClientBuilderParams addTrailersExtractor(
 
         decorators.forEach(optionsBuilder::decorator);
 
-        return ClientBuilderParams.of(
-                params.scheme(), params.endpointGroup(), params.absolutePathRef(),
-                params.clientType(), optionsBuilder.build());
+        return params.paramsBuilder()
+                     .options(optionsBuilder.build())
+                     .build();
     }
 
     @Nullable
diff --git a/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcClientBuilderTest.java b/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcClientBuilderTest.java
index 56f144f9377..62dcdca4063 100644
--- a/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcClientBuilderTest.java
+++ b/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcClientBuilderTest.java
@@ -21,23 +21,39 @@
 import static testing.grpc.Messages.PayloadType.COMPRESSABLE;
 
 import java.io.InputStream;
+import java.util.stream.Stream;
 
 import org.jetbrains.annotations.Nullable;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import com.google.protobuf.ByteString;
 
 import com.linecorp.armeria.client.ClientBuilderParams;
+import com.linecorp.armeria.client.ClientOptionValue;
+import com.linecorp.armeria.client.ClientOptions;
+import com.linecorp.armeria.client.ClientPreprocessors;
+import com.linecorp.armeria.client.ClientRequestContext;
+import com.linecorp.armeria.client.ClientRequestContextCaptor;
 import com.linecorp.armeria.client.Clients;
+import com.linecorp.armeria.client.Endpoint;
+import com.linecorp.armeria.client.HttpPreprocessor;
+import com.linecorp.armeria.client.PreClient;
+import com.linecorp.armeria.client.RpcPreprocessor;
 import com.linecorp.armeria.client.endpoint.EndpointGroup;
 import com.linecorp.armeria.common.CommonPools;
 import com.linecorp.armeria.common.ContentTooLargeException;
+import com.linecorp.armeria.common.Scheme;
 import com.linecorp.armeria.common.SerializationFormat;
+import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.common.grpc.GrpcExceptionHandlerFunction;
 import com.linecorp.armeria.common.grpc.GrpcSerializationFormats;
+import com.linecorp.armeria.internal.client.ClientBuilderParamsUtil;
+import com.linecorp.armeria.internal.client.endpoint.FailingEndpointGroup;
 import com.linecorp.armeria.internal.common.grpc.TestServiceImpl;
 import com.linecorp.armeria.server.ServerBuilder;
 import com.linecorp.armeria.server.grpc.GrpcService;
@@ -52,6 +68,7 @@
 import io.grpc.Status;
 import io.grpc.Status.Code;
 import io.grpc.StatusRuntimeException;
+import testing.grpc.EmptyProtos.Empty;
 import testing.grpc.Messages.Payload;
 import testing.grpc.Messages.SimpleRequest;
 import testing.grpc.Messages.SimpleResponse;
@@ -307,4 +324,93 @@ void useDefaultGrpcExceptionHandlerFunctionAsFallback() {
                 .extracting(Status::getCode)
                 .isEqualTo(Code.RESOURCE_EXHAUSTED);
     }
+
+    @Test
+    void undefinedProtocol() {
+        assertThatThrownBy(() -> GrpcClients
+                .newClient(Scheme.of(GrpcSerializationFormats.PROTO, SessionProtocol.UNDEFINED),
+                           Endpoint.of("1.2.3.4"), TestServiceBlockingStub.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one preprocessor must be specified");
+
+        assertThatThrownBy(() -> GrpcClients
+                .newClient("undefined://1.2.3.4", TestServiceBlockingStub.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one preprocessor must be specified");
+    }
+
+    public static Stream preprocessor_args() {
+        final HttpPreprocessor preprocessor = HttpPreprocessor.of(SessionProtocol.HTTP, server.httpEndpoint());
+        return Stream.of(
+                Arguments.of(GrpcClients.newClient(preprocessor, TestServiceBlockingStub.class)),
+                Arguments.of(GrpcClients.newClient(GrpcSerializationFormats.PROTO,
+                                                   preprocessor, TestServiceBlockingStub.class)),
+                Arguments.of(GrpcClients.builder(GrpcSerializationFormats.PROTO,
+                                                   preprocessor)
+                                        .build(TestServiceBlockingStub.class)),
+                Arguments.of(GrpcClients.builder(preprocessor)
+                                        .build(TestServiceBlockingStub.class)),
+                Arguments.of(GrpcClients.builder(PreClient::execute)
+                                        .preprocessor(preprocessor)
+                                        .build(TestServiceBlockingStub.class))
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("preprocessor_args")
+    void preprocessor(TestServiceBlockingStub stub) {
+        final ClientRequestContext ctx;
+        try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
+            assertThat(stub.emptyCall(Empty.getDefaultInstance())).isEqualTo(Empty.getDefaultInstance());
+            ctx = captor.get();
+        }
+        final ClientOptionValue option = ClientOptions.WRITE_TIMEOUT_MILLIS.newValue(Long.MAX_VALUE);
+        final TestServiceBlockingStub derivedStub = Clients.newDerivedClient(stub, option);
+        final ClientRequestContext derivedCtx;
+        try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
+            assertThat(derivedStub.emptyCall(Empty.getDefaultInstance())).isEqualTo(Empty.getDefaultInstance());
+            derivedCtx = captor.get();
+        }
+        assertThat(ctx.options().clientPreprocessors().preprocessors())
+                .isEqualTo(derivedCtx.options().clientPreprocessors().preprocessors());
+    }
+
+    public static Stream preprocessParams_args() {
+        return Stream.of(
+                Arguments.of(GrpcClients.newClient(PreClient::execute,
+                                                   TestServiceBlockingStub.class).getChannel(), "/"),
+                Arguments.of(GrpcClients.builder(PreClient::execute)
+                                        .pathPrefix("/prefix")
+                                        .build(TestServiceBlockingStub.class).getChannel(), "/prefix/")
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("preprocessParams_args")
+    void preprocessParams(ClientBuilderParams params, String expectedPrefix) {
+        assertThat(params.scheme()).isEqualTo(Scheme.of(GrpcSerializationFormats.PROTO,
+                                                        SessionProtocol.UNDEFINED));
+        assertThat(params.endpointGroup()).isInstanceOf(FailingEndpointGroup.class);
+        assertThat(params.absolutePathRef()).isEqualTo(expectedPrefix);
+        assertThat(params.uri().getRawAuthority()).startsWith("armeria-preprocessor");
+        assertThat(params.uri().getScheme()).isEqualTo("gproto+undefined");
+        assertThat(ClientBuilderParamsUtil.isInternalUri(params.uri())).isTrue();
+        assertThat(Clients.isUndefinedUri(params.uri())).isFalse();
+    }
+
+    @Test
+    void preprocessorThrows() {
+        final GrpcClientBuilder builder = GrpcClients.builder("http://foo.com");
+        assertThatThrownBy(() -> builder.rpcPreprocessor(RpcPreprocessor.of(SessionProtocol.HTTP,
+                                                                            Endpoint.of("foo.com"))))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("rpcPreprocessor() does not support gRPC");
+
+        final RpcPreprocessor rpcPreprocessor =
+                RpcPreprocessor.of(SessionProtocol.HTTP, Endpoint.of("foo.com"));
+        assertThatThrownBy(() -> Clients.newClient(ClientPreprocessors.ofRpc(rpcPreprocessor),
+                                                   TestServiceBlockingStub.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one preprocessor must be specified");
+    }
 }
diff --git a/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcClientTest.java b/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcClientTest.java
index 9e6eda085b0..8de02edc408 100644
--- a/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcClientTest.java
+++ b/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcClientTest.java
@@ -66,6 +66,7 @@
 import com.linecorp.armeria.client.Clients;
 import com.linecorp.armeria.client.DecoratingHttpClientFunction;
 import com.linecorp.armeria.client.Endpoint;
+import com.linecorp.armeria.client.HttpPreprocessor;
 import com.linecorp.armeria.client.ResponseTimeoutException;
 import com.linecorp.armeria.client.endpoint.EndpointGroup;
 import com.linecorp.armeria.common.CommonPools;
@@ -75,6 +76,7 @@
 import com.linecorp.armeria.common.HttpResponse;
 import com.linecorp.armeria.common.RpcRequest;
 import com.linecorp.armeria.common.RpcResponse;
+import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.common.annotation.Nullable;
 import com.linecorp.armeria.common.grpc.GrpcCallOptions;
 import com.linecorp.armeria.common.grpc.GrpcExceptionHandlerFunction;
@@ -299,6 +301,14 @@ void emptyUnary_grpcWeb() throws Exception {
         assertThat(stub.emptyCall(EMPTY)).isEqualTo(EMPTY);
     }
 
+    @Test
+    void preprocessor() throws Exception {
+        final TestServiceBlockingStub stub =
+                GrpcClients.newClient(HttpPreprocessor.of(SessionProtocol.HTTP, server.httpEndpoint()),
+                                      TestServiceBlockingStub.class);
+        assertThat(stub.emptyCall(EMPTY)).isEqualTo(EMPTY);
+    }
+
     @Test
     void contextCaptorBlocking() {
         try (ClientRequestContextCaptor ctxCaptor = Clients.newContextCaptor()) {
diff --git a/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcServicePathTest.java b/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcServicePathTest.java
index f58c6b84b8a..d41fc6864f3 100644
--- a/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcServicePathTest.java
+++ b/grpc/src/test/java/com/linecorp/armeria/client/grpc/GrpcServicePathTest.java
@@ -24,7 +24,9 @@
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.CsvSource;
 
+import com.linecorp.armeria.client.HttpPreprocessor;
 import com.linecorp.armeria.common.ExchangeType;
+import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.common.logging.RequestLog;
 import com.linecorp.armeria.internal.common.grpc.TestServiceImpl;
 import com.linecorp.armeria.server.ServerBuilder;
@@ -75,6 +77,19 @@ void prefix(String path) {
         assertThat(response.getPayload().getBody().size()).isEqualTo(10);
     }
 
+    @CsvSource({ "/grpc", "/grpc/" })
+    @ParameterizedTest
+    void preprocessorPrefix(String path) {
+        final TestServiceBlockingStub client =
+                GrpcClients.builder(HttpPreprocessor.of(SessionProtocol.HTTP, server.httpEndpoint()))
+                           .pathPrefix(path)
+                           .build(TestServiceBlockingStub.class);
+        final SimpleResponse response = client.unaryCall(SimpleRequest.newBuilder()
+                                                                      .setResponseSize(10)
+                                                                      .build());
+        assertThat(response.getPayload().getBody().size()).isEqualTo(10);
+    }
+
     @CsvSource({ "/grpc", "/grpc/", "/" })
     @ParameterizedTest
     void exchangeTypeWithPathPrefix(String path) throws InterruptedException {
diff --git a/retrofit2/src/main/java/com/linecorp/armeria/client/retrofit2/ArmeriaRetrofit.java b/retrofit2/src/main/java/com/linecorp/armeria/client/retrofit2/ArmeriaRetrofit.java
index 3bed31904c0..c4366b1ebe1 100644
--- a/retrofit2/src/main/java/com/linecorp/armeria/client/retrofit2/ArmeriaRetrofit.java
+++ b/retrofit2/src/main/java/com/linecorp/armeria/client/retrofit2/ArmeriaRetrofit.java
@@ -22,6 +22,7 @@
 
 import com.linecorp.armeria.client.Clients;
 import com.linecorp.armeria.client.Endpoint;
+import com.linecorp.armeria.client.HttpPreprocessor;
 import com.linecorp.armeria.client.WebClient;
 import com.linecorp.armeria.client.endpoint.EndpointGroup;
 import com.linecorp.armeria.common.SessionProtocol;
@@ -103,6 +104,20 @@ public static Retrofit of(SessionProtocol protocol, EndpointGroup endpointGroup,
         return builder(protocol, endpointGroup, path).build();
     }
 
+    /**
+     * TBU.
+     */
+    public static Retrofit of(HttpPreprocessor preprocessor) {
+        return builder(preprocessor).build();
+    }
+
+    /**
+     * TBU.
+     */
+    public static Retrofit of(HttpPreprocessor preprocessor, String path) {
+        return builder(preprocessor, path).build();
+    }
+
     /**
      * Returns a new {@link Retrofit} which sends requests using the specified {@link WebClient}.
      */
@@ -189,6 +204,20 @@ public static ArmeriaRetrofitBuilder builder(SessionProtocol protocol, EndpointG
         return builder(WebClient.of(protocol, endpointGroup, path));
     }
 
+    /**
+     * TBU.
+     */
+    public static ArmeriaRetrofitBuilder builder(HttpPreprocessor httpPreprocessor) {
+        return builder(WebClient.of(httpPreprocessor));
+    }
+
+    /**
+     * TBU.
+     */
+    public static ArmeriaRetrofitBuilder builder(HttpPreprocessor httpPreprocessor, String path) {
+        return builder(WebClient.of(httpPreprocessor, path));
+    }
+
     /**
      * Returns a new {@link ArmeriaRetrofitBuilder} that builds a client that sends requests using
      * the specified {@link WebClient}.
diff --git a/retrofit2/src/main/java/com/linecorp/armeria/client/retrofit2/ArmeriaRetrofitBuilder.java b/retrofit2/src/main/java/com/linecorp/armeria/client/retrofit2/ArmeriaRetrofitBuilder.java
index 2cff44b6f65..594a4fc553a 100644
--- a/retrofit2/src/main/java/com/linecorp/armeria/client/retrofit2/ArmeriaRetrofitBuilder.java
+++ b/retrofit2/src/main/java/com/linecorp/armeria/client/retrofit2/ArmeriaRetrofitBuilder.java
@@ -34,6 +34,7 @@
 import com.google.common.collect.Maps;
 
 import com.linecorp.armeria.client.AbstractClientOptionsBuilder;
+import com.linecorp.armeria.client.ClientBuilderParams;
 import com.linecorp.armeria.client.ClientFactory;
 import com.linecorp.armeria.client.ClientOption;
 import com.linecorp.armeria.client.ClientOptionValue;
@@ -218,13 +219,13 @@ public ArmeriaRetrofitBuilder validateEagerly(boolean validateEagerly) {
      * Returns a newly-created {@link Retrofit} based on the properties of this builder.
      */
     public Retrofit build() {
-        final SessionProtocol protocol = webClient.scheme().sessionProtocol();
-
         final ClientOptions retrofitOptions = buildOptions(webClient.options());
+        final ClientBuilderParams params = webClient.paramsBuilder()
+                                                    .absolutePathRef("/")
+                                                    .options(retrofitOptions)
+                                                    .build();
         // Re-create the base client without a path, because Retrofit will always provide a full path.
-        final WebClient baseWebClient = WebClient.builder(protocol, webClient.endpointGroup())
-                                                 .options(retrofitOptions)
-                                                 .build();
+        final WebClient baseWebClient = (WebClient) retrofitOptions.factory().newClient(params);
 
         if (nonBaseClientFactory == null) {
             nonBaseClientFactory =
diff --git a/retrofit2/src/test/java/com/linecorp/armeria/client/retrofit2/ArmeriaCallFactoryTest.java b/retrofit2/src/test/java/com/linecorp/armeria/client/retrofit2/ArmeriaCallFactoryTest.java
index 89021b432b7..ec09c05f04c 100644
--- a/retrofit2/src/test/java/com/linecorp/armeria/client/retrofit2/ArmeriaCallFactoryTest.java
+++ b/retrofit2/src/test/java/com/linecorp/armeria/client/retrofit2/ArmeriaCallFactoryTest.java
@@ -36,6 +36,7 @@
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.ArgumentsProvider;
 import org.junit.jupiter.params.provider.ArgumentsSource;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
@@ -44,6 +45,7 @@
 import com.linecorp.armeria.client.ClientRequestContextCaptor;
 import com.linecorp.armeria.client.Clients;
 import com.linecorp.armeria.client.Endpoint;
+import com.linecorp.armeria.client.HttpPreprocessor;
 import com.linecorp.armeria.client.WebClient;
 import com.linecorp.armeria.client.endpoint.EndpointGroup;
 import com.linecorp.armeria.common.HttpHeaderNames;
@@ -81,6 +83,7 @@
 
 @GenerateNativeImageTrace
 class ArmeriaCallFactoryTest {
+
     public static class Pojo {
         @Nullable
         @JsonProperty("name")
@@ -181,12 +184,12 @@ CompletableFuture customHeaders(
         @Override
         protected void configure(ServerBuilder sb) throws Exception {
             sb.service("/pojo", new AbstractHttpService() {
-                @Override
-                protected HttpResponse doGet(ServiceRequestContext ctx, HttpRequest req) throws Exception {
-                    return HttpResponse.of(HttpStatus.OK, MediaType.JSON_UTF_8,
-                                           "{\"name\":\"Cony\", \"age\":26}");
-                }
-            })
+                  @Override
+                  protected HttpResponse doGet(ServiceRequestContext ctx, HttpRequest req) throws Exception {
+                      return HttpResponse.of(HttpStatus.OK, MediaType.JSON_UTF_8,
+                                             "{\"name\":\"Cony\", \"age\":26}");
+                  }
+              })
               .serviceUnder("/pathWithName", new AbstractHttpService() {
 
                   @Override
@@ -632,4 +635,27 @@ public Stream provideArguments(ExtensionContext context) {
                          .map(Arguments::of);
         }
     }
+
+    public static Stream preprocessor_args() {
+        final HttpPreprocessor preprocessor = HttpPreprocessor.of(SessionProtocol.HTTP, server.httpEndpoint());
+        return Stream.of(
+                Arguments.of(
+                        ArmeriaRetrofit.builder(preprocessor)
+                                       .addConverterFactory(converterFactory)
+                                       .build()
+                                       .create(Service.class),
+                        ArmeriaRetrofit.builder(WebClient.of(preprocessor))
+                                       .addConverterFactory(converterFactory)
+                                       .build()
+                                       .create(Service.class)
+                )
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("preprocessor_args")
+    void preprocessor(Service service) throws Exception {
+        final Pojo pojo = service.pojo().get();
+        assertThat(pojo).isEqualTo(new Pojo("Cony", 26));
+    }
 }
diff --git a/scala/scala_2.13/src/main/scala/com/linecorp/armeria/client/scala/ScalaRestClientFactory.scala b/scala/scala_2.13/src/main/scala/com/linecorp/armeria/client/scala/ScalaRestClientFactory.scala
index 9aec9ce1a1e..392b23699b9 100644
--- a/scala/scala_2.13/src/main/scala/com/linecorp/armeria/client/scala/ScalaRestClientFactory.scala
+++ b/scala/scala_2.13/src/main/scala/com/linecorp/armeria/client/scala/ScalaRestClientFactory.scala
@@ -17,7 +17,7 @@
 package com.linecorp.armeria.client.scala
 
 import com.linecorp.armeria.client.{ClientBuilderParams, ClientFactory, DecoratingClientFactory, WebClient}
-import com.linecorp.armeria.common.{Scheme, SerializationFormat}
+import com.linecorp.armeria.common.SerializationFormat
 
 private[scala] final class ScalaRestClientFactory(delegate: ClientFactory)
     extends DecoratingClientFactory(delegate) {
@@ -26,13 +26,11 @@ private[scala] final class ScalaRestClientFactory(delegate: ClientFactory)
     classOf[ScalaRestClient].isAssignableFrom(clientType)
 
   override def newClient(params: ClientBuilderParams): ScalaRestClient = {
-    val scheme = params.scheme()
-    val newParams = ClientBuilderParams.of(
-      Scheme.of(SerializationFormat.NONE, scheme.sessionProtocol()),
-      params.endpointGroup(),
-      params.absolutePathRef(),
-      classOf[WebClient],
-      params.options())
+    val newParams = params
+      .paramsBuilder()
+      .serializationFormat(SerializationFormat.NONE)
+      .clientType(classOf[WebClient])
+      .build()
     val webClient = super.newClient(newParams).asInstanceOf[WebClient]
     ScalaRestClient(webClient)
   }
diff --git a/thrift/thrift0.13/src/main/java/com/linecorp/armeria/client/thrift/ThriftClientBuilder.java b/thrift/thrift0.13/src/main/java/com/linecorp/armeria/client/thrift/ThriftClientBuilder.java
index 17f31b6c8d8..7731d31be48 100644
--- a/thrift/thrift0.13/src/main/java/com/linecorp/armeria/client/thrift/ThriftClientBuilder.java
+++ b/thrift/thrift0.13/src/main/java/com/linecorp/armeria/client/thrift/ThriftClientBuilder.java
@@ -19,6 +19,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.linecorp.armeria.client.thrift.ThriftClientOptions.MAX_RESPONSE_CONTAINER_LENGTH;
 import static com.linecorp.armeria.client.thrift.ThriftClientOptions.MAX_RESPONSE_STRING_LENGTH;
+import static com.linecorp.armeria.internal.client.ClientBuilderParamsUtil.preprocessorToUri;
 import static java.util.Objects.requireNonNull;
 
 import java.net.URI;
@@ -51,6 +52,7 @@
 import com.linecorp.armeria.common.RequestId;
 import com.linecorp.armeria.common.Scheme;
 import com.linecorp.armeria.common.SerializationFormat;
+import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.common.SuccessFunction;
 import com.linecorp.armeria.common.annotation.Nullable;
 import com.linecorp.armeria.common.annotation.UnstableApi;
@@ -94,6 +96,16 @@ public final class ThriftClientBuilder extends AbstractClientOptionsBuilder {
         this.endpointGroup = endpointGroup;
     }
 
+    ThriftClientBuilder(SerializationFormat serializationFormat, RpcPreprocessor rpcPreprocessor) {
+        requireNonNull(serializationFormat, "serializationFormat");
+        requireNonNull(rpcPreprocessor, "rpcPreprocessor");
+        scheme = Scheme.of(serializationFormat, SessionProtocol.UNDEFINED);
+        validateOrSetSerializationFormat();
+        uri = preprocessorToUri(scheme, rpcPreprocessor, null);
+        endpointGroup = null;
+        rpcPreprocessor(rpcPreprocessor);
+    }
+
     private void validateOrSetSerializationFormat() {
         if (scheme.serializationFormat() == SerializationFormat.NONE) {
             // If not set, TBinary is used as a default serialization format.
diff --git a/thrift/thrift0.13/src/main/java/com/linecorp/armeria/client/thrift/ThriftClients.java b/thrift/thrift0.13/src/main/java/com/linecorp/armeria/client/thrift/ThriftClients.java
index bf3c1915d90..5b92a32c970 100644
--- a/thrift/thrift0.13/src/main/java/com/linecorp/armeria/client/thrift/ThriftClients.java
+++ b/thrift/thrift0.13/src/main/java/com/linecorp/armeria/client/thrift/ThriftClients.java
@@ -21,6 +21,7 @@
 import java.net.URI;
 
 import com.linecorp.armeria.client.ClientFactory;
+import com.linecorp.armeria.client.RpcPreprocessor;
 import com.linecorp.armeria.client.endpoint.EndpointGroup;
 import com.linecorp.armeria.common.Scheme;
 import com.linecorp.armeria.common.SerializationFormat;
@@ -156,6 +157,30 @@ public static  T newClient(SessionProtocol protocol, EndpointGroup endpointGr
         return builder(protocol, endpointGroup).path(path).build(clientType);
     }
 
+    /**
+     * TBU.
+     */
+    public static  T newClient(RpcPreprocessor rpcPreprocessor, Class clientType) {
+        return builder(rpcPreprocessor).build(clientType);
+    }
+
+    /**
+     * TBU.
+     */
+    public static  T newClient(SerializationFormat serializationFormat,
+                                  RpcPreprocessor rpcPreprocessor, Class clientType) {
+        return builder(serializationFormat, rpcPreprocessor).build(clientType);
+    }
+
+    /**
+     * TBU.
+     */
+    public static  T newClient(SerializationFormat serializationFormat,
+                                  RpcPreprocessor rpcPreprocessor, Class clientType,
+                                  String path) {
+        return builder(serializationFormat, rpcPreprocessor).path(path).build(clientType);
+    }
+
     /**
      * Returns a new {@link ThriftClientBuilder} that builds the client that connects to the specified
      * {@code uri}.
@@ -220,5 +245,23 @@ public static ThriftClientBuilder builder(Scheme scheme, EndpointGroup endpointG
         return new ThriftClientBuilder(scheme, endpointGroup);
     }
 
+    /**
+     * TBU.
+     */
+    public static ThriftClientBuilder builder(RpcPreprocessor rpcPreprocessor) {
+        return new ThriftClientBuilder(SerializationFormat.NONE,
+                                       requireNonNull(rpcPreprocessor, "rpcPreprocessor"));
+    }
+
+    /**
+     * TBU.
+     */
+    public static ThriftClientBuilder builder(SerializationFormat serializationFormat,
+                                              RpcPreprocessor rpcPreprocessor) {
+        requireNonNull(serializationFormat, "serializationFormat");
+        requireNonNull(rpcPreprocessor, "rpcPreprocessor");
+        return new ThriftClientBuilder(serializationFormat, rpcPreprocessor);
+    }
+
     private ThriftClients() {}
 }
diff --git a/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/client/thrift/THttpClientFactory.java b/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/client/thrift/THttpClientFactory.java
index 60e830d77bd..5d856295c61 100644
--- a/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/client/thrift/THttpClientFactory.java
+++ b/thrift/thrift0.13/src/main/java/com/linecorp/armeria/internal/client/thrift/THttpClientFactory.java
@@ -78,11 +78,10 @@ public Object newClient(ClientBuilderParams params) {
         }
 
         // Create a THttpClient without path.
-        final ClientBuilderParams delegateParams =
-                ClientBuilderParams.of(params.scheme(),
-                                       params.endpointGroup(),
-                                       "/", THttpClient.class,
-                                       options);
+        final ClientBuilderParams delegateParams = params.paramsBuilder()
+                                                         .absolutePathRef("/")
+                                                         .clientType(THttpClient.class)
+                                                         .build();
 
         final THttpClient thriftClient = new DefaultTHttpClient(delegateParams, delegate, meterRegistry());
 
@@ -91,4 +90,15 @@ public Object newClient(ClientBuilderParams params) {
                 new Class[] { clientType },
                 new THttpClientInvocationHandler(params, thriftClient));
     }
+
+    @Override
+    public ClientBuilderParams validateParams(ClientBuilderParams params) {
+        if (params.scheme().sessionProtocol() == SessionProtocol.UNDEFINED &&
+            params.options().clientPreprocessors().rpcPreprocessors().isEmpty()) {
+            throw new IllegalArgumentException(
+                    "At least one rpcPreprocessor must be specified for rpc-based clients " +
+                    "with sessionProtocol '" + params.scheme().sessionProtocol() + "'.");
+        }
+        return super.validateParams(params);
+    }
 }
diff --git a/thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientBuilderTest.java b/thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientBuilderTest.java
index a5ca1f4a0f0..81321da04ec 100644
--- a/thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientBuilderTest.java
+++ b/thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientBuilderTest.java
@@ -23,19 +23,30 @@
 import java.util.Arrays;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.stream.Stream;
 
 import org.apache.thrift.transport.TTransportException;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.reflections.ReflectionUtils;
 
 import com.linecorp.armeria.client.AbstractClientOptionsBuilder;
 import com.linecorp.armeria.client.ClientBuilderParams;
+import com.linecorp.armeria.client.ClientPreprocessors;
 import com.linecorp.armeria.client.Clients;
 import com.linecorp.armeria.client.Endpoint;
+import com.linecorp.armeria.client.HttpPreprocessor;
+import com.linecorp.armeria.client.PreClient;
 import com.linecorp.armeria.common.HttpRequest;
+import com.linecorp.armeria.common.Scheme;
 import com.linecorp.armeria.common.SerializationFormat;
+import com.linecorp.armeria.common.SessionProtocol;
 import com.linecorp.armeria.common.stream.AbortedStreamException;
 import com.linecorp.armeria.common.thrift.ThriftSerializationFormats;
+import com.linecorp.armeria.internal.client.ClientBuilderParamsUtil;
+import com.linecorp.armeria.internal.client.endpoint.FailingEndpointGroup;
 import com.linecorp.armeria.internal.testing.AnticipatedException;
 
 import testing.thrift.main.HelloService;
@@ -71,6 +82,20 @@ void endpointWithPath() {
         assertThat(params.scheme().serializationFormat()).isSameAs(ThriftSerializationFormats.BINARY);
     }
 
+    @Test
+    void undefinedProtocol() {
+        assertThatThrownBy(() -> ThriftClients
+                .newClient(Scheme.of(ThriftSerializationFormats.BINARY, SessionProtocol.UNDEFINED),
+                           Endpoint.of("1.2.3.4"), HelloService.Iface.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one rpcPreprocessor must be specified");
+
+        assertThatThrownBy(() -> ThriftClients
+                .newClient("undefined://1.2.3.4", HelloService.Iface.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one rpcPreprocessor must be specified");
+    }
+
     @Test
     void httpRequestIsAbortedIfDecoratorThrowException() throws Exception {
         final CompletableFuture reqCaptor = new CompletableFuture<>();
@@ -166,4 +191,43 @@ void apiConsistency() {
             }).hasSize(1);
         }
     }
+
+    @Test
+    void preprocessorThrows() {
+        final HttpPreprocessor preprocessor =
+                HttpPreprocessor.of(SessionProtocol.HTTP, Endpoint.of("foo.com"));
+        final ThriftClientBuilder builder = ThriftClients.builder("http://foo.com");
+        assertThatThrownBy(() -> builder.preprocessor(preprocessor))
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("preprocessor() does not support Thrift");
+
+        assertThatThrownBy(() -> Clients.newClient(ThriftSerializationFormats.BINARY,
+                                                   ClientPreprocessors.of(preprocessor),
+                                                   HelloService.Iface.class))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("At least one rpcPreprocessor must be specified");
+    }
+
+    public static Stream preprocessParams_args() {
+        return Stream.of(
+                Arguments.of(ThriftClients.newClient(PreClient::execute,
+                                                     THttpClient.class), "/"),
+                Arguments.of(ThriftClients.builder(PreClient::execute)
+                                          .path("/prefix")
+                                          .build(THttpClient.class), "/prefix")
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("preprocessParams_args")
+    void preprocessParams(ClientBuilderParams params, String expectedPrefix) {
+        assertThat(params.scheme()).isEqualTo(Scheme.of(ThriftSerializationFormats.BINARY,
+                                                        SessionProtocol.UNDEFINED));
+        assertThat(params.endpointGroup()).isInstanceOf(FailingEndpointGroup.class);
+        assertThat(params.absolutePathRef()).isEqualTo(expectedPrefix);
+        assertThat(params.uri().getRawAuthority()).startsWith("armeria-preprocessor");
+        assertThat(params.uri().getScheme()).isEqualTo("tbinary+undefined");
+        assertThat(ClientBuilderParamsUtil.isInternalUri(params.uri())).isTrue();
+        assertThat(Clients.isUndefinedUri(params.uri())).isFalse();
+    }
 }
diff --git a/thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientExchangeTypeTest.java b/thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientExchangeTypeTest.java
deleted file mode 100644
index a060415d7b6..00000000000
--- a/thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientExchangeTypeTest.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- *  Copyright 2022 LINE Corporation
- *
- *  LINE Corporation 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:
- *
- *    https://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.
- */
-
-package com.linecorp.armeria.client.thrift;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import org.apache.thrift.TException;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.RegisterExtension;
-
-import com.linecorp.armeria.client.ClientRequestContext;
-import com.linecorp.armeria.client.ClientRequestContextCaptor;
-import com.linecorp.armeria.client.Clients;
-import com.linecorp.armeria.common.ExchangeType;
-import com.linecorp.armeria.common.thrift.ThriftSerializationFormats;
-import com.linecorp.armeria.server.ServerBuilder;
-import com.linecorp.armeria.server.thrift.THttpService;
-import com.linecorp.armeria.testing.junit5.server.ServerExtension;
-
-import testing.thrift.main.HelloService;
-
-class ThriftClientExchangeTypeTest {
-    @RegisterExtension
-    static ServerExtension server = new ServerExtension() {
-        @Override
-        protected void configure(ServerBuilder sb) {
-            sb.service("/hello", THttpService.of((HelloService.AsyncIface) (name, resultHandler)
-                    -> resultHandler.onComplete("Hello, " + name + '!')));
-        }
-    };
-
-    @Test
-    void unaryExchangeType() throws TException {
-        try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
-            final HelloService.Iface client =
-                    Clients.newClient(server.httpUri(ThriftSerializationFormats.BINARY).resolve("/hello"),
-                                      HelloService.Iface.class);
-            assertThat(client.hello("Armeria")).isEqualTo("Hello, Armeria!");
-            final ClientRequestContext ctx = captor.get();
-            assertThat(ctx.exchangeType()).isEqualTo(ExchangeType.UNARY);
-        }
-    }
-}
diff --git a/thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientTest.java b/thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientTest.java
new file mode 100644
index 00000000000..6711655d325
--- /dev/null
+++ b/thrift/thrift0.13/src/test/java/com/linecorp/armeria/client/thrift/ThriftClientTest.java
@@ -0,0 +1,109 @@
+/*
+ *  Copyright 2022 LINE Corporation
+ *
+ *  LINE Corporation 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:
+ *
+ *    https://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.
+ */
+
+package com.linecorp.armeria.client.thrift;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.lang.reflect.Proxy;
+import java.util.stream.Stream;
+
+import org.apache.thrift.TException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import com.linecorp.armeria.client.ClientBuilderParams;
+import com.linecorp.armeria.client.ClientOptions;
+import com.linecorp.armeria.client.ClientRequestContext;
+import com.linecorp.armeria.client.ClientRequestContextCaptor;
+import com.linecorp.armeria.client.Clients;
+import com.linecorp.armeria.client.RpcPreprocessor;
+import com.linecorp.armeria.common.ExchangeType;
+import com.linecorp.armeria.common.SessionProtocol;
+import com.linecorp.armeria.common.thrift.ThriftSerializationFormats;
+import com.linecorp.armeria.server.ServerBuilder;
+import com.linecorp.armeria.server.thrift.THttpService;
+import com.linecorp.armeria.testing.junit5.server.ServerExtension;
+
+import testing.thrift.main.HelloService;
+
+class ThriftClientTest {
+
+    @RegisterExtension
+    static ServerExtension server = new ServerExtension() {
+        @Override
+        protected void configure(ServerBuilder sb) {
+            sb.service("/hello", THttpService.of((HelloService.AsyncIface) (name, resultHandler)
+                    -> resultHandler.onComplete("Hello, " + name + '!')));
+            sb.service("/", THttpService.of((HelloService.AsyncIface) (name, resultHandler)
+                    -> resultHandler.onComplete(name)));
+            sb.service("/compact", THttpService.builder()
+                                               .defaultSerializationFormat(ThriftSerializationFormats.COMPACT)
+                                               .addService((HelloService.AsyncIface) (name, resultHandler)
+                                                       -> resultHandler.onComplete("Compact " + name))
+                                               .build());
+        }
+    };
+
+    @Test
+    void unaryExchangeType() throws TException {
+        try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
+            final HelloService.Iface client =
+                    Clients.newClient(server.httpUri(ThriftSerializationFormats.BINARY).resolve("/hello"),
+                                      HelloService.Iface.class);
+            assertThat(client.hello("Armeria")).isEqualTo("Hello, Armeria!");
+            final ClientRequestContext ctx = captor.get();
+            assertThat(ctx.exchangeType()).isEqualTo(ExchangeType.UNARY);
+        }
+    }
+
+    private static Stream preprocessors_args() {
+        final RpcPreprocessor rpcPreprocessor =
+                RpcPreprocessor.of(SessionProtocol.HTTP, server.httpEndpoint());
+        return Stream.of(
+                Arguments.of(ThriftClients.builder(rpcPreprocessor)
+                                          .build(HelloService.Iface.class), "Armeria"),
+                Arguments.of(ThriftClients.newClient(rpcPreprocessor, HelloService.Iface.class), "Armeria"),
+                Arguments.of(ThriftClients.newClient(
+                        ThriftSerializationFormats.COMPACT,
+                        rpcPreprocessor, HelloService.Iface.class, "/compact"), "Compact Armeria"),
+                Arguments.of(ThriftClients.builder(rpcPreprocessor)
+                                          .path("/hello")
+                                          .build(HelloService.Iface.class), "Hello, Armeria!"),
+                Arguments.of(ThriftClients.newClient(ThriftSerializationFormats.BINARY,
+                                                     rpcPreprocessor, HelloService.Iface.class, "/hello"),
+                             "Hello, Armeria!")
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("preprocessors_args")
+    void preprocessors(HelloService.Iface iface, String expected) throws Exception {
+        final ClientBuilderParams params = (ClientBuilderParams) Proxy.getInvocationHandler(iface);
+        assertThat(iface.hello("Armeria")).isEqualTo(expected);
+
+        final HelloService.Iface derived = Clients.newDerivedClient(
+                iface, ClientOptions.WRITE_TIMEOUT_MILLIS.newValue(Long.MAX_VALUE));
+        assertThat(derived.hello("Armeria")).isEqualTo(expected);
+        final ClientBuilderParams derivedParams = (ClientBuilderParams) Proxy.getInvocationHandler(derived);
+
+        assertThat(params.options().clientPreprocessors())
+                .isEqualTo(derivedParams.options().clientPreprocessors());
+    }
+}