Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initializer API for headers of HTTP proxy CONNECT request #2744

Merged
merged 5 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import io.servicetalk.grpc.api.GrpcStatusCode;
import io.servicetalk.grpc.api.GrpcStatusException;
import io.servicetalk.http.api.HttpResponseStatus;
import io.servicetalk.http.api.ProxyConfig;
import io.servicetalk.http.api.ProxyConnectResponseException;
import io.servicetalk.http.api.StreamingHttpConnectionFilter;
import io.servicetalk.http.api.StreamingHttpRequest;
Expand Down Expand Up @@ -89,7 +90,7 @@ class GrpcProxyTunnelTest {
.listenAndAwait((Greeter.BlockingGreeterService) (ctx, request) ->
HelloReply.newBuilder().setMessage(GREETING_PREFIX + request.getName()).build());
client = GrpcClients.forAddress(serverHostAndPort(serverContext))
.initializeHttp(httpBuilder -> httpBuilder.proxyAddress(proxyAddress)
.initializeHttp(httpBuilder -> httpBuilder.proxyConfig(ProxyConfig.of(proxyAddress))
Copy link
Member Author

Choose a reason for hiding this comment

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

I debated between ProxyConfig.forAddress(proxyAddress) and ProxyConfig.of(proxyAddress). Went with of only because wanted to be consistent with similar static method on other interfaces/classes, like HostAndPort, HttpRequestMethod, etc. If of doesn't read well for this use-case, happy to rename it by your recommendations.

.sslConfig(new ClientSslConfigBuilder(DefaultTestCerts::loadServerCAPem)
.peerHost(serverPemHostname()).build())
.appendConnectionFilter(connection -> new StreamingHttpConnectionFilter(connection) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,18 @@ public String toString() {
}

@Override
@SuppressWarnings("deprecation")
public SingleAddressHttpClientBuilder<U, R> proxyAddress(final U proxyAddress) {
delegate = delegate.proxyAddress(proxyAddress);
return this;
}

@Override
public SingleAddressHttpClientBuilder<U, R> proxyConfig(final ProxyConfig<U> proxyConfig) {
delegate = delegate.proxyConfig(proxyConfig);
return this;
}

@Override
public <T> SingleAddressHttpClientBuilder<U, R> socketOption(final SocketOption<T> option, final T value) {
delegate = delegate.socketOption(option, value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public final class HttpContextKeys {
* <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Proxy_servers_and_tunneling#http_tunneling">secure
* HTTP proxy tunneling</a> and a clear text HTTP proxy, check presence of {@link ConnectionInfo#sslConfig()}.
*
* @see SingleAddressHttpClientBuilder#proxyAddress(Object)
* @see SingleAddressHttpClientBuilder#proxyConfig(ProxyConfig)
* @deprecated Use {@link TransportObserverConnectionFactoryFilter} to configure {@link TransportObserver} and then
* listen {@link ConnectionObserver#onProxyConnect(Object)} callback to distinguish between a regular connection and
* a connection to the secure HTTP proxy tunnel. For clear text HTTP proxies, consider installing a custom client
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright © 2023 Apple Inc. and the ServiceTalk project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.servicetalk.http.api;

import io.servicetalk.transport.api.ClientSslConfig;

import java.util.function.Consumer;

/**
* Configuration for a proxy.
*
* @param <A> the type of address
*/
public interface ProxyConfig<A> {
Copy link
Member Author

Choose a reason for hiding this comment

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

For others who review: this may look like an overkill to add a new "config" API while we need only one more config parameter, which could be a 2nd argument for original proxyAddress(...) method, like it was implemented in #2698. However, we discussed with @daschl and agreed that having a config object now (similar to RedirectConfig) will minimize API changes on SingleAddressHttpClientBuilder in the future, when we may need to add more options, like:

  1. Timeout for CONNECT request, similar to what Netty does: https://github.com/netty/netty/blob/7e18e8344819428a5560064ac15a2f035b4728fd/handler-proxy/src/main/java/io/netty/handler/proxy/ProxyHandler.java#L57
  2. SOCKS proxy config options

Note that SingleAddressHttpClientBuilder is used by users when they rely on HttpProviders. So, it's important to minimize API friction for them.


/**
* Address of the proxy.
* <p>
* Usually, this is an unresolved proxy address and its type must match the type of address before resolution used
* by {@link SingleAddressHttpClientBuilder}. However, if the client builder was created for a resolved address,
* this address must also be already resolved. Otherwise, a runtime exceptions will occur.
*
* @return address of the proxy
*/
A address();

/**
* An initializer for {@link HttpHeaders} related to
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request.
* <p>
* When this {@link ProxyConfig} is used for secure proxy tunnels (when
* {@link SingleAddressHttpClientBuilder#sslConfig(ClientSslConfig) ClientSslConfig} is configured) the tunnel is
* always initialized using {@code HTTP/1.1 CONNECT} request. This {@link Consumer} can be used to set custom
* {@link HttpHeaders} for that request, like {@link HttpHeaderNames#PROXY_AUTHORIZATION proxy-authorization},
* tracing, or any other header.
*
Copy link
Contributor

Choose a reason for hiding this comment

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

If (are we?) doing any retries on the connect request, this comment I think should clarified if it is called N times or just once.

Copy link
Member Author

Choose a reason for hiding this comment

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

No, there are no retries specifically for a CONNECT request. In case of a general retry, it will go through process of opening a new connection and then it's not different from any other new connection event, regardless of if it was retried or not.

* @return An initializer for {@link HttpHeaders} related to
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request
*/
Consumer<HttpHeaders> connectRequestHeadersInitializer();

/**
* Returns a {@link ProxyConfig} for the specified {@code address}.
* <p>
* All other {@link ProxyConfig} options will use their default values applied by {@link ProxyConfigBuilder}.
*
* @param address Address of the proxy
* @param <A> the type of address
* @return a {@link ProxyConfig} for the specified {@code address}
* @see ProxyConfigBuilder
*/
static <A> ProxyConfig<A> of(A address) {
Copy link
Contributor

Choose a reason for hiding this comment

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

"forAddress" I think reads easier (since you are creating a proxy config for an address, not of an address), but as you mentioned using of aligns it with HostAndPort. I'm ok either way and it's not a big deal.

Copy link
Contributor

Choose a reason for hiding this comment

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

Another option would be to not have this static method at all and let users go through the builder all the time?

Copy link
Member Author

Choose a reason for hiding this comment

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

I had it originally and it looked quite verbose

Copy link
Member Author

Choose a reason for hiding this comment

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

I will rename to forAddress to make it more readable when someone uses a static import.

return new ProxyConfigBuilder<>(address).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright © 2023 Apple Inc. and the ServiceTalk project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.servicetalk.http.api;

import java.util.function.Consumer;

import static java.util.Objects.requireNonNull;

/**
* Builder for {@link ProxyConfig}.
*
* @param <A> the type of address
*/
public final class ProxyConfigBuilder<A> {

private static final Consumer<HttpHeaders> NOOP_HEADERS_CONSUMER = new Consumer<HttpHeaders>() {
@Override
public void accept(final HttpHeaders headers) {
}

@Override
public String toString() {
return "NOOP";
}
};

private final A address;
private Consumer<HttpHeaders> connectRequestHeadersInitializer = NOOP_HEADERS_CONSUMER;

/**
* Creates a new instance.
*
* @param address Proxy address
* @see ProxyConfig#address()
*/
public ProxyConfigBuilder(final A address) {
this.address = requireNonNull(address);
}

/**
* Sets an initializer for {@link HttpHeaders} related to
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request.
*
* @param connectRequestHeadersInitializer {@link Consumer} that can be used to set custom {@link HttpHeaders} for
* {@code HTTP/1.1 CONNECT} request (auth, tracing, etc.)
* @return {@code this}
* @see ProxyConfig#connectRequestHeadersInitializer()
*/
public ProxyConfigBuilder<A> connectRequestHeadersInitializer(
final Consumer<HttpHeaders> connectRequestHeadersInitializer) {
this.connectRequestHeadersInitializer = requireNonNull(connectRequestHeadersInitializer);
return this;
}

/**
* Builds a new {@link ProxyConfig}.
*
* @return a new {@link ProxyConfig}.
*/
public ProxyConfig<A> build() {
return new DefaultProxyConfig<>(address, connectRequestHeadersInitializer);
}

private static final class DefaultProxyConfig<A> implements ProxyConfig<A> {

private final A address;
private final Consumer<HttpHeaders> connectRequestHeadersInitializer;

private DefaultProxyConfig(final A address, final Consumer<HttpHeaders> connectRequestHeadersInitializer) {
this.address = address;
this.connectRequestHeadersInitializer = connectRequestHeadersInitializer;
}

@Override
public A address() {
return address;
}

@Override
public Consumer<HttpHeaders> connectRequestHeadersInitializer() {
return connectRequestHeadersInitializer;
}

@Override
public boolean equals(final Object o) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Because one of the fields is a Consumer, which is going to often be set by anonymous lambda, I think notions of equality and hash-code are going to be error prone. Does this class need to have such notions?

Copy link
Member Author

@idelpivnitskiy idelpivnitskiy Nov 8, 2023

Choose a reason for hiding this comment

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

I think it's fine to keep best effort and we can rely on users to care about equality for consumer. If they don't care, having different lambdas will make configs different. If they care, they must provide the same instance of Consumer or override equals/hashCode for consumer.

Copy link
Contributor

Choose a reason for hiding this comment

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

If that's an invariant which is important for the semantics of the executed code, I think we should point this out in a javadoc somewhere.

Copy link
Member Author

Choose a reason for hiding this comment

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

It feels kind of obvious that if users pass 2 different lambdas, the resulting "pojo" will be different. Note that there is no use-case for equals/hashCode right now to really worry about it. This is a pkg-private class, which makes it harder to document impl details. Not sure if we need to put this info on interface bcz it's not required right now.

if (this == o) {
return true;
}
if (!(o instanceof DefaultProxyConfig)) {
return false;
}

final DefaultProxyConfig<?> that = (DefaultProxyConfig<?>) o;
if (!address.equals(that.address)) {
return false;
}
return connectRequestHeadersInitializer.equals(that.connectRequestHeadersInitializer);
}

@Override
public int hashCode() {
int result = address.hashCode();
result = 31 * result + connectRequestHeadersInitializer.hashCode();
return result;
}

@Override
public String toString() {
return getClass().getSimpleName() +
"{address=" + address +
", connectRequestHeadersInitializer=" + connectRequestHeadersInitializer +
'}';
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* An exception while processing
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request.
*
* @see SingleAddressHttpClientBuilder#proxyAddress(Object)
* @see SingleAddressHttpClientBuilder#proxyConfig(ProxyConfig)
*/
public class ProxyConnectException extends IOException {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* A subclass of {@link ProxyConnectException} that indicates an unexpected response status from a proxy received for
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request.
*
* @see SingleAddressHttpClientBuilder#proxyAddress(Object)
* @see SingleAddressHttpClientBuilder#proxyConfig(ProxyConfig)
*/
public class ProxyConnectResponseException extends ProxyConnectException {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@ public RedirectConfigBuilder redirectRequestTransformer(final RedirectRequestTra
return this;
}

/**
* Builds a new {@link RedirectConfig}.
*
* @return a new {@link RedirectConfig}
*/
public RedirectConfig build() {
return new DefaultRedirectConfig(maxRedirects,
allowedStatuses == null ? DEFAULT_ALLOWED_STATUSES : toSet(allowedStatuses),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
*/
public interface SingleAddressHttpClientBuilder<U, R> extends HttpClientBuilder<U, R, ServiceDiscovererEvent<R>> {
/**
* Configure proxy to serve as an intermediary for requests.
* Configures a proxy to serve as an intermediary for requests.
* <p>
* If the client talks to a proxy over http (not https, {@link #sslConfig(ClientSslConfig) ClientSslConfig} is NOT
* configured), it will rewrite the request-target to
Expand All @@ -65,10 +65,40 @@ public interface SingleAddressHttpClientBuilder<U, R> extends HttpClientBuilder<
* @param proxyAddress Unresolved address of the proxy. When used with a builder created for a resolved address,
* {@code proxyAddress} should also be already resolved – otherwise runtime exceptions may occur.
* @return {@code this}.
* @deprecated Use {@link #proxyConfig(ProxyConfig)} with {@link ProxyConfig#of(Object)}.
*/
default SingleAddressHttpClientBuilder<U, R> proxyAddress(U proxyAddress) { // FIXME: 0.43 - remove default impl
throw new UnsupportedOperationException("Setting proxy address is not yet supported by "
+ getClass().getName());
// FIXME: 0.43 - remove deprecated method
@Deprecated
@SuppressWarnings("DeprecatedIsStillUsed")
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we keep this method for convenience? Related to my suggestions of the naming of the factory method: if we force them to go through the builder I would keep this convenience method, if we have the static factory I guess there is no harm in removing this since its going to be very short anyways.

Copy link
Member Author

Choose a reason for hiding this comment

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

Usually, users have more confusion when there are multiple overloads on the builder for the same functionality. Especially when they override each other. People don't really read javadoc and as a result, there is a high chance of confusing their understanding of runtime behavior when they use both of them:

builder.proxyAddress(address)
    .proxyConfig(config)

Another use-case is HttpProviders. It makes it harder to implement a provider that need to track 2 different methods for the same functionality. As a result, I proposed a static factory.

Copy link
Contributor

Choose a reason for hiding this comment

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

good point, makes sense. thanks!

default SingleAddressHttpClientBuilder<U, R> proxyAddress(U proxyAddress) {
proxyConfig(new ProxyConfigBuilder<>(proxyAddress).build());
return this;
}

/**
* Configures a proxy to serve as an intermediary for requests.
* <p>
* If the client talks to a proxy over http (not https, {@link #sslConfig(ClientSslConfig) ClientSslConfig} is NOT
* configured), it will rewrite the request-target to
* <a href="https://tools.ietf.org/html/rfc7230#section-5.3.2">absolute-form</a>, as specified by the RFC.
* <p>
* For secure proxy tunnels (when {@link #sslConfig(ClientSslConfig) ClientSslConfig} is configured) the tunnel is
* always initialized using
* <a href="https://datatracker.ietf.org/doc/html/rfc9110#section-9.3.6">HTTP/1.1 CONNECT</a> request. The actual
* protocol will be negotiated via <a href="https://tools.ietf.org/html/rfc7301">ALPN extension</a> of TLS protocol,
* taking into account HTTP protocols configured via {@link #protocols(HttpProtocolConfig...)} method. In case of
* any error during {@code CONNECT} process, {@link ProxyConnectException} or {@link ProxyConnectResponseException}
* will be thrown when a request attempt is made through the constructed client instance.
*
* @param proxyConfig Configuration for a proxy. When used with a builder created for a resolved address,
* {@link ProxyConfig#address()} must also be already resolved – otherwise runtime exceptions will occur.
* @return {@code this}.
* @see ProxyConfig#of(Object)
* @see ProxyConfigBuilder
*/
// FIXME: 0.43 - consider removing default impl
default SingleAddressHttpClientBuilder<U, R> proxyConfig(ProxyConfig<U> proxyConfig) {
throw new UnsupportedOperationException("Setting proxy config is not yet supported by " + getClass().getName());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import io.servicetalk.http.api.HttpLoadBalancerFactory;
import io.servicetalk.http.api.HttpProtocolConfig;
import io.servicetalk.http.api.HttpProtocolVersion;
import io.servicetalk.http.api.ProxyConfig;
import io.servicetalk.http.api.SingleAddressHttpClientBuilder;
import io.servicetalk.http.api.StreamingHttpClient;
import io.servicetalk.http.api.StreamingHttpClientFilter;
Expand Down Expand Up @@ -242,10 +243,10 @@ public HttpExecutionStrategy executionStrategy() {
ctx.builder.strategyComputation.buildForConnectionFactory();

if (roConfig.hasProxy() && sslContext != null) {
assert roConfig.connectAddress() != null;
assert roConfig.proxyConfig() != null;
@SuppressWarnings("deprecation")
final ConnectionFactoryFilter<R, FilterableStreamingHttpConnection> proxy =
new ProxyConnectConnectionFactoryFilter<>(roConfig.connectAddress(), connectionFactoryStrategy);
new ProxyConnectConnectionFactoryFilter<>(roConfig.proxyConfig().address());
assert !proxy.requiredOffloads().hasOffloads();
connectionFactoryFilter = appendConnectionFilter(proxy, connectionFactoryFilter);
}
Expand Down Expand Up @@ -446,9 +447,9 @@ private AbsoluteAddressHttpRequesterFilter proxyAbsoluteAddressFilterFactory() {
}

@Override
public DefaultSingleAddressHttpClientBuilder<U, R> proxyAddress(final U proxyAddress) {
this.proxyAddress = requireNonNull(proxyAddress);
config.connectAddress(hostToCharSequenceFunction.apply(address));
public DefaultSingleAddressHttpClientBuilder<U, R> proxyConfig(final ProxyConfig<U> proxyConfig) {
this.proxyAddress = requireNonNull(proxyConfig.address());
config.proxyConfig(hostToCharSequenceFunction.apply(address), proxyConfig);
return this;
}

Expand Down
Loading