From c382b4a91d3117f4e4b7c7bf2e3361631713668d Mon Sep 17 00:00:00 2001
From: vam-google <vam@google.com>
Date: Tue, 11 Jan 2022 20:11:29 -0800
Subject: [PATCH 1/7] feat: Introduce HttpJsonClientCall, Listeners
 infrastructure and ServerStreaming support in REST transport

This includes the following changes for HTTP1.1/REST transport:
1) `HttpJsonClientCall` class (with `HttpJsonClientCall.Listener`) mimicking [io.grpc.ClientCall](https://github.com/grpc/grpc-java/blob/master/api/src/main/java/io/grpc/ClientCall.java#L102) functionality.
2) The unary callables are rewritten to be based on `HttpJsonClientCall` flow (similarly to how it is already done in grpc unary calls).
3) Server streaming support for REST transport. The implementation is based on `HttpJsonClientCall` and `HttpJsonClientCall.Listener`, similarly to how grpc streaming is based on `io.grpc.ClientCall` and io.grpc.ClientCall.Listener` respectively.

The extreme similarity between `HttpJsonClient` call and `io.grpc.ClientCall` is intentional and crucial for consistency of the two transports and also intends simplifying creation and maintenance of multi-transport manual wrappers (like [google-ads](https://github.com/googleads/google-ads-java/blob/main/google-ads/src/main/java/com/google/ads/googleads/lib/logging/LoggingInterceptor.java#L68)).

The server streaming abstractions in gax java are all based on the flow control managed by a ClientCall, so having similar set of abstractions in REST transport is necessary to reuse transport-independent portions of streaming logic in gax and maintain identical user-facing streaming surface.

This PR also builds a foundation for the soon-coming [ClientInterceptor](https://github.com/grpc/grpc-java/blob/master/api/src/main/java/io/grpc/ClientInterceptor.java#L42)-like infrastructure in REST transport.

REST-based client-side streaming and bidirectional streaming is not implemented by this PR and most likely will never be due to limitations of the HTTP1.1/REST protocol compared to HTTP2/gRPC.

Note, ideally I need to split this PR at least in two separate ones: 1) HttpJsonClientCall stuff and unary calls based on it in one PR and then 2) server streaming feature in a second PR. Unfortunately only reasonable way to test `HttpJsonClientCall` infrastructure is by doing it from server streaming logic beause most of the complexity introduced in HttpJsonClient call is induced by necessity to support streaming workflow.
---
 ...GrpcDirectServerStreamingCallableTest.java |  10 +-
 .../ApiMessageHttpResponseParser.java         |  20 +-
 .../api/gax/httpjson/ApiMethodDescriptor.java |  15 +-
 .../httpjson/HttpJsonApiExceptionFactory.java |  81 +++
 .../api/gax/httpjson/HttpJsonCallContext.java | 100 ++--
 .../api/gax/httpjson/HttpJsonCallOptions.java |  29 +
 .../gax/httpjson/HttpJsonCallableFactory.java |  24 +
 .../api/gax/httpjson/HttpJsonChannel.java     |   4 +
 .../api/gax/httpjson/HttpJsonClientCall.java  | 158 +++++
 .../gax/httpjson/HttpJsonClientCallImpl.java  | 539 ++++++++++++++++++
 .../api/gax/httpjson/HttpJsonClientCalls.java | 140 +++++
 .../gax/httpjson/HttpJsonDirectCallable.java  |  25 +-
 ...HttpJsonDirectServerStreamingCallable.java |  69 +++
 .../HttpJsonDirectStreamController.java       | 126 ++++
 .../httpjson/HttpJsonExceptionCallable.java   |  29 +-
 .../HttpJsonExceptionResponseObserver.java    |  91 +++
 ...pJsonExceptionServerStreamingCallable.java |  65 +++
 .../api/gax/httpjson/HttpJsonMetadata.java    |  67 +++
 .../httpjson/HttpJsonTransportChannel.java    |   4 +
 .../api/gax/httpjson/HttpRequestRunnable.java | 206 ++++---
 .../api/gax/httpjson/HttpResponseParser.java  |  13 +-
 .../InstantiatingHttpJsonChannelProvider.java |  12 +-
 .../gax/httpjson/ManagedHttpJsonChannel.java  |  59 +-
 .../ProtoMessageJsonStreamIterator.java       | 134 +++++
 .../httpjson/ProtoMessageResponseParser.java  |  25 +-
 .../api/gax/httpjson/ProtoRestSerializer.java |  10 +-
 .../gax/httpjson/StatusRuntimeException.java  |  50 ++
 .../httpjson/ApiMessageHttpRequestTest.java   |  50 +-
 .../httpjson/HttpJsonDirectCallableTest.java  | 283 +++++----
 ...JsonDirectServerStreamingCallableTest.java | 367 ++++++++++++
 .../gax/httpjson/HttpRequestRunnableTest.java | 101 +---
 .../api/gax/httpjson/MockHttpServiceTest.java |   6 +
 .../ProtoMessageJsonStreamIteratorTest.java   | 238 ++++++++
 .../gax/httpjson/ProtoRestSerializerTest.java |  14 +-
 .../gax/httpjson/testing/MockHttpService.java | 246 ++++----
 gax/build.gradle                              |   1 +
 36 files changed, 2804 insertions(+), 607 deletions(-)
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCall.java
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCalls.java
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallable.java
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectStreamController.java
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionResponseObserver.java
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionServerStreamingCallable.java
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonMetadata.java
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageJsonStreamIterator.java
 create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/StatusRuntimeException.java
 create mode 100644 gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallableTest.java
 create mode 100644 gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageJsonStreamIteratorTest.java

diff --git a/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcDirectServerStreamingCallableTest.java b/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcDirectServerStreamingCallableTest.java
index 335a70966..e5084b753 100644
--- a/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcDirectServerStreamingCallableTest.java
+++ b/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcDirectServerStreamingCallableTest.java
@@ -145,7 +145,7 @@ public void testServerStreaming() throws Exception {
 
     streamingCallable.call(DEFAULT_REQUEST, moneyObserver);
 
-    latch.await(20, TimeUnit.SECONDS);
+    Truth.assertThat(latch.await(20, TimeUnit.SECONDS)).isTrue();
     Truth.assertThat(moneyObserver.error).isNull();
     Truth.assertThat(moneyObserver.response).isEqualTo(DEFAULT_RESPONSE);
   }
@@ -157,13 +157,13 @@ public void testManualFlowControl() throws Exception {
 
     streamingCallable.call(DEFAULT_REQUEST, moneyObserver);
 
-    latch.await(500, TimeUnit.MILLISECONDS);
+    Truth.assertThat(latch.await(500, TimeUnit.MILLISECONDS)).isFalse();
     Truth.assertWithMessage("Received response before requesting it")
         .that(moneyObserver.response)
         .isNull();
 
     moneyObserver.controller.request(1);
-    latch.await(500, TimeUnit.MILLISECONDS);
+    Truth.assertThat(latch.await(500, TimeUnit.MILLISECONDS)).isTrue();
 
     Truth.assertThat(moneyObserver.response).isEqualTo(DEFAULT_RESPONSE);
     Truth.assertThat(moneyObserver.completed).isTrue();
@@ -178,7 +178,7 @@ public void testCancelClientCall() throws Exception {
 
     moneyObserver.controller.cancel();
     moneyObserver.controller.request(1);
-    latch.await(500, TimeUnit.MILLISECONDS);
+    Truth.assertThat(latch.await(500, TimeUnit.MILLISECONDS)).isTrue();
 
     Truth.assertThat(moneyObserver.error).isInstanceOf(CancellationException.class);
     Truth.assertThat(moneyObserver.error).hasMessageThat().isEqualTo("User cancelled stream");
@@ -190,7 +190,7 @@ public void testOnResponseError() throws Throwable {
     MoneyObserver moneyObserver = new MoneyObserver(true, latch);
 
     streamingCallable.call(ERROR_REQUEST, moneyObserver);
-    latch.await(500, TimeUnit.MILLISECONDS);
+    Truth.assertThat(latch.await(500, TimeUnit.MILLISECONDS)).isTrue();
 
     Truth.assertThat(moneyObserver.error).isInstanceOf(ApiException.class);
     Truth.assertThat(((ApiException) moneyObserver.error).getStatusCode().getCode())
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMessageHttpResponseParser.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMessageHttpResponseParser.java
index 8c5a5c806..643f1fbda 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMessageHttpResponseParser.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMessageHttpResponseParser.java
@@ -40,6 +40,7 @@
 import com.google.protobuf.TypeRegistry;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.io.Reader;
 import java.lang.reflect.Type;
 import java.nio.charset.StandardCharsets;
 
@@ -91,25 +92,28 @@ ApiMessageHttpResponseParser.Builder<ResponseT> newBuilder() {
 
   @Override
   public ResponseT parse(InputStream httpResponseBody) {
+    return parse(httpResponseBody, null);
+  }
+
+  @Override
+  public ResponseT parse(InputStream httpResponseBody, TypeRegistry registry) {
+    return parse(new InputStreamReader(httpResponseBody, StandardCharsets.UTF_8), registry);
+  }
+
+  @Override
+  public ResponseT parse(Reader httpResponseBody, TypeRegistry registry) {
     if (getResponseInstance() == null) {
       return null;
     } else {
       Type responseType = getResponseInstance().getClass();
       try {
-        return getResponseMarshaller()
-            .fromJson(
-                new InputStreamReader(httpResponseBody, StandardCharsets.UTF_8), responseType);
+        return getResponseMarshaller().fromJson(httpResponseBody, responseType);
       } catch (JsonIOException | JsonSyntaxException e) {
         throw new RestSerializationException(e);
       }
     }
   }
 
-  @Override
-  public ResponseT parse(InputStream httpResponseBody, TypeRegistry registry) {
-    return parse(httpResponseBody);
-  }
-
   @Override
   public String serialize(ResponseT response) {
     return getResponseMarshaller().toJson(response);
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMethodDescriptor.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMethodDescriptor.java
index 665aea66f..fcd3c6b33 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMethodDescriptor.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMethodDescriptor.java
@@ -37,12 +37,18 @@
 @AutoValue
 /* Method descriptor for messages to be transmitted over HTTP. */
 public abstract class ApiMethodDescriptor<RequestT, ResponseT> {
+  public enum MethodType {
+    UNARY,
+    CLIENT_STREAMING,
+    SERVER_STREAMING,
+    BIDI_STREAMING,
+    UNKNOWN;
+  }
 
   public abstract String getFullMethodName();
 
   public abstract HttpRequestFormatter<RequestT> getRequestFormatter();
 
-  @Nullable
   public abstract HttpResponseParser<ResponseT> getResponseParser();
 
   /** Return the HTTP method for this request message type. */
@@ -55,8 +61,11 @@ public abstract class ApiMethodDescriptor<RequestT, ResponseT> {
   @Nullable
   public abstract PollingRequestFactory<RequestT> getPollingRequestFactory();
 
+  public abstract MethodType getType();
+
   public static <RequestT, ResponseT> Builder<RequestT, ResponseT> newBuilder() {
-    return new AutoValue_ApiMethodDescriptor.Builder<RequestT, ResponseT>();
+    return new AutoValue_ApiMethodDescriptor.Builder<RequestT, ResponseT>()
+        .setType(MethodType.UNARY);
   }
 
   @AutoValue.Builder
@@ -78,6 +87,8 @@ public abstract Builder<RequestT, ResponseT> setOperationSnapshotFactory(
     public abstract Builder<RequestT, ResponseT> setPollingRequestFactory(
         PollingRequestFactory<RequestT> pollingRequestFactory);
 
+    public abstract Builder<RequestT, ResponseT> setType(MethodType type);
+
     public abstract ApiMethodDescriptor<RequestT, ResponseT> build();
   }
 }
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java
new file mode 100644
index 000000000..af70e8c48
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.ApiExceptionFactory;
+import com.google.api.gax.rpc.StatusCode;
+import com.google.api.gax.rpc.StatusCode.Code;
+import com.google.common.collect.ImmutableSet;
+import java.util.Set;
+import java.util.concurrent.CancellationException;
+
+class HttpJsonApiExceptionFactory {
+  private final Set<Code> retryableCodes;
+
+  HttpJsonApiExceptionFactory(Set<Code> retryableCodes) {
+    this.retryableCodes = ImmutableSet.copyOf(retryableCodes);
+  }
+
+  ApiException create(Throwable throwable) {
+    if (throwable instanceof HttpResponseException) {
+      HttpResponseException e = (HttpResponseException) throwable;
+      StatusCode statusCode = HttpJsonStatusCode.of(e.getStatusCode());
+      boolean canRetry = retryableCodes.contains(statusCode.getCode());
+      String message = e.getStatusMessage();
+      return createApiException(throwable, statusCode, message, canRetry);
+    } else if (throwable instanceof StatusRuntimeException) {
+      StatusRuntimeException e = (StatusRuntimeException) throwable;
+      StatusCode statusCode = HttpJsonStatusCode.of(e.getStatusCode());
+      return createApiException(
+          throwable,
+          HttpJsonStatusCode.of(e.getStatusCode()),
+          e.getMessage(),
+          retryableCodes.contains(statusCode.getCode()));
+    } else if (throwable instanceof CancellationException) {
+      return ApiExceptionFactory.createException(
+          throwable, HttpJsonStatusCode.of(Code.CANCELLED), false);
+    } else if (throwable instanceof ApiException) {
+      return (ApiException) throwable;
+    } else {
+      // Do not retry on unknown throwable, even when UNKNOWN is in retryableCodes
+      return ApiExceptionFactory.createException(
+          throwable, HttpJsonStatusCode.of(StatusCode.Code.UNKNOWN), false);
+    }
+  }
+
+  private ApiException createApiException(
+      Throwable throwable, StatusCode statusCode, String message, boolean canRetry) {
+    return message == null
+        ? ApiExceptionFactory.createException(throwable, statusCode, canRetry)
+        : ApiExceptionFactory.createException(message, throwable, statusCode, canRetry);
+  }
+}
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
index 0d3b00898..acc36e735 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
@@ -62,9 +62,8 @@
 @BetaApi
 public final class HttpJsonCallContext implements ApiCallContext {
   private final HttpJsonChannel channel;
+  private final HttpJsonCallOptions callOptions;
   private final Duration timeout;
-  private final Instant deadline;
-  private final Credentials credentials;
   private final ImmutableMap<String, List<String>> extraHeaders;
   private final ApiCallContextOptions options;
   private final ApiTracer tracer;
@@ -75,10 +74,21 @@ public final class HttpJsonCallContext implements ApiCallContext {
   public static HttpJsonCallContext createDefault() {
     return new HttpJsonCallContext(
         null,
+        HttpJsonCallOptions.newBuilder().build(),
+        null,
+        ImmutableMap.of(),
+        ApiCallContextOptions.getDefaultOptions(),
         null,
         null,
+        null);
+  }
+
+  public static HttpJsonCallContext of(HttpJsonChannel channel, HttpJsonCallOptions options) {
+    return new HttpJsonCallContext(
+        channel,
+        options,
         null,
-        ImmutableMap.<String, List<String>>of(),
+        ImmutableMap.of(),
         ApiCallContextOptions.getDefaultOptions(),
         null,
         null,
@@ -87,18 +97,16 @@ public static HttpJsonCallContext createDefault() {
 
   private HttpJsonCallContext(
       HttpJsonChannel channel,
+      HttpJsonCallOptions callOptions,
       Duration timeout,
-      Instant deadline,
-      Credentials credentials,
       ImmutableMap<String, List<String>> extraHeaders,
       ApiCallContextOptions options,
       ApiTracer tracer,
       RetrySettings defaultRetrySettings,
       Set<StatusCode.Code> defaultRetryableCodes) {
     this.channel = channel;
+    this.callOptions = callOptions;
     this.timeout = timeout;
-    this.deadline = deadline;
-    this.credentials = credentials;
     this.extraHeaders = extraHeaders;
     this.options = options;
     this.tracer = tracer;
@@ -146,21 +154,14 @@ public HttpJsonCallContext merge(ApiCallContext inputCallContext) {
       newChannel = this.channel;
     }
 
+    // Do deep merge of callOptions
+    HttpJsonCallOptions newCallOptions = callOptions.merge(httpJsonCallContext.callOptions);
+
     Duration newTimeout = httpJsonCallContext.timeout;
     if (newTimeout == null) {
       newTimeout = this.timeout;
     }
 
-    Instant newDeadline = httpJsonCallContext.deadline;
-    if (newDeadline == null) {
-      newDeadline = this.deadline;
-    }
-
-    Credentials newCredentials = httpJsonCallContext.credentials;
-    if (newCredentials == null) {
-      newCredentials = this.credentials;
-    }
-
     ImmutableMap<String, List<String>> newExtraHeaders =
         Headers.mergeHeaders(extraHeaders, httpJsonCallContext.extraHeaders);
 
@@ -183,9 +184,8 @@ public HttpJsonCallContext merge(ApiCallContext inputCallContext) {
 
     return new HttpJsonCallContext(
         newChannel,
+        newCallOptions,
         newTimeout,
-        newDeadline,
-        newCredentials,
         newExtraHeaders,
         newOptions,
         newTracer,
@@ -195,16 +195,9 @@ public HttpJsonCallContext merge(ApiCallContext inputCallContext) {
 
   @Override
   public HttpJsonCallContext withCredentials(Credentials newCredentials) {
-    return new HttpJsonCallContext(
-        this.channel,
-        this.timeout,
-        this.deadline,
-        newCredentials,
-        this.extraHeaders,
-        this.options,
-        this.tracer,
-        this.retrySettings,
-        this.retryableCodes);
+    HttpJsonCallOptions.Builder builder =
+        callOptions != null ? callOptions.toBuilder() : HttpJsonCallOptions.newBuilder();
+    return withCallOptions(builder.setCredentials(newCredentials).build());
   }
 
   @Override
@@ -232,9 +225,8 @@ public HttpJsonCallContext withTimeout(Duration timeout) {
 
     return new HttpJsonCallContext(
         this.channel,
+        this.callOptions,
         timeout,
-        this.deadline,
-        this.credentials,
         this.extraHeaders,
         this.options,
         this.tracer,
@@ -278,9 +270,8 @@ public ApiCallContext withExtraHeaders(Map<String, List<String>> extraHeaders) {
         Headers.mergeHeaders(this.extraHeaders, extraHeaders);
     return new HttpJsonCallContext(
         this.channel,
+        this.callOptions,
         this.timeout,
-        this.deadline,
-        this.credentials,
         newExtraHeaders,
         this.options,
         this.tracer,
@@ -300,9 +291,8 @@ public <T> ApiCallContext withOption(Key<T> key, T value) {
     ApiCallContextOptions newOptions = options.withOption(key, value);
     return new HttpJsonCallContext(
         this.channel,
+        this.callOptions,
         this.timeout,
-        this.deadline,
-        this.credentials,
         this.extraHeaders,
         newOptions,
         this.tracer,
@@ -320,12 +310,18 @@ public HttpJsonChannel getChannel() {
     return channel;
   }
 
+  public HttpJsonCallOptions getCallOptions() {
+    return callOptions;
+  }
+
+  @Deprecated
   public Instant getDeadline() {
-    return deadline;
+    return getCallOptions() != null ? getCallOptions().getDeadline() : null;
   }
 
+  @Deprecated
   public Credentials getCredentials() {
-    return credentials;
+    return getCallOptions() != null ? getCallOptions().getCredentials() : null;
   }
 
   @Override
@@ -337,9 +333,8 @@ public RetrySettings getRetrySettings() {
   public HttpJsonCallContext withRetrySettings(RetrySettings retrySettings) {
     return new HttpJsonCallContext(
         this.channel,
+        this.callOptions,
         this.timeout,
-        this.deadline,
-        this.credentials,
         this.extraHeaders,
         this.options,
         this.tracer,
@@ -356,9 +351,8 @@ public Set<StatusCode.Code> getRetryableCodes() {
   public HttpJsonCallContext withRetryableCodes(Set<StatusCode.Code> retryableCodes) {
     return new HttpJsonCallContext(
         this.channel,
+        this.callOptions,
         this.timeout,
-        this.deadline,
-        this.credentials,
         this.extraHeaders,
         this.options,
         this.tracer,
@@ -369,9 +363,8 @@ public HttpJsonCallContext withRetryableCodes(Set<StatusCode.Code> retryableCode
   public HttpJsonCallContext withChannel(HttpJsonChannel newChannel) {
     return new HttpJsonCallContext(
         newChannel,
+        this.callOptions,
         this.timeout,
-        this.deadline,
-        this.credentials,
         this.extraHeaders,
         this.options,
         this.tracer,
@@ -379,12 +372,11 @@ public HttpJsonCallContext withChannel(HttpJsonChannel newChannel) {
         this.retryableCodes);
   }
 
-  public HttpJsonCallContext withDeadline(Instant newDeadline) {
+  public HttpJsonCallContext withCallOptions(HttpJsonCallOptions newCallOptions) {
     return new HttpJsonCallContext(
         this.channel,
+        newCallOptions,
         this.timeout,
-        newDeadline,
-        this.credentials,
         this.extraHeaders,
         this.options,
         this.tracer,
@@ -392,6 +384,13 @@ public HttpJsonCallContext withDeadline(Instant newDeadline) {
         this.retryableCodes);
   }
 
+  @Deprecated
+  public HttpJsonCallContext withDeadline(Instant newDeadline) {
+    HttpJsonCallOptions.Builder builder =
+        callOptions != null ? callOptions.toBuilder() : HttpJsonCallOptions.newBuilder();
+    return withCallOptions(builder.setDeadline(newDeadline).build());
+  }
+
   @Nonnull
   @Override
   public ApiTracer getTracer() {
@@ -408,9 +407,8 @@ public HttpJsonCallContext withTracer(@Nonnull ApiTracer newTracer) {
 
     return new HttpJsonCallContext(
         this.channel,
+        this.callOptions,
         this.timeout,
-        this.deadline,
-        this.credentials,
         this.extraHeaders,
         this.options,
         newTracer,
@@ -428,9 +426,8 @@ public boolean equals(Object o) {
     }
     HttpJsonCallContext that = (HttpJsonCallContext) o;
     return Objects.equals(this.channel, that.channel)
+        && Objects.equals(this.callOptions, that.callOptions)
         && Objects.equals(this.timeout, that.timeout)
-        && Objects.equals(this.deadline, that.deadline)
-        && Objects.equals(this.credentials, that.credentials)
         && Objects.equals(this.extraHeaders, that.extraHeaders)
         && Objects.equals(this.options, that.options)
         && Objects.equals(this.tracer, that.tracer)
@@ -442,9 +439,8 @@ public boolean equals(Object o) {
   public int hashCode() {
     return Objects.hash(
         channel,
+        callOptions,
         timeout,
-        deadline,
-        credentials,
         extraHeaders,
         options,
         tracer,
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallOptions.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallOptions.java
index beb5ff98b..dbb3cb625 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallOptions.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallOptions.java
@@ -40,6 +40,8 @@
 @BetaApi
 @AutoValue
 public abstract class HttpJsonCallOptions {
+  public static final HttpJsonCallOptions DEFAULT = newBuilder().build();
+
   @Nullable
   public abstract Instant getDeadline();
 
@@ -49,10 +51,37 @@ public abstract class HttpJsonCallOptions {
   @Nullable
   public abstract TypeRegistry getTypeRegistry();
 
+  public abstract Builder toBuilder();
+
   public static Builder newBuilder() {
     return new AutoValue_HttpJsonCallOptions.Builder();
   }
 
+  public HttpJsonCallOptions merge(HttpJsonCallOptions inputOptions) {
+    if (inputOptions == null) {
+      return this;
+    }
+
+    Builder builder = this.toBuilder();
+
+    Instant newDeadline = inputOptions.getDeadline();
+    if (newDeadline != null) {
+      builder.setDeadline(newDeadline);
+    }
+
+    Credentials newCredentials = inputOptions.getCredentials();
+    if (newCredentials != null) {
+      builder.setCredentials(newCredentials);
+    }
+
+    TypeRegistry newTypeRegistry = inputOptions.getTypeRegistry();
+    if (newTypeRegistry != null) {
+      builder.setTypeRegistry(newTypeRegistry);
+    }
+
+    return builder.build();
+  }
+
   @AutoValue.Builder
   public abstract static class Builder {
     public abstract Builder setDeadline(Instant value);
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java
index c6b4c5763..d951a90a5 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallableFactory.java
@@ -39,6 +39,8 @@
 import com.google.api.gax.rpc.OperationCallSettings;
 import com.google.api.gax.rpc.OperationCallable;
 import com.google.api.gax.rpc.PagedCallSettings;
+import com.google.api.gax.rpc.ServerStreamingCallSettings;
+import com.google.api.gax.rpc.ServerStreamingCallable;
 import com.google.api.gax.rpc.UnaryCallSettings;
 import com.google.api.gax.rpc.UnaryCallable;
 import com.google.api.gax.tracing.SpanName;
@@ -173,6 +175,28 @@ OperationCallable<RequestT, ResponseT, MetadataT> createOperationCallable(
     return operationCallable.withDefaultCallContext(clientContext.getDefaultCallContext());
   }
 
+  @BetaApi("The surface for streaming is not stable yet and may change in the future.")
+  public static <RequestT, ResponseT>
+      ServerStreamingCallable<RequestT, ResponseT> createServerStreamingCallable(
+          HttpJsonCallSettings<RequestT, ResponseT> httpJsoncallSettings,
+          ServerStreamingCallSettings<RequestT, ResponseT> streamingCallSettings,
+          ClientContext clientContext) {
+
+    ServerStreamingCallable<RequestT, ResponseT> callable =
+        new HttpJsonDirectServerStreamingCallable<>(httpJsoncallSettings.getMethodDescriptor());
+
+    callable =
+        new HttpJsonExceptionServerStreamingCallable<>(
+            callable, streamingCallSettings.getRetryableCodes());
+
+    if (clientContext.getStreamWatchdog() != null) {
+      callable = Callables.watched(callable, streamingCallSettings, clientContext);
+    }
+
+    callable = Callables.retrying(callable, streamingCallSettings, clientContext);
+    return callable.withDefaultCallContext(clientContext.getDefaultCallContext());
+  }
+
   @InternalApi("Visible for testing")
   static SpanName getSpanName(@Nonnull ApiMethodDescriptor<?, ?> methodDescriptor) {
     Matcher matcher = FULL_METHOD_NAME_REGEX.matcher(methodDescriptor.getFullMethodName());
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonChannel.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonChannel.java
index 01cd47cdd..558816c4d 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonChannel.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonChannel.java
@@ -35,6 +35,10 @@
 /** HttpJsonChannel contains the functionality to issue http-json calls. */
 @BetaApi
 public interface HttpJsonChannel {
+  <RequestT, ResponseT> HttpJsonClientCall<RequestT, ResponseT> newCall(
+      ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor, HttpJsonCallOptions callOptions);
+
+  @Deprecated
   <ResponseT, RequestT> ApiFuture<ResponseT> issueFutureUnaryCall(
       HttpJsonCallOptions callOptions,
       RequestT request,
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCall.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCall.java
new file mode 100644
index 000000000..39fc7d0eb
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCall.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.core.BetaApi;
+import javax.annotation.Nullable;
+
+// This class mimics the structure and behavior of the corresponding ClientCall from gRPC package as
+// closely as possible.
+/**
+ * An instance of a call to a remote method. A call will send zero or more request messages to the
+ * server and receive zero or more response messages back.
+ *
+ * <p>Instances are created by a {@link HttpJsonChannel} and used by stubs to invoke their remote
+ * behavior.
+ *
+ * <p>{@link #start} must be called prior to calling any other methods, with the exception of {@link
+ * #cancel}. Whereas {@link #cancel} must not be followed by any other methods, but can be called
+ * more than once, while only the first one has effect.
+ *
+ * <p>Methods are potentially blocking but are designed to execute quickly. The implementations of
+ * this class are expected to be thread-safe.
+ *
+ * <p>There is a race between {@link #cancel} and the completion/failure of the RPC in other ways.
+ * If {@link #cancel} won the race, {@link HttpJsonClientCall.Listener#onClose Listener.onClose()}
+ * is called with {@code statusCode} corresponding to {@link
+ * com.google.api.gax.rpc.StatusCode.Code#CANCELLED CANCELLED}. Otherwise, {@link
+ * HttpJsonClientCall.Listener#onClose Listener.onClose()} is called with whatever status the RPC
+ * was finished. We ensure that at most one is called.
+ *
+ * @param <RequestT> type of message sent to the server
+ * @param <ResponseT> type of message received one or more times from the server
+ */
+@BetaApi
+public abstract class HttpJsonClientCall<RequestT, ResponseT> {
+  /**
+   * Callbacks for receiving metadata, response messages and completion status from the server.
+   *
+   * <p>Implementations are discouraged to block for extended periods of time. Implementations are
+   * not required to be thread-safe, but they must not be thread-hostile. The caller is free to call
+   * an instance from multiple threads, but only one call simultaneously.
+   */
+  @BetaApi
+  public abstract static class Listener<T> {
+    /**
+     * The response headers have been received. Headers always precede messages.
+     *
+     * @param responseHeaders containing metadata sent by the server at the start of the response
+     */
+    public void onHeaders(HttpJsonMetadata responseHeaders) {}
+
+    /**
+     * A response message has been received. May be called zero or more times depending on whether
+     * the call response is empty, a single message or a stream of messages.
+     *
+     * @param message returned by the server
+     */
+    public void onMessage(T message) {}
+
+    /**
+     * The ClientCall has been closed. Any additional calls to the {@code ClientCall} will not be
+     * processed by the server. No further receiving will occur and no further notifications will be
+     * made.
+     *
+     * <p>This method should not throw. If this method throws, there is no way to be notified of the
+     * exception. Implementations should therefore be careful of exceptions which can accidentally
+     * leak resources.
+     *
+     * @param statusCode the HTTP status code representing the result of the remote call
+     * @param trailers metadata provided at call completion
+     */
+    public void onClose(int statusCode, HttpJsonMetadata trailers) {}
+  }
+
+  /**
+   * Start a call, using {@code responseListener} for processing response messages.
+   *
+   * <p>It must be called prior to any other method on this class, except for {@link #cancel} which
+   * may be called at any time.
+   *
+   * @param responseListener receives response messages
+   * @param requestHeaders which can contain extra call metadata, e.g. authentication credentials.
+   */
+  public abstract void start(Listener<ResponseT> responseListener, HttpJsonMetadata requestHeaders);
+
+  /**
+   * Requests up to the given number of messages from the call to be delivered to {@link
+   * HttpJsonClientCall.Listener#onMessage(Object)}. No additional messages will be delivered.
+   *
+   * <p>Message delivery is guaranteed to be sequential in the order received. In addition, the
+   * listener methods will not be accessed concurrently. While it is not guaranteed that the same
+   * thread will always be used, it is guaranteed that only a single thread will access the listener
+   * at a time.
+   *
+   * <p>If called multiple times, the number of messages able to delivered will be the sum of the
+   * calls.
+   *
+   * <p>This method is safe to call from multiple threads without external synchronization.
+   *
+   * @param numMessages the requested number of messages to be delivered to the listener. Must be
+   *     non-negative.
+   */
+  public abstract void request(int numMessages);
+
+  /**
+   * Prevent any further processing for this {@code HttpJsonClientCall}. No further messages may be
+   * sent or will be received. The server is not informed of cancellations. Cancellation is
+   * permitted even if previously {@link #halfClose}d. Cancelling an already {@code cancel()}ed
+   * {@code ClientCall} has no effect.
+   *
+   * <p>No other methods on this class can be called after this method has been called.
+   *
+   * @param message if not {@code null}, will appear as the description of the CANCELLED status
+   * @param cause if not {@code null}, will appear as the cause of the CANCELLED status
+   */
+  public abstract void cancel(@Nullable String message, @Nullable Throwable cause);
+
+  /**
+   * Close the call for request message sending. Incoming response messages are unaffected. This
+   * should be called when no more messages will be sent from the client.
+   */
+  public void halfClose() {}
+
+  /**
+   * Send a request message to the server. May be called zero or more times but for unary and server
+   * streaming calls it must be called not more than once.
+   *
+   * @param message message to be sent to the server.
+   */
+  public abstract void sendMessage(RequestT message);
+}
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
new file mode 100644
index 000000000..8ea3ae4c3
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
@@ -0,0 +1,539 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.client.http.HttpTransport;
+import com.google.api.gax.httpjson.ApiMethodDescriptor.MethodType;
+import com.google.api.gax.httpjson.HttpRequestRunnable.ResultListener;
+import com.google.api.gax.httpjson.HttpRequestRunnable.RunnableResult;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayDeque;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * This class servers as main implementation of {@link HttpJsonClientCall} for rest transport and is
+ * expected to be used for every rest call. It currently supports unary and server-streaming
+ * workflows. The overall behavior and surface of the class mimics as close as possible behavior of
+ * a corresponding ClientCall implementations in gRPC transport.
+ *
+ * <p>This class is thread-safe.
+ *
+ * @param <RequestT> call request type
+ * @param <ResponseT> call response type
+ */
+final class HttpJsonClientCallImpl<RequestT, ResponseT>
+    extends HttpJsonClientCall<RequestT, ResponseT> implements ResultListener {
+  //
+  // A lock to guard the state of this call (and the response stream).
+  //
+  private final Lock lock = new ReentrantLock();
+
+  //
+  // An active delivery loops counter, used to make sure there is only one a See delivery() method
+  // comments, which explain the purpose of this field.
+  //
+  @GuardedBy("lock")
+  private int activeDeliveryLoops = 0;
+
+  // A queue to keep "scheduled" calls to HttpJsonClientCall.Listener<ResponseT> in a form of tasks.
+  // It may seem like an overkill, but it exists to implement the following listeners contract:
+  //   - onHeaders() must be called before any onMessage();
+  //   - onClose() must be the last call made, no onMessage() or onHeaders() are allowed after that;
+  //   - while methods on the same listener may be called from different threads they must never be
+  //     called simultaneously;
+  //   - listeners should not be called under the internal lock of the client call to reduce risk of
+  //     deadlocking and minimize time spent under lock;
+  //   - a specialized notifications' dispatcher thread may be used in the future to send
+  //     notifications (not the case right now).
+  @GuardedBy("lock")
+  private final Queue<NotificationTask<ResponseT>> pendingNotifications = new ArrayDeque<>();
+
+  //
+  // Immutable API method-specific data.
+  //
+  private final HttpJsonCallOptions callOptions;
+  private final String endpoint;
+  private final ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor;
+  private final HttpTransport httpTransport;
+  private final Executor executor;
+  private final HttpJsonMetadata defaultHeaders;
+
+  //
+  // Request-specific data (provided by client code) before we get a response.
+  //
+  @GuardedBy("lock")
+  private HttpJsonMetadata requestHeaders;
+
+  @GuardedBy("lock")
+  private Listener<ResponseT> listener;
+
+  @GuardedBy("lock")
+  private int pendingNumMessages;
+
+  //
+  // Response-specific data (received from server).
+  //
+  @GuardedBy("lock")
+  private HttpRequestRunnable<RequestT, ResponseT> requestRunnable;
+
+  @GuardedBy("lock")
+  private RunnableResult runnableResult;
+
+  @GuardedBy("lock")
+  private ProtoMessageJsonStreamIterator responseStreamIterator;
+
+  @GuardedBy("lock")
+  private boolean closed;
+
+  HttpJsonClientCallImpl(
+      ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor,
+      String endpoint,
+      HttpJsonCallOptions callOptions,
+      HttpTransport httpTransport,
+      Executor executor,
+      HttpJsonMetadata defaultHeaders) {
+    this.methodDescriptor = methodDescriptor;
+    this.endpoint = endpoint;
+    this.callOptions = callOptions;
+    this.httpTransport = httpTransport;
+    this.executor = executor;
+    this.closed = false;
+    this.defaultHeaders = defaultHeaders;
+  }
+
+  @Override
+  public void setResult(RunnableResult runnableResult) {
+    Preconditions.checkNotNull(runnableResult);
+    lock.lock();
+    try {
+      if (closed) {
+        return;
+      }
+      Preconditions.checkState(this.runnableResult == null, "The call result is already set");
+      this.runnableResult = runnableResult;
+      if (runnableResult.getResponseHeaders() != null) {
+        pendingNotifications.offer(
+            new OnHeadersNotificationTask<>(listener, runnableResult.getResponseHeaders()));
+      }
+    } finally {
+      lock.unlock();
+    }
+
+    // trigger delivery loop if not already running
+    deliver();
+  }
+
+  @Override
+  public void start(Listener<ResponseT> responseListener, HttpJsonMetadata requestHeaders) {
+    Preconditions.checkNotNull(responseListener);
+    Preconditions.checkNotNull(requestHeaders);
+    lock.lock();
+    try {
+      if (closed) {
+        return;
+      }
+      Preconditions.checkState(this.listener == null, "The call is already started");
+      this.listener = responseListener;
+      Map<String, Object> mergedHeaders =
+          ImmutableMap.<String, Object>builder()
+              .putAll(defaultHeaders.getHeaders())
+              .putAll(requestHeaders.getHeaders())
+              .build();
+      this.requestHeaders = requestHeaders.toBuilder().setHeaders(mergedHeaders).build();
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  @Override
+  public void request(int numMessages) {
+    if (numMessages < 0) {
+      throw new IllegalArgumentException("numMessages must be non-negative");
+    }
+    lock.lock();
+    try {
+      if (closed) {
+        return;
+      }
+      pendingNumMessages += numMessages;
+    } finally {
+      lock.unlock();
+    }
+
+    // trigger delivery loop if not already running
+    deliver();
+  }
+
+  @Override
+  public void cancel(@Nullable String message, @Nullable Throwable cause) {
+    Throwable actualCause = cause;
+    if (actualCause == null) {
+      actualCause = new CancellationException(message);
+    }
+
+    lock.lock();
+    try {
+      close(499, message, actualCause, true);
+    } finally {
+      lock.unlock();
+    }
+
+    // trigger delivery loop if not already running
+    deliver();
+  }
+
+  @Override
+  public void sendMessage(RequestT message) {
+    Preconditions.checkNotNull(message);
+    HttpRequestRunnable<RequestT, ResponseT> localRunnable;
+    lock.lock();
+    try {
+      if (closed) {
+        return;
+      }
+      Preconditions.checkState(listener != null, "The call hasn't been started");
+      Preconditions.checkState(
+          requestRunnable == null,
+          "The message has already been sent. Bidirectional streaming calls are not supported");
+
+      requestRunnable =
+          new HttpRequestRunnable<>(
+              message,
+              methodDescriptor,
+              endpoint,
+              callOptions,
+              httpTransport,
+              requestHeaders,
+              this);
+      localRunnable = requestRunnable;
+    } finally {
+      lock.unlock();
+    }
+    executor.execute(localRunnable);
+  }
+
+  @Override
+  public void halfClose() {
+    // no-op for now, as halfClose makes sense only for bidirectional streams.
+  }
+
+  private void deliver() {
+    // A flag stored in method stack space to detect when we enter a delivery loop (regardless if
+    // it is a concurrent thread or a recursive call execution of delivery() method within the same
+    // thread).
+    boolean newActiveDeliveryLoop = true;
+    boolean allMessagesConsumed = false;
+    while (true) {
+      // The try block around listener notification logic. We need to keep this
+      // block inside the loop to make sure that in case onMessage() call throws, we close the
+      // request properly and call onClose() method on listener once eventually (because the
+      // listener can be called only inside this loop).
+      try {
+        // Check if there is only one delivery loop active. Exit if a competing delivery loop
+        // detected (either in a concurrent thread or in a previous recursive call to this method in
+        // the same thread). The last-standing delivery loop will do all the job. Even if something
+        // in this loop throws, the code will first go through this block before exiting the loop to
+        // make sure that the activeDeliveryLoops counter stays correct.
+        //
+        // Note, we must enter the loop before doing the check.
+        lock.lock();
+        try {
+          if (newActiveDeliveryLoop) {
+            activeDeliveryLoops = Math.max(1, activeDeliveryLoops + 1);
+            newActiveDeliveryLoop = false;
+          }
+          if (activeDeliveryLoops > 1) {
+            activeDeliveryLoops--;
+            // EXIT delivery loop because another active delivery loop has been detected.
+            break;
+          }
+        } finally {
+          lock.unlock();
+        }
+
+        if (Thread.interrupted()) {
+          // The catch block below will properly cancel the call. Note Thread.interrupted() clears
+          // the interruption flag on this thread, so we don't throw forever.
+          throw new InterruptedException("Message delivery has been interrupted");
+        }
+
+        // All listeners must be called under delivery loop (but outside the lock) to ensure that no
+        // two notifications come simultaneously from two different threads and that we do not go
+        // indefinitely deep in the stack if delivery logic is called recursively via listeners.
+        notifyListeners();
+
+        // The blocking try block around message reading and cancellation notification processing
+        // logic
+        lock.lock();
+        try {
+          if (allMessagesConsumed) {
+            // allMessagesProcessed was set to true on previous loop iteration. We do it this
+            // way to make sure that notifyListeners() is called in between consuming the last
+            // message in a stream and closing the call.
+            // This is to make sure that onMessage() for the last message in a stream is called
+            // before closing this call, because that last onMessage() listener execution may change
+            // how the call has to be closed (normally or cancelled).
+
+            // Close the call normally.
+            // once close() is called we will never ever enter this again, because `close` flag
+            // will be set to true by the close() method. If the call is already closed, close()
+            // will have no effect.
+            allMessagesConsumed = false;
+            close(
+                runnableResult.getStatusCode(),
+                runnableResult.getTrailers().getStatusMessage(),
+                runnableResult.getTrailers().getException(),
+                false);
+          }
+
+          // Attempt to terminate the delivery loop if:
+          //   `runnableResult == null` => there is no response from the server yet;
+          //   `pendingNumMessages <= 0` => we have already delivered as much as has been asked;
+          //   `closed` => this call has been closed already;
+          if (runnableResult == null || pendingNumMessages <= 0 || closed) {
+            // The loop terminates only when there are no pending notifications left. The check
+            // happens under the lock, so no other thread may add a listener notification task in
+            // the middle of this logic.
+            if (pendingNotifications.isEmpty()) {
+              // EXIT delivery loop because there is no more work left to do. This is expected to be
+              // the only active delivery loop.
+              activeDeliveryLoops--;
+              break;
+            } else {
+              // We still have some stuff in notiticationTasksQueue so continue the loop, most
+              // likely we will finally terminate on the next cycle.
+              continue;
+            }
+          }
+          pendingNumMessages--;
+          allMessagesConsumed = consumeMessageFromStream();
+
+        } finally {
+          lock.unlock();
+        }
+      } catch (Throwable e) {
+        // Exceptions in message delivery result into cancellation of the call to stay consistent
+        // with other transport implementations.
+        StatusRuntimeException ex =
+            new StatusRuntimeException(499, "Exception in message delivery", e);
+        // If we are already closed the exception will be swallowed, which is the best thing we
+        // can do in such an unlikely situation (otherwise we would stay forever in the delivery
+        // loop).
+        lock.lock();
+        try {
+          // Close the call immediately marking it cancelled. If already closed close() will have no
+          // effect.
+          close(ex.getStatusCode(), ex.getMessage(), ex, true);
+        } finally {
+          lock.unlock();
+        }
+      }
+    }
+  }
+
+  private void notifyListeners() {
+    while (true) {
+      lock.lock();
+      NotificationTask<ResponseT> notification;
+      try {
+        if (pendingNotifications.isEmpty()) {
+          return;
+        }
+        notification = pendingNotifications.poll();
+      } finally {
+        lock.unlock();
+      }
+      notification.call();
+    }
+  }
+
+  @GuardedBy("lock")
+  private boolean consumeMessageFromStream() throws IOException {
+    if (runnableResult.getTrailers().getException() != null
+        || runnableResult.getResponseContent() == null) {
+      // Server returned an error, no messages to process. This will result into closing a call with
+      // an error.
+      return true;
+    }
+
+    boolean allMessagesConsumed;
+    Reader responseReader;
+    if (methodDescriptor.getType() == MethodType.SERVER_STREAMING) {
+      // Lazily initialize responseStreamIterator in case if it is a server steraming response
+      if (responseStreamIterator == null) {
+        responseStreamIterator =
+            new ProtoMessageJsonStreamIterator(
+                new InputStreamReader(runnableResult.getResponseContent(), StandardCharsets.UTF_8));
+      }
+      if (responseStreamIterator.hasNext()) {
+        responseReader = responseStreamIterator.next();
+      } else {
+        return true;
+      }
+      // To make sure that the call will be closed immediately once we read the last
+      // message from the response (otherwise we would need to wait for another request(1)
+      // from the client to check if there is anything else left in the stream).
+      allMessagesConsumed = !responseStreamIterator.hasNext();
+    } else {
+      responseReader =
+          new InputStreamReader(runnableResult.getResponseContent(), StandardCharsets.UTF_8);
+      // Unary calls have only one message in their response, so we should be ready to close
+      // immediately after delivering a single response message.
+      allMessagesConsumed = true;
+    }
+
+    ResponseT message =
+        methodDescriptor.getResponseParser().parse(responseReader, callOptions.getTypeRegistry());
+    pendingNotifications.offer(new OnMessageNotificationTask<>(listener, message));
+
+    return allMessagesConsumed;
+  }
+
+  @GuardedBy("lock")
+  private void close(
+      int statusCode, String message, Throwable cause, boolean terminateImmediatelly) {
+    try {
+      if (closed) {
+        return;
+      }
+      closed = true;
+      // Best effort task cancellation (to not be confused with task's thread interruption).
+      // If the task is in blocking I/O waiting for the server response, it will keep waiting for
+      // the response from the server, but once response is received the task will exit silently.
+      // If the task has already completed, this call has no effect.
+      if (requestRunnable != null) {
+        requestRunnable.cancel();
+        requestRunnable = null;
+      }
+
+      HttpJsonMetadata.Builder meatadaBuilder = HttpJsonMetadata.newBuilder();
+      if (runnableResult != null && runnableResult.getTrailers() != null) {
+        meatadaBuilder = runnableResult.getTrailers().toBuilder();
+      }
+      meatadaBuilder.setException(cause);
+      meatadaBuilder.setStatusMessage(message);
+      if (responseStreamIterator != null) {
+        responseStreamIterator.close();
+      }
+      if (runnableResult != null && runnableResult.getResponseContent() != null) {
+        runnableResult.getResponseContent().close();
+      }
+
+      // onClose() suppresses all other pending notifications.
+      // there should be no place in the code which inserts something in this queue before checking
+      // the `closed` flag under the lock and refusing to insert anything if `closed == true`.
+      if (terminateImmediatelly) {
+        // This usually means we are cancelling the call before processing the response in full.
+        // It may happen if a user explicitly cancels the call or in response to an unexpected
+        // exception either from server or a call listener execution.
+        pendingNotifications.clear();
+      }
+
+      pendingNotifications.offer(
+          new OnCloseNotificationTask<>(listener, statusCode, meatadaBuilder.build()));
+
+    } catch (Throwable e) {
+      // suppress stream closing exceptions in favor of the actual call closing cause. This method
+      // should not throw, otherwise we may stuck in an infinite loop of exception processing.
+    }
+  }
+
+  //
+  // Listener notification tasks. Each class simply calls only one specific method on the Listener
+  // interface, and to do so it also stores tha parameters needed to make the all.
+  //
+  private abstract static class NotificationTask<ResponseT> {
+    private final Listener<ResponseT> listener;
+
+    NotificationTask(Listener<ResponseT> listener) {
+      this.listener = listener;
+    }
+
+    protected Listener<ResponseT> getListener() {
+      return listener;
+    }
+
+    abstract void call();
+  }
+
+  private static class OnHeadersNotificationTask<ResponseT> extends NotificationTask<ResponseT> {
+    private final HttpJsonMetadata responseHeaders;
+
+    OnHeadersNotificationTask(Listener<ResponseT> listener, HttpJsonMetadata responseHeaders) {
+      super(listener);
+      this.responseHeaders = responseHeaders;
+    }
+
+    public void call() {
+      getListener().onHeaders(responseHeaders);
+    }
+  }
+
+  private static class OnMessageNotificationTask<ResponseT> extends NotificationTask<ResponseT> {
+    private final ResponseT message;
+
+    OnMessageNotificationTask(Listener<ResponseT> listener, ResponseT message) {
+      super(listener);
+      this.message = message;
+    }
+
+    public void call() {
+      getListener().onMessage(message);
+    }
+  }
+
+  private static class OnCloseNotificationTask<ResponseT> extends NotificationTask<ResponseT> {
+    private final int statusCode;
+    private final HttpJsonMetadata trailers;
+
+    OnCloseNotificationTask(
+        Listener<ResponseT> listener, int statusCode, HttpJsonMetadata trailers) {
+      super(listener);
+      this.statusCode = statusCode;
+      this.trailers = trailers;
+    }
+
+    public void call() {
+      getListener().onClose(statusCode, trailers);
+    }
+  }
+}
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCalls.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCalls.java
new file mode 100644
index 000000000..ad8320eba
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCalls.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.core.AbstractApiFuture;
+import com.google.api.core.ApiFuture;
+import com.google.api.gax.rpc.ApiCallContext;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javax.annotation.Nonnull;
+import org.threeten.bp.Instant;
+
+/**
+ * {@code HttpJsonClientCalls} creates a new {@code HttpJsonClientCAll} from the given call context.
+ *
+ * <p>Package-private for internal use.
+ */
+class HttpJsonClientCalls {
+  private static final Logger LOGGER = Logger.getLogger(HttpJsonClientCalls.class.getName());
+
+  public static <RequestT, ResponseT> HttpJsonClientCall<RequestT, ResponseT> newCall(
+      ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor, ApiCallContext context) {
+
+    HttpJsonCallContext httpJsonContext = HttpJsonCallContext.createDefault().nullToSelf(context);
+
+    // Try to convert the timeout into a deadline and use it if it occurs before the actual deadline
+    if (httpJsonContext.getTimeout() != null) {
+      @Nonnull Instant newDeadline = Instant.now().plus(httpJsonContext.getTimeout());
+      HttpJsonCallOptions callOptions = httpJsonContext.getCallOptions();
+      if (callOptions.getDeadline() == null || newDeadline.isBefore(callOptions.getDeadline())) {
+        callOptions = callOptions.toBuilder().setDeadline(newDeadline).build();
+        httpJsonContext = httpJsonContext.withCallOptions(callOptions);
+      }
+    }
+
+    // TODO: add headers interceptor logic
+    return httpJsonContext.getChannel().newCall(methodDescriptor, httpJsonContext.getCallOptions());
+  }
+
+  static <RequestT, ResponseT> ApiFuture<ResponseT> eagerFutureUnaryCall(
+      HttpJsonClientCall<RequestT, ResponseT> clientCall, RequestT request) {
+    // Start the call
+    HttpJsonFuture<ResponseT> future = new HttpJsonFuture<>(clientCall);
+    clientCall.start(new FutureListener<>(future), HttpJsonMetadata.newBuilder().build());
+
+    // Send the request
+    try {
+      clientCall.sendMessage(request);
+      clientCall.halfClose();
+      // Request an extra message to detect misconfigured servers
+      clientCall.request(2);
+    } catch (Throwable sendError) {
+      // Cancel if anything goes wrong
+      try {
+        clientCall.cancel(null, sendError);
+      } catch (Throwable cancelError) {
+        LOGGER.log(Level.SEVERE, "Error encountered while closing it", sendError);
+      }
+
+      throw sendError;
+    }
+
+    return future;
+  }
+
+  private static class HttpJsonFuture<T> extends AbstractApiFuture<T> {
+    private final HttpJsonClientCall<?, T> call;
+
+    private HttpJsonFuture(HttpJsonClientCall<?, T> call) {
+      this.call = call;
+    }
+
+    @Override
+    protected void interruptTask() {
+      call.cancel("HttpJsonFuture was cancelled", null);
+    }
+
+    @Override
+    public boolean set(T value) {
+      return super.set(value);
+    }
+
+    @Override
+    public boolean setException(Throwable throwable) {
+      return super.setException(throwable);
+    }
+  }
+
+  private static class FutureListener<T> extends HttpJsonClientCall.Listener<T> {
+    private final HttpJsonFuture<T> future;
+
+    private FutureListener(HttpJsonFuture<T> future) {
+      this.future = future;
+    }
+
+    @Override
+    public void onMessage(T message) {
+      if (!future.set(message)) {
+        throw new IllegalStateException("More than one value received for unary call");
+      }
+    }
+
+    @Override
+    public void onClose(int statusCode, HttpJsonMetadata trailers) {
+      if (!future.isDone()) {
+        future.setException(trailers.getException());
+      } else if (statusCode < 200 || statusCode >= 400) {
+        LOGGER.log(
+            Level.WARNING, "Received error for unary call after receiving a successful response");
+      }
+    }
+  }
+}
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectCallable.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectCallable.java
index 55278c22f..dd0826dd6 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectCallable.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectCallable.java
@@ -34,9 +34,6 @@
 import com.google.api.gax.rpc.UnaryCallable;
 import com.google.common.base.Preconditions;
 import com.google.protobuf.TypeRegistry;
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import org.threeten.bp.Instant;
 
 /**
  * {@code HttpJsonDirectCallable} creates HTTP calls.
@@ -62,23 +59,13 @@ public ApiFuture<ResponseT> futureCall(RequestT request, ApiCallContext inputCon
     Preconditions.checkNotNull(request);
     HttpJsonCallContext context = HttpJsonCallContext.createDefault().nullToSelf(inputContext);
 
-    @Nullable Instant deadline = context.getDeadline();
-    // Try to convert the timeout into a deadline and use it if it occurs before the actual deadline
-    if (context.getTimeout() != null) {
-      @Nonnull Instant newDeadline = Instant.now().plus(context.getTimeout());
+    context =
+        context.withCallOptions(
+            context.getCallOptions().toBuilder().setTypeRegistry(typeRegistry).build());
 
-      if (deadline == null || newDeadline.isBefore(deadline)) {
-        deadline = newDeadline;
-      }
-    }
-
-    HttpJsonCallOptions callOptions =
-        HttpJsonCallOptions.newBuilder()
-            .setDeadline(deadline)
-            .setCredentials(context.getCredentials())
-            .setTypeRegistry(typeRegistry)
-            .build();
-    return context.getChannel().issueFutureUnaryCall(callOptions, request, descriptor);
+    HttpJsonClientCall<RequestT, ResponseT> clientCall =
+        HttpJsonClientCalls.newCall(descriptor, context);
+    return HttpJsonClientCalls.eagerFutureUnaryCall(clientCall, request);
   }
 
   @Override
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallable.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallable.java
new file mode 100644
index 000000000..ed3bebde3
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallable.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.gax.rpc.ApiCallContext;
+import com.google.api.gax.rpc.ResponseObserver;
+import com.google.api.gax.rpc.ServerStreamingCallable;
+import com.google.api.gax.rpc.StreamController;
+import com.google.common.base.Preconditions;
+
+/**
+ * {@code HttpJsonDirectServerStreamingCallable} creates server-streaming REST calls.
+ *
+ * <p>In a chain of {@link ServerStreamingCallable}s this is the innermost callable. It wraps a
+ * {@link HttpJsonClientCall} in a {@link StreamController} and the downstream {@link
+ * ResponseObserver} in a {@link HttpJsonClientCall.Listener}. This class is implemented to look and
+ * behave as similarly as possible to gRPC variant of it.
+ *
+ * <p>Package-private for internal use.
+ */
+class HttpJsonDirectServerStreamingCallable<RequestT, ResponseT>
+    extends ServerStreamingCallable<RequestT, ResponseT> {
+
+  private final ApiMethodDescriptor<RequestT, ResponseT> descriptor;
+
+  HttpJsonDirectServerStreamingCallable(ApiMethodDescriptor<RequestT, ResponseT> descriptor) {
+    this.descriptor = descriptor;
+  }
+
+  @Override
+  public void call(
+      RequestT request, ResponseObserver<ResponseT> responseObserver, ApiCallContext context) {
+
+    Preconditions.checkNotNull(request);
+    Preconditions.checkNotNull(responseObserver);
+
+    HttpJsonClientCall<RequestT, ResponseT> call = HttpJsonClientCalls.newCall(descriptor, context);
+    HttpJsonDirectStreamController<RequestT, ResponseT> controller =
+        new HttpJsonDirectStreamController<>(call, responseObserver);
+    controller.start(request);
+  }
+}
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectStreamController.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectStreamController.java
new file mode 100644
index 000000000..5f56390f0
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonDirectStreamController.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.gax.rpc.ResponseObserver;
+import com.google.api.gax.rpc.StreamController;
+import com.google.common.base.Preconditions;
+import java.util.concurrent.CancellationException;
+
+/**
+ * Wraps a HttpJsonClientCall in a {@link StreamController}. It feeds events to a {@link
+ * ResponseObserver} and allows for back pressure.
+ *
+ * <p>Package-private for internal use.
+ */
+class HttpJsonDirectStreamController<RequestT, ResponseT> implements StreamController {
+  private final HttpJsonClientCall<RequestT, ResponseT> clientCall;
+  private final ResponseObserver<ResponseT> responseObserver;
+  private volatile boolean hasStarted;
+  private volatile boolean autoflowControl = true;
+  private volatile int numRequested;
+  private volatile CancellationException cancellationException;
+
+  HttpJsonDirectStreamController(
+      HttpJsonClientCall<RequestT, ResponseT> clientCall,
+      ResponseObserver<ResponseT> responseObserver) {
+    this.clientCall = clientCall;
+    this.responseObserver = responseObserver;
+  }
+
+  @Override
+  public void cancel() {
+    cancellationException = new CancellationException("User cancelled stream");
+    clientCall.cancel(null, cancellationException);
+  }
+
+  @Override
+  public void disableAutoInboundFlowControl() {
+    Preconditions.checkState(
+        !hasStarted, "Can't disable automatic flow control after the stream has started.");
+    autoflowControl = false;
+  }
+
+  @Override
+  public void request(int count) {
+    Preconditions.checkState(!autoflowControl, "Autoflow control is enabled.");
+
+    // Buffer the requested count in case the consumer requested responses in the onStart()
+    if (!hasStarted) {
+      numRequested += count;
+    } else {
+      clientCall.request(count);
+    }
+  }
+
+  void start(RequestT request) {
+    responseObserver.onStart(this);
+    this.hasStarted = true;
+    clientCall.start(new ResponseObserverAdapter(), HttpJsonMetadata.newBuilder().build());
+
+    if (autoflowControl) {
+      clientCall.request(1);
+    } else if (numRequested > 0) {
+      clientCall.request(numRequested);
+    }
+
+    clientCall.sendMessage(request);
+  }
+
+  private class ResponseObserverAdapter extends HttpJsonClientCall.Listener<ResponseT> {
+    /**
+     * Notifies the outerObserver of the new message and if automatic flow control is enabled,
+     * requests the next message. Any errors raised by the outerObserver will be bubbled up to GRPC,
+     * which cancel the ClientCall and close this listener.
+     *
+     * @param message The new message.
+     */
+    @Override
+    public void onMessage(ResponseT message) {
+      responseObserver.onResponse(message);
+
+      if (autoflowControl) {
+        clientCall.request(1);
+      }
+    }
+
+    @Override
+    public void onClose(int statusCode, HttpJsonMetadata trailers) {
+      if (statusCode >= 200 && statusCode < 300) {
+        responseObserver.onComplete();
+      } else if (cancellationException != null) {
+        // Intercept cancellations and replace with the top level cause
+        responseObserver.onError(cancellationException);
+      } else {
+        responseObserver.onError(trailers.getException());
+      }
+    }
+  }
+}
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionCallable.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionCallable.java
index 14be14332..ede9ce343 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionCallable.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionCallable.java
@@ -31,18 +31,15 @@
 
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
-import com.google.api.client.http.HttpResponseException;
 import com.google.api.core.AbstractApiFuture;
 import com.google.api.core.ApiFuture;
 import com.google.api.core.ApiFutureCallback;
 import com.google.api.core.ApiFutures;
 import com.google.api.gax.rpc.ApiCallContext;
 import com.google.api.gax.rpc.ApiException;
-import com.google.api.gax.rpc.ApiExceptionFactory;
 import com.google.api.gax.rpc.StatusCode;
 import com.google.api.gax.rpc.UnaryCallable;
 import com.google.common.base.Preconditions;
-import com.google.common.collect.ImmutableSet;
 import java.util.Set;
 import java.util.concurrent.CancellationException;
 
@@ -53,12 +50,12 @@
  */
 class HttpJsonExceptionCallable<RequestT, ResponseT> extends UnaryCallable<RequestT, ResponseT> {
   private final UnaryCallable<RequestT, ResponseT> callable;
-  private final ImmutableSet<StatusCode.Code> retryableCodes;
+  private final HttpJsonApiExceptionFactory exceptionFactory;
 
   HttpJsonExceptionCallable(
       UnaryCallable<RequestT, ResponseT> callable, Set<StatusCode.Code> retryableCodes) {
     this.callable = Preconditions.checkNotNull(callable);
-    this.retryableCodes = ImmutableSet.copyOf(retryableCodes);
+    this.exceptionFactory = new HttpJsonApiExceptionFactory(retryableCodes);
   }
 
   @Override
@@ -73,7 +70,7 @@ public ApiFuture<ResponseT> futureCall(RequestT request, ApiCallContext inputCon
 
   private class ExceptionTransformingFuture extends AbstractApiFuture<ResponseT>
       implements ApiFutureCallback<ResponseT> {
-    private ApiFuture<ResponseT> innerCallFuture;
+    private final ApiFuture<ResponseT> innerCallFuture;
     private volatile boolean cancelled = false;
 
     public ExceptionTransformingFuture(ApiFuture<ResponseT> innerCallFuture) {
@@ -93,27 +90,11 @@ public void onSuccess(ResponseT r) {
 
     @Override
     public void onFailure(Throwable throwable) {
-      if (throwable instanceof HttpResponseException) {
-        HttpResponseException e = (HttpResponseException) throwable;
-        StatusCode statusCode = HttpJsonStatusCode.of(e.getStatusCode());
-        boolean canRetry = retryableCodes.contains(statusCode.getCode());
-        String message = e.getStatusMessage();
-        ApiException newException =
-            message == null
-                ? ApiExceptionFactory.createException(throwable, statusCode, canRetry)
-                : ApiExceptionFactory.createException(message, throwable, statusCode, canRetry);
-        super.setException(newException);
-      } else if (throwable instanceof CancellationException && cancelled) {
+      if (throwable instanceof CancellationException && cancelled) {
         // this just circled around, so ignore.
         return;
-      } else if (throwable instanceof ApiException) {
-        super.setException(throwable);
-      } else {
-        // Do not retry on unknown throwable, even when UNKNOWN is in retryableCodes
-        setException(
-            ApiExceptionFactory.createException(
-                throwable, HttpJsonStatusCode.of(StatusCode.Code.UNKNOWN), false));
       }
+      setException(exceptionFactory.create(throwable));
     }
   }
 }
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionResponseObserver.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionResponseObserver.java
new file mode 100644
index 000000000..0264a33f0
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionResponseObserver.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.gax.rpc.ResponseObserver;
+import com.google.api.gax.rpc.StateCheckingResponseObserver;
+import com.google.api.gax.rpc.StreamController;
+import java.util.concurrent.CancellationException;
+
+/** Package-private for internal use. */
+class HttpJsonExceptionResponseObserver<ResponseT>
+    extends StateCheckingResponseObserver<ResponseT> {
+  private final ResponseObserver<ResponseT> innerObserver;
+  private volatile CancellationException cancellationException;
+  private final HttpJsonApiExceptionFactory exceptionFactory;
+
+  public HttpJsonExceptionResponseObserver(
+      ResponseObserver<ResponseT> innerObserver, HttpJsonApiExceptionFactory exceptionFactory) {
+    this.innerObserver = innerObserver;
+    this.exceptionFactory = exceptionFactory;
+  }
+
+  @Override
+  protected void onStartImpl(final StreamController controller) {
+    innerObserver.onStart(
+        new StreamController() {
+          @Override
+          public void cancel() {
+            cancellationException = new CancellationException("User cancelled stream");
+            controller.cancel();
+          }
+
+          @Override
+          public void disableAutoInboundFlowControl() {
+            controller.disableAutoInboundFlowControl();
+          }
+
+          @Override
+          public void request(int count) {
+            controller.request(count);
+          }
+        });
+  }
+
+  @Override
+  protected void onResponseImpl(ResponseT response) {
+    innerObserver.onResponse(response);
+  }
+
+  @Override
+  protected void onErrorImpl(Throwable t) {
+    if (cancellationException != null) {
+      t = cancellationException;
+    } else {
+      t = exceptionFactory.create(t);
+    }
+    innerObserver.onError(t);
+  }
+
+  @Override
+  protected void onCompleteImpl() {
+    innerObserver.onComplete();
+  }
+}
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionServerStreamingCallable.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionServerStreamingCallable.java
new file mode 100644
index 000000000..7c82b2fb1
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonExceptionServerStreamingCallable.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.google.api.gax.httpjson;
+
+import com.google.api.gax.rpc.ApiCallContext;
+import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.ResponseObserver;
+import com.google.api.gax.rpc.ServerStreamingCallable;
+import com.google.api.gax.rpc.StatusCode.Code;
+import java.util.Set;
+
+/**
+ * Transforms all {@code Throwable}s thrown during a rest call into an instance of {@link
+ * ApiException}.
+ *
+ * <p>Package-private for internal use.
+ */
+class HttpJsonExceptionServerStreamingCallable<RequestT, ResponseT>
+    extends ServerStreamingCallable<RequestT, ResponseT> {
+  private final ServerStreamingCallable<RequestT, ResponseT> inner;
+  private final HttpJsonApiExceptionFactory exceptionFactory;
+
+  public HttpJsonExceptionServerStreamingCallable(
+      ServerStreamingCallable<RequestT, ResponseT> inner, Set<Code> retryableCodes) {
+    this.inner = inner;
+    this.exceptionFactory = new HttpJsonApiExceptionFactory(retryableCodes);
+  }
+
+  @Override
+  public void call(
+      RequestT request, ResponseObserver<ResponseT> responseObserver, ApiCallContext context) {
+    inner.call(
+        request,
+        new HttpJsonExceptionResponseObserver<>(responseObserver, exceptionFactory),
+        context);
+  }
+}
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonMetadata.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonMetadata.java
new file mode 100644
index 000000000..9fef6db5c
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonMetadata.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.core.BetaApi;
+import com.google.api.core.InternalExtensionOnly;
+import com.google.auto.value.AutoValue;
+import java.util.Collections;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+@AutoValue
+@BetaApi
+@InternalExtensionOnly
+public abstract class HttpJsonMetadata {
+  public abstract Map<String, Object> getHeaders();
+
+  @Nullable
+  public abstract String getStatusMessage();
+
+  @Nullable
+  public abstract Throwable getException();
+
+  public abstract Builder toBuilder();
+
+  public static HttpJsonMetadata.Builder newBuilder() {
+    return new AutoValue_HttpJsonMetadata.Builder().setHeaders(Collections.emptyMap());
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder setHeaders(Map<String, Object> headers);
+
+    public abstract Builder setStatusMessage(String value);
+
+    public abstract Builder setException(Throwable value);
+
+    abstract HttpJsonMetadata build();
+  }
+}
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java
index e992f85a8..337f7b5a0 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java
@@ -98,6 +98,10 @@ public static Builder newBuilder() {
     return new AutoValue_HttpJsonTransportChannel.Builder();
   }
 
+  public static HttpJsonTransportChannel create(ManagedHttpJsonChannel channel) {
+    return newBuilder().setManagedChannel(channel).build();
+  }
+
   @AutoValue.Builder
   public abstract static class Builder {
     public abstract Builder setManagedChannel(ManagedHttpJsonChannel value);
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java
index f3a7ffd6c..621335f89 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java
@@ -37,66 +37,139 @@
 import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpRequestFactory;
 import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpResponseException;
 import com.google.api.client.http.HttpTransport;
 import com.google.api.client.http.json.JsonHttpContent;
 import com.google.api.client.json.JsonFactory;
 import com.google.api.client.json.JsonObjectParser;
+import com.google.api.client.json.gson.GsonFactory;
 import com.google.api.client.util.GenericData;
-import com.google.api.core.SettableApiFuture;
 import com.google.auth.Credentials;
 import com.google.auth.http.HttpCredentialsAdapter;
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
-import java.util.LinkedList;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import javax.annotation.Nullable;
 import org.threeten.bp.Duration;
 import org.threeten.bp.Instant;
 
 /** A runnable object that creates and executes an HTTP request. */
-@AutoValue
-abstract class HttpRequestRunnable<RequestT, ResponseT> implements Runnable {
-  abstract HttpJsonCallOptions getHttpJsonCallOptions();
-
-  abstract RequestT getRequest();
-
-  abstract ApiMethodDescriptor<RequestT, ResponseT> getApiMethodDescriptor();
-
-  abstract HttpTransport getHttpTransport();
+class HttpRequestRunnable<RequestT, ResponseT> implements Runnable {
+  private final RequestT request;
+  private final ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor;
+  private final String endpoint;
+  private final HttpJsonCallOptions httpJsonCallOptions;
+  private final HttpTransport httpTransport;
+  private final HttpJsonMetadata headers;
+  private final ResultListener resultListener;
+
+  private volatile boolean cancelled = false;
+
+  HttpRequestRunnable(
+      RequestT request,
+      ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor,
+      String endpoint,
+      HttpJsonCallOptions httpJsonCallOptions,
+      HttpTransport httpTransport,
+      HttpJsonMetadata headers,
+      ResultListener resultListener) {
+    this.request = request;
+    this.methodDescriptor = methodDescriptor;
+    this.endpoint = endpoint;
+    this.httpJsonCallOptions = httpJsonCallOptions;
+    this.httpTransport = httpTransport;
+    this.headers = headers;
+    this.resultListener = resultListener;
+  }
 
-  abstract String getEndpoint();
+  // Best effort cancellation without guarantees.
+  // It will check if the task cancelled before each three sequential potentially time-consuming
+  // operations:
+  //   - request construction;
+  //   - request execution (the most time consuming, taking);
+  //   - response construction.
+  void cancel() {
+    cancelled = true;
+  }
 
-  abstract JsonFactory getJsonFactory();
+  @Override
+  public void run() {
+    HttpResponse httpResponse = null;
+    RunnableResult.Builder result = RunnableResult.builder();
+    HttpJsonMetadata.Builder trailers = HttpJsonMetadata.newBuilder();
+    HttpRequest httpRequest = null;
+    try {
+      // Check if already cancelled before even creating a request
+      if (cancelled) {
+        return;
+      }
+      httpRequest = createHttpRequest();
+      // Check if already cancelled before sending the request;
+      if (cancelled) {
+        return;
+      }
 
-  abstract ImmutableList<HttpJsonHeaderEnhancer> getHeaderEnhancers();
+      httpResponse = httpRequest.execute();
 
-  abstract SettableApiFuture<ResponseT> getResponseFuture();
+      // Check if already cancelled before sending the request;
+      if (cancelled) {
+        httpResponse.disconnect();
+        return;
+      }
+      result.setResponseHeaders(
+          HttpJsonMetadata.newBuilder().setHeaders(httpResponse.getHeaders()).build());
+      result.setStatusCode(httpResponse.getStatusCode());
+      result.setResponseContent(httpResponse.getContent());
+      trailers.setStatusMessage(httpResponse.getStatusMessage());
+    } catch (HttpResponseException e) {
+      result.setStatusCode(e.getStatusCode());
+      result.setResponseHeaders(HttpJsonMetadata.newBuilder().setHeaders(e.getHeaders()).build());
+      result.setResponseContent(
+          new ByteArrayInputStream(e.getContent().getBytes(StandardCharsets.UTF_8)));
+      trailers.setStatusMessage(e.getStatusMessage());
+      trailers.setException(e);
+    } catch (Throwable e) {
+      if (httpResponse != null) {
+        trailers.setStatusMessage(httpResponse.getStatusMessage());
+        result.setStatusCode(httpResponse.getStatusCode());
+      } else {
+        result.setStatusCode(400);
+      }
+      trailers.setException(e);
+    } finally {
+      if (!cancelled) {
+        resultListener.setResult(result.setTrailers(trailers.build()).build());
+      }
+    }
+  }
 
   HttpRequest createHttpRequest() throws IOException {
     GenericData tokenRequest = new GenericData();
 
-    HttpRequestFormatter<RequestT> requestFormatter =
-        getApiMethodDescriptor().getRequestFormatter();
+    HttpRequestFormatter<RequestT> requestFormatter = methodDescriptor.getRequestFormatter();
 
     HttpRequestFactory requestFactory;
-    Credentials credentials = getHttpJsonCallOptions().getCredentials();
+    Credentials credentials = httpJsonCallOptions.getCredentials();
     if (credentials != null) {
-      requestFactory =
-          getHttpTransport().createRequestFactory(new HttpCredentialsAdapter(credentials));
+      requestFactory = httpTransport.createRequestFactory(new HttpCredentialsAdapter(credentials));
     } else {
-      requestFactory = getHttpTransport().createRequestFactory();
+      requestFactory = httpTransport.createRequestFactory();
     }
 
+    JsonFactory jsonFactory = GsonFactory.getDefaultInstance();
     // Create HTTP request body.
-    String requestBody = requestFormatter.getRequestBody(getRequest());
+    String requestBody = requestFormatter.getRequestBody(request);
     HttpContent jsonHttpContent;
     if (!Strings.isNullOrEmpty(requestBody)) {
-      getJsonFactory().createJsonParser(requestBody).parse(tokenRequest);
+      jsonFactory.createJsonParser(requestBody).parse(tokenRequest);
       jsonHttpContent =
-          new JsonHttpContent(getJsonFactory(), tokenRequest)
+          new JsonHttpContent(jsonFactory, tokenRequest)
               .setMediaType((new HttpMediaType("application/json")));
     } else {
       // Force underlying HTTP lib to set Content-Length header to avoid 411s.
@@ -105,9 +178,9 @@ HttpRequest createHttpRequest() throws IOException {
     }
 
     // Populate URL path and query parameters.
-    String endpoint = normalizeEndpoint(getEndpoint());
-    GenericUrl url = new GenericUrl(endpoint + requestFormatter.getPath(getRequest()));
-    Map<String, List<String>> queryParams = requestFormatter.getQueryParamNames(getRequest());
+    String normalizedEndpoint = normalizeEndpoint(endpoint);
+    GenericUrl url = new GenericUrl(normalizedEndpoint + requestFormatter.getPath(request));
+    Map<String, List<String>> queryParams = requestFormatter.getQueryParamNames(request);
     for (Entry<String, List<String>> queryParam : queryParams.entrySet()) {
       if (queryParam.getValue() != null) {
         url.set(queryParam.getKey(), queryParam.getValue());
@@ -116,7 +189,7 @@ HttpRequest createHttpRequest() throws IOException {
 
     HttpRequest httpRequest = buildRequest(requestFactory, url, jsonHttpContent);
 
-    Instant deadline = getHttpJsonCallOptions().getDeadline();
+    Instant deadline = httpJsonCallOptions.getDeadline();
     if (deadline != null) {
       long readTimeout = Duration.between(Instant.now(), deadline).toMillis();
       if (httpRequest.getReadTimeout() > 0
@@ -126,10 +199,13 @@ HttpRequest createHttpRequest() throws IOException {
       }
     }
 
-    for (HttpJsonHeaderEnhancer enhancer : getHeaderEnhancers()) {
-      enhancer.enhance(httpRequest.getHeaders());
+    for (Map.Entry<String, Object> entry : headers.getHeaders().entrySet()) {
+      HttpHeadersUtils.setHeader(
+          httpRequest.getHeaders(), entry.getKey(), (String) entry.getValue());
     }
-    httpRequest.setParser(new JsonObjectParser(getJsonFactory()));
+
+    httpRequest.setParser(new JsonObjectParser(jsonFactory));
+
     return httpRequest;
   }
 
@@ -155,7 +231,7 @@ private HttpRequest buildRequest(
     // gax-httpjson is), writing own implementation of HttpUrlConnection (fragile and a lot of
     // work), depending on v2.ApacheHttpTransport (it has many extra dependencies, does not support
     // mtls etc).
-    String actualHttpMethod = getApiMethodDescriptor().getHttpMethod();
+    String actualHttpMethod = methodDescriptor.getHttpMethod();
     String originalHttpMethod = actualHttpMethod;
     if (HttpMethods.PATCH.equals(actualHttpMethod)) {
       actualHttpMethod = HttpMethods.POST;
@@ -169,8 +245,8 @@ private HttpRequest buildRequest(
   }
 
   // This will be frequently executed, so avoiding using regexps if not necessary.
-  private String normalizeEndpoint(String endpoint) {
-    String normalized = endpoint;
+  private String normalizeEndpoint(String rawEndpoint) {
+    String normalized = rawEndpoint;
     // Set protocol as https by default if not set explicitly
     if (!normalized.contains("://")) {
       normalized = "https://" + normalized;
@@ -183,53 +259,39 @@ private String normalizeEndpoint(String endpoint) {
     return normalized;
   }
 
-  @Override
-  public void run() {
-    try {
-      HttpRequest httpRequest = createHttpRequest();
-      HttpResponse httpResponse = httpRequest.execute();
-
-      if (getApiMethodDescriptor().getResponseParser() != null) {
-        ResponseT response =
-            getApiMethodDescriptor()
-                .getResponseParser()
-                .parse(httpResponse.getContent(), getHttpJsonCallOptions().getTypeRegistry());
-
-        getResponseFuture().set(response);
-      } else {
-        getResponseFuture().set(null);
-      }
-    } catch (Exception e) {
-      getResponseFuture().setException(e);
-    }
+  @FunctionalInterface
+  interface ResultListener {
+    void setResult(RunnableResult result);
   }
 
-  static <RequestT, ResponseT> Builder<RequestT, ResponseT> newBuilder() {
-    return new AutoValue_HttpRequestRunnable.Builder<RequestT, ResponseT>()
-        .setHeaderEnhancers(new LinkedList<>());
-  }
+  @AutoValue
+  abstract static class RunnableResult {
+    @Nullable
+    abstract HttpJsonMetadata getResponseHeaders();
 
-  @AutoValue.Builder
-  abstract static class Builder<RequestT, ResponseT> {
-    abstract Builder<RequestT, ResponseT> setHttpJsonCallOptions(HttpJsonCallOptions callOptions);
+    abstract int getStatusCode();
 
-    abstract Builder<RequestT, ResponseT> setRequest(RequestT request);
+    @Nullable
+    abstract InputStream getResponseContent();
 
-    abstract Builder<RequestT, ResponseT> setApiMethodDescriptor(
-        ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor);
+    abstract HttpJsonMetadata getTrailers();
 
-    abstract Builder<RequestT, ResponseT> setHttpTransport(HttpTransport httpTransport);
+    public static Builder builder() {
+      return new AutoValue_HttpRequestRunnable_RunnableResult.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
 
-    abstract Builder<RequestT, ResponseT> setEndpoint(String endpoint);
+      public abstract Builder setResponseHeaders(HttpJsonMetadata newResponseHeaders);
 
-    abstract Builder<RequestT, ResponseT> setJsonFactory(JsonFactory jsonFactory);
+      public abstract Builder setStatusCode(int newStatusCode);
 
-    abstract Builder<RequestT, ResponseT> setHeaderEnhancers(
-        List<HttpJsonHeaderEnhancer> headerEnhancers);
+      public abstract Builder setResponseContent(InputStream newResponseContent);
 
-    abstract Builder<RequestT, ResponseT> setResponseFuture(
-        SettableApiFuture<ResponseT> responseFuture);
+      public abstract Builder setTrailers(HttpJsonMetadata newTrailers);
 
-    abstract HttpRequestRunnable<RequestT, ResponseT> build();
+      public abstract RunnableResult build();
+    }
   }
 }
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpResponseParser.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpResponseParser.java
index 78aacf2dd..b2c3d5fc8 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpResponseParser.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpResponseParser.java
@@ -33,6 +33,7 @@
 import com.google.api.core.InternalExtensionOnly;
 import com.google.protobuf.TypeRegistry;
 import java.io.InputStream;
+import java.io.Reader;
 
 /** Interface for classes that parse parts of HTTP responses into the parameterized message type. */
 @InternalExtensionOnly
@@ -50,13 +51,23 @@ public interface HttpResponseParser<MessageFormatT> {
   /**
    * Parse the http body content JSON stream into the MessageFormatT.
    *
-   * @param httpContent the body of an HTTP response
+   * @param httpContent the body of an HTTP response, represented as an {@link InputStream}
    * @param registry type registry with Any fields descriptors
    * @throws RestSerializationException if failed to parse the {@code httpContent} to a valid {@code
    *     MessageFormatT}
    */
   MessageFormatT parse(InputStream httpContent, TypeRegistry registry);
 
+  /**
+   * Parse the http body content JSON reader into the MessageFormatT.
+   *
+   * @param httpContent the body of an HTTP response, represented as a {@link Reader}
+   * @param registry type registry with Any fields descriptors
+   * @throws RestSerializationException if failed to parse the {@code httpContent} to a valid {@code
+   *     MessageFormatT}
+   */
+  MessageFormatT parse(Reader httpContent, TypeRegistry registry);
+
   /**
    * Serialize an object into an HTTP body, which is written out to output.
    *
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java
index 2e4ff935b..ca92d0fbe 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java
@@ -41,11 +41,10 @@
 import com.google.api.gax.rpc.mtls.MtlsProvider;
 import com.google.auth.Credentials;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.Lists;
 import java.io.IOException;
 import java.security.GeneralSecurityException;
 import java.security.KeyStore;
-import java.util.List;
+import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ScheduledExecutorService;
@@ -187,12 +186,7 @@ HttpTransport createHttpTransport() throws IOException, GeneralSecurityException
   }
 
   private TransportChannel createChannel() throws IOException, GeneralSecurityException {
-    Map<String, String> headers = headerProvider.getHeaders();
-
-    List<HttpJsonHeaderEnhancer> headerEnhancers = Lists.newArrayList();
-    for (Map.Entry<String, String> header : headers.entrySet()) {
-      headerEnhancers.add(HttpJsonHeaderEnhancers.create(header.getKey(), header.getValue()));
-    }
+    Map<String, Object> headers = new HashMap<>(headerProvider.getHeaders());
 
     HttpTransport httpTransportToUse = httpTransport;
     if (httpTransportToUse == null) {
@@ -202,7 +196,7 @@ private TransportChannel createChannel() throws IOException, GeneralSecurityExce
     ManagedHttpJsonChannel channel =
         ManagedHttpJsonChannel.newBuilder()
             .setEndpoint(endpoint)
-            .setHeaderEnhancers(headerEnhancers)
+            .setDefaultHeaders(HttpJsonMetadata.newBuilder().setHeaders(headers).build())
             .setExecutor(executor)
             .setHttpTransport(httpTransportToUse)
             .build();
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java
index 760f3f0b1..d75152230 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java
@@ -31,18 +31,12 @@
 
 import com.google.api.client.http.HttpTransport;
 import com.google.api.client.http.javanet.NetHttpTransport;
-import com.google.api.client.json.JsonFactory;
-import com.google.api.client.json.gson.GsonFactory;
 import com.google.api.core.ApiFuture;
 import com.google.api.core.BetaApi;
-import com.google.api.core.SettableApiFuture;
 import com.google.api.gax.core.BackgroundResource;
 import com.google.api.gax.core.InstantiatingExecutorProvider;
 import com.google.common.base.Preconditions;
-import com.google.common.collect.ImmutableList;
 import java.io.IOException;
-import java.util.LinkedList;
-import java.util.List;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.TimeUnit;
@@ -51,14 +45,12 @@
 /** Implementation of HttpJsonChannel which can issue http-json calls. */
 @BetaApi
 public class ManagedHttpJsonChannel implements HttpJsonChannel, BackgroundResource {
-  private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
   private static final ExecutorService DEFAULT_EXECUTOR =
       InstantiatingExecutorProvider.newBuilder().build().getExecutor();
 
   private final Executor executor;
   private final String endpoint;
-  private final JsonFactory jsonFactory;
-  private final ImmutableList<HttpJsonHeaderEnhancer> headerEnhancers;
+  private final HttpJsonMetadata defaultHeaders;
   private final HttpTransport httpTransport;
 
   private boolean isTransportShutdown;
@@ -66,38 +58,31 @@ public class ManagedHttpJsonChannel implements HttpJsonChannel, BackgroundResour
   private ManagedHttpJsonChannel(
       Executor executor,
       String endpoint,
-      JsonFactory jsonFactory,
-      List<HttpJsonHeaderEnhancer> headerEnhancers,
-      @Nullable HttpTransport httpTransport) {
+      @Nullable HttpTransport httpTransport,
+      HttpJsonMetadata defaultHeaders) {
     this.executor = executor;
     this.endpoint = endpoint;
-    this.jsonFactory = jsonFactory;
-    this.headerEnhancers = ImmutableList.copyOf(headerEnhancers);
     this.httpTransport = httpTransport == null ? new NetHttpTransport() : httpTransport;
+    this.defaultHeaders = defaultHeaders;
   }
 
   @Override
+  public <RequestT, ResponseT> HttpJsonClientCall<RequestT, ResponseT> newCall(
+      ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor, HttpJsonCallOptions callOptions) {
+
+    return new HttpJsonClientCallImpl<>(
+        methodDescriptor, endpoint, callOptions, httpTransport, executor, defaultHeaders);
+  }
+
+  @Override
+  @Deprecated
   public <ResponseT, RequestT> ApiFuture<ResponseT> issueFutureUnaryCall(
       HttpJsonCallOptions callOptions,
       RequestT request,
       ApiMethodDescriptor<RequestT, ResponseT> methodDescriptor) {
-    final SettableApiFuture<ResponseT> responseFuture = SettableApiFuture.create();
-
-    HttpRequestRunnable<RequestT, ResponseT> runnable =
-        HttpRequestRunnable.<RequestT, ResponseT>newBuilder()
-            .setResponseFuture(responseFuture)
-            .setApiMethodDescriptor(methodDescriptor)
-            .setHeaderEnhancers(headerEnhancers)
-            .setHttpJsonCallOptions(callOptions)
-            .setHttpTransport(httpTransport)
-            .setJsonFactory(jsonFactory)
-            .setRequest(request)
-            .setEndpoint(endpoint)
-            .build();
-
-    executor.execute(runnable);
-
-    return responseFuture;
+
+    return HttpJsonClientCalls.eagerFutureUnaryCall(
+        newCall(methodDescriptor, callOptions), request);
   }
 
   @Override
@@ -139,15 +124,14 @@ public void close() {}
 
   public static Builder newBuilder() {
     return new Builder()
-        .setHeaderEnhancers(new LinkedList<HttpJsonHeaderEnhancer>())
+        .setDefaultHeaders(HttpJsonMetadata.newBuilder().build())
         .setExecutor(DEFAULT_EXECUTOR);
   }
 
   public static class Builder {
     private Executor executor;
     private String endpoint;
-    private JsonFactory jsonFactory = JSON_FACTORY;
-    private List<HttpJsonHeaderEnhancer> headerEnhancers;
+    private HttpJsonMetadata defaultHeaders;
     private HttpTransport httpTransport;
 
     private Builder() {}
@@ -162,8 +146,8 @@ public Builder setEndpoint(String endpoint) {
       return this;
     }
 
-    public Builder setHeaderEnhancers(List<HttpJsonHeaderEnhancer> headerEnhancers) {
-      this.headerEnhancers = headerEnhancers;
+    public Builder setDefaultHeaders(HttpJsonMetadata defaultHeaders) {
+      this.defaultHeaders = defaultHeaders;
       return this;
     }
 
@@ -174,8 +158,7 @@ public Builder setHttpTransport(HttpTransport httpTransport) {
 
     public ManagedHttpJsonChannel build() {
       Preconditions.checkNotNull(endpoint);
-      return new ManagedHttpJsonChannel(
-          executor, endpoint, jsonFactory, headerEnhancers, httpTransport);
+      return new ManagedHttpJsonChannel(executor, endpoint, httpTransport, defaultHeaders);
     }
   }
 }
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageJsonStreamIterator.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageJsonStreamIterator.java
new file mode 100644
index 000000000..84167de5e
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageJsonStreamIterator.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.PipedReader;
+import java.io.PipedWriter;
+import java.io.Reader;
+import java.util.Iterator;
+
+/** This class is not thread-safe and is expected to be used under external synchronization. */
+class ProtoMessageJsonStreamIterator implements Closeable, Iterator<Reader> {
+  private volatile boolean arrayStarted;
+  private final JsonReader jsonReader;
+  private final PipedReader reader;
+  private final PipedWriter writer;
+
+  ProtoMessageJsonStreamIterator(Reader rawReader) throws IOException {
+    this.arrayStarted = false;
+    this.jsonReader = new JsonReader(rawReader);
+    this.reader = new PipedReader(0x40000); // 256K
+    this.writer = new PipedWriter();
+    writer.connect(reader);
+  }
+
+  @Override
+  public void close() throws IOException {
+    reader.close();
+    writer.close();
+    jsonReader.close();
+  }
+
+  public boolean hasNext() {
+    try {
+      if (!arrayStarted) {
+        jsonReader.beginArray();
+        arrayStarted = true;
+      }
+      return jsonReader.hasNext();
+    } catch (IOException e) {
+      throw new RestSerializationException(e);
+    }
+  }
+
+  @Override
+  public Reader next() {
+    try {
+      int nestedObjectCount = 0;
+      JsonWriter jsonWriter = new JsonWriter(writer);
+      do {
+        JsonToken token = jsonReader.peek();
+        switch (token) {
+          case BEGIN_ARRAY:
+            jsonReader.beginArray();
+            jsonWriter.beginArray();
+            break;
+          case END_ARRAY:
+            jsonReader.endArray();
+            jsonWriter.endArray();
+            break;
+          case BEGIN_OBJECT:
+            nestedObjectCount++;
+            jsonReader.beginObject();
+            jsonWriter.beginObject();
+            break;
+          case END_OBJECT:
+            jsonReader.endObject();
+            jsonWriter.endObject();
+            nestedObjectCount--;
+            break;
+          case NAME:
+            String name = jsonReader.nextName();
+            jsonWriter.name(name);
+            break;
+          case STRING:
+            String s = jsonReader.nextString();
+            jsonWriter.value(s);
+            break;
+          case NUMBER:
+            String n = jsonReader.nextString();
+            jsonWriter.value(n);
+            break;
+          case BOOLEAN:
+            boolean b = jsonReader.nextBoolean();
+            jsonWriter.value(b);
+            break;
+          case NULL:
+            jsonReader.nextNull();
+            jsonWriter.nullValue();
+            break;
+          case END_DOCUMENT:
+            nestedObjectCount--;
+        }
+      } while (nestedObjectCount > 0);
+
+      jsonWriter.flush();
+
+      return reader;
+    } catch (IOException e) {
+      throw new RestSerializationException(e);
+    }
+  }
+}
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java
index fabf77ce7..84d39418d 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java
@@ -32,7 +32,10 @@
 import com.google.api.core.BetaApi;
 import com.google.protobuf.Message;
 import com.google.protobuf.TypeRegistry;
+import java.io.IOException;
 import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
 import java.nio.charset.StandardCharsets;
 
 /** The implementation of {@link HttpResponseParser} which works with protobuf messages. */
@@ -43,28 +46,36 @@ public class ProtoMessageResponseParser<ResponseT extends Message>
   private final ResponseT defaultInstance;
   private final TypeRegistry defaultRegistry;
 
-  private ProtoMessageResponseParser(ResponseT defaultInstance, TypeRegistry defaultRegistry) {
+  protected ProtoMessageResponseParser(ResponseT defaultInstance, TypeRegistry defaultRegistry) {
     this.defaultInstance = defaultInstance;
     this.defaultRegistry = defaultRegistry;
   }
 
-  public static <RequestT extends Message>
-      ProtoMessageResponseParser.Builder<RequestT> newBuilder() {
-    return new ProtoMessageResponseParser.Builder<RequestT>()
+  public static <ResponseT extends Message>
+      ProtoMessageResponseParser.Builder<ResponseT> newBuilder() {
+    return new ProtoMessageResponseParser.Builder<ResponseT>()
         .setDefaultTypeRegistry(TypeRegistry.getEmptyTypeRegistry());
   }
 
   /* {@inheritDoc} */
   @Override
   public ResponseT parse(InputStream httpContent) {
-    return ProtoRestSerializer.<ResponseT>create(defaultRegistry)
-        .fromJson(httpContent, StandardCharsets.UTF_8, defaultInstance.newBuilderForType());
+    return parse(httpContent, defaultRegistry);
   }
 
   @Override
   public ResponseT parse(InputStream httpContent, TypeRegistry registry) {
+    try (Reader json = new InputStreamReader(httpContent, StandardCharsets.UTF_8)) {
+      return parse(json, registry);
+    } catch (IOException e) {
+      throw new RestSerializationException("Failed to parse response message", e);
+    }
+  }
+
+  @Override
+  public ResponseT parse(Reader httpContent, TypeRegistry registry) {
     return ProtoRestSerializer.<ResponseT>create(registry)
-        .fromJson(httpContent, StandardCharsets.UTF_8, defaultInstance.newBuilderForType());
+        .fromJson(httpContent, defaultInstance.newBuilderForType());
   }
 
   /* {@inheritDoc} */
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoRestSerializer.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoRestSerializer.java
index 39f352910..9c75be0b0 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoRestSerializer.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoRestSerializer.java
@@ -36,10 +36,7 @@
 import com.google.protobuf.TypeRegistry;
 import com.google.protobuf.util.JsonFormat;
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
 import java.io.Reader;
-import java.nio.charset.Charset;
 import java.util.List;
 import java.util.Map;
 
@@ -86,15 +83,14 @@ String toJson(RequestT message) {
   /**
    * Deserializes a {@code message} from an input stream to a protobuf message.
    *
-   * @param message the input stream with a JSON-encoded message in it
-   * @param messageCharset the message charset
+   * @param json the input reader with a JSON-encoded message in it
    * @param builder an empty builder for the specific {@code RequestT} message to serialize
    * @throws RestSerializationException if failed to deserialize a protobuf message from the JSON
    *     stream
    */
   @SuppressWarnings("unchecked")
-  RequestT fromJson(InputStream message, Charset messageCharset, Message.Builder builder) {
-    try (Reader json = new InputStreamReader(message, messageCharset)) {
+  RequestT fromJson(Reader json, Message.Builder builder) {
+    try {
       JsonFormat.parser().usingTypeRegistry(registry).ignoringUnknownFields().merge(json, builder);
       return (RequestT) builder.build();
     } catch (IOException e) {
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/StatusRuntimeException.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/StatusRuntimeException.java
new file mode 100644
index 000000000..6406e21a4
--- /dev/null
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/StatusRuntimeException.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.google.api.gax.httpjson;
+
+/**
+ * HTTP status code in RuntimeException form, for propagating status code information via
+ * exceptions.
+ */
+public class StatusRuntimeException extends RuntimeException {
+  private static final long serialVersionUID = -5390915748330242256L;
+
+  private final int statusCode;
+
+  public StatusRuntimeException(int statusCode, String message, Throwable cause) {
+    super(message, cause);
+    this.statusCode = statusCode;
+  }
+
+  public int getStatusCode() {
+    return statusCode;
+  }
+}
diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ApiMessageHttpRequestTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ApiMessageHttpRequestTest.java
index a5ac52638..174e5ac90 100644
--- a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ApiMessageHttpRequestTest.java
+++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ApiMessageHttpRequestTest.java
@@ -30,17 +30,13 @@
 package com.google.api.gax.httpjson;
 
 import com.google.api.client.http.HttpRequest;
-import com.google.api.client.json.gson.GsonFactory;
 import com.google.api.client.testing.http.MockHttpTransport;
-import com.google.api.core.SettableApiFuture;
 import com.google.api.pathtemplate.PathTemplate;
 import com.google.api.resourcenames.ResourceName;
 import com.google.api.resourcenames.ResourceNameFactory;
-import com.google.auth.Credentials;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.truth.Truth;
-import com.google.protobuf.TypeRegistry;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.HashMap;
@@ -48,29 +44,15 @@
 import java.util.Map;
 import javax.annotation.Nullable;
 import org.junit.Test;
-import org.threeten.bp.Instant;
+import org.mockito.Mockito;
 
 public class ApiMessageHttpRequestTest {
   private static final String ENDPOINT = "https://www.googleapis.com/animals/v1/projects/";
-  private static PathTemplate nameTemplate = PathTemplate.create("name/{name}");
-
-  private static HttpJsonCallOptions fakeCallOptions =
-      new HttpJsonCallOptions() {
-        @Override
-        public Instant getDeadline() {
-          return null;
-        }
-
-        @Override
-        public Credentials getCredentials() {
-          return null;
-        }
-
-        @Override
-        public TypeRegistry getTypeRegistry() {
-          return null;
-        }
-      };
+  private static final PathTemplate nameTemplate = PathTemplate.create("name/{name}");
+
+  @SuppressWarnings("unchecked")
+  private static final HttpResponseParser<EmptyMessage> responseParser =
+      Mockito.mock(HttpResponseParser.class);
 
   @Test
   public void testFieldMask() throws IOException {
@@ -149,18 +131,18 @@ public String getFieldValue(String s) {
             .setFullMethodName("house.details.get")
             .setHttpMethod(null)
             .setRequestFormatter(frogFormatter)
+            .setResponseParser(responseParser)
             .build();
 
-    HttpRequestRunnable httpRequestRunnable =
-        HttpRequestRunnable.<InsertFrogRequest, EmptyMessage>newBuilder()
-            .setHttpJsonCallOptions(fakeCallOptions)
-            .setEndpoint(ENDPOINT)
-            .setRequest(insertFrogRequest)
-            .setApiMethodDescriptor(apiMethodDescriptor)
-            .setHttpTransport(new MockHttpTransport())
-            .setJsonFactory(new GsonFactory())
-            .setResponseFuture(SettableApiFuture.<EmptyMessage>create())
-            .build();
+    HttpRequestRunnable<InsertFrogRequest, EmptyMessage> httpRequestRunnable =
+        new HttpRequestRunnable<>(
+            insertFrogRequest,
+            apiMethodDescriptor,
+            ENDPOINT,
+            HttpJsonCallOptions.newBuilder().build(),
+            new MockHttpTransport(),
+            HttpJsonMetadata.newBuilder().build(),
+            (result) -> {});
 
     HttpRequest httpRequest = httpRequestRunnable.createHttpRequest();
     String expectedUrl = ENDPOINT + "name/tree_frog" + "?requestId=request57";
diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectCallableTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectCallableTest.java
index 4a2d59136..394f7df32 100644
--- a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectCallableTest.java
+++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectCallableTest.java
@@ -31,177 +31,158 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.api.core.SettableApiFuture;
-import com.google.api.pathtemplate.PathTemplate;
-import com.google.common.collect.ImmutableMap;
-import com.google.protobuf.TypeRegistry;
-import java.io.InputStream;
+import com.google.api.client.http.HttpResponseException;
+import com.google.api.gax.httpjson.testing.MockHttpService;
+import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.ApiExceptionFactory;
+import com.google.api.gax.rpc.StatusCode.Code;
+import com.google.api.gax.rpc.testing.FakeStatusCode;
+import com.google.protobuf.Field;
+import com.google.protobuf.Field.Cardinality;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.BeforeClass;
 import org.junit.Test;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mockito;
 import org.threeten.bp.Duration;
-import org.threeten.bp.Instant;
 
 public class HttpJsonDirectCallableTest {
-  private final ApiMethodDescriptor<String, String> API_DESCRIPTOR =
-      ApiMethodDescriptor.<String, String>newBuilder()
-          .setFullMethodName("fakeMethod")
-          .setHttpMethod("GET")
-          .setRequestFormatter(new FakeRequestFormatter())
-          .setResponseParser(new FakeResponseParser())
+  private static final ApiMethodDescriptor<Field, Field> FAKE_METHOD_DESCRIPTOR =
+      ApiMethodDescriptor.<Field, Field>newBuilder()
+          .setFullMethodName("google.cloud.v1.Fake/FakeMethod")
+          .setHttpMethod("POST")
+          .setRequestFormatter(
+              ProtoMessageRequestFormatter.<Field>newBuilder()
+                  .setPath(
+                      "/fake/v1/name/{name}",
+                      request -> {
+                        Map<String, String> fields = new HashMap<>();
+                        ProtoRestSerializer<Field> serializer = ProtoRestSerializer.create();
+                        serializer.putPathParam(fields, "name", request.getName());
+                        return fields;
+                      })
+                  .setQueryParamsExtractor(
+                      request -> {
+                        Map<String, List<String>> fields = new HashMap<>();
+                        ProtoRestSerializer<Field> serializer = ProtoRestSerializer.create();
+                        serializer.putQueryParam(fields, "number", request.getNumber());
+                        return fields;
+                      })
+                  .setRequestBodyExtractor(
+                      request ->
+                          ProtoRestSerializer.create()
+                              .toBody("*", request.toBuilder().clearName().build()))
+                  .build())
+          .setResponseParser(
+              ProtoMessageResponseParser.<Field>newBuilder()
+                  .setDefaultInstance(Field.getDefaultInstance())
+                  .build())
           .build();
 
-  @SuppressWarnings("unchecked")
-  @Test
-  public void testTimeout() {
-    HttpJsonChannel mockChannel = Mockito.mock(HttpJsonChannel.class);
-
-    String expectedRequest = "fake";
-
-    HttpJsonDirectCallable<String, String> callable = new HttpJsonDirectCallable<>(API_DESCRIPTOR);
-
-    // Mock the channel that captures the call options
-    ArgumentCaptor<HttpJsonCallOptions> capturedCallOptions =
-        ArgumentCaptor.forClass(HttpJsonCallOptions.class);
-
-    Mockito.when(
-            mockChannel.issueFutureUnaryCall(
-                capturedCallOptions.capture(),
-                Mockito.anyString(),
-                Mockito.any(ApiMethodDescriptor.class)))
-        .thenReturn(SettableApiFuture.create());
-
-    // Compose the call context
-    Duration timeout = Duration.ofSeconds(10);
-    Instant minExpectedDeadline = Instant.now().plus(timeout);
-
-    HttpJsonCallContext callContext =
-        HttpJsonCallContext.createDefault().withChannel(mockChannel).withTimeout(timeout);
-
-    callable.futureCall(expectedRequest, callContext);
-
-    Instant maxExpectedDeadline = Instant.now().plus(timeout);
+  private static final MockHttpService MOCK_SERVICE =
+      new MockHttpService(Collections.singletonList(FAKE_METHOD_DESCRIPTOR), "google.com:443");
+
+  private final ManagedHttpJsonChannel channel =
+      ManagedHttpJsonChannel.newBuilder()
+          .setEndpoint("google.com:443")
+          .setDefaultHeaders(
+              HttpJsonMetadata.newBuilder()
+                  .setHeaders(Collections.singletonMap("header-key", "headerValue"))
+                  .build())
+          .setExecutor(executorService)
+          .setHttpTransport(MOCK_SERVICE)
+          .build();
 
-    // Verify that the timeout was converted into a deadline
-    assertThat(capturedCallOptions.getValue().getDeadline()).isAtLeast(minExpectedDeadline);
-    assertThat(capturedCallOptions.getValue().getDeadline()).isAtMost(maxExpectedDeadline);
+  private static ExecutorService executorService;
+
+  @BeforeClass
+  public static void initialize() {
+    executorService =
+        Executors.newFixedThreadPool(
+            2,
+            r -> {
+              Thread t = Executors.defaultThreadFactory().newThread(r);
+              t.setDaemon(true);
+              return t;
+            });
   }
 
-  @SuppressWarnings("unchecked")
-  @Test
-  public void testTimeoutAfterDeadline() {
-    HttpJsonChannel mockChannel = Mockito.mock(HttpJsonChannel.class);
-
-    String expectedRequest = "fake";
-
-    HttpJsonDirectCallable<String, String> callable = new HttpJsonDirectCallable<>(API_DESCRIPTOR);
-
-    // Mock the channel that captures the call options
-    ArgumentCaptor<HttpJsonCallOptions> capturedCallOptions =
-        ArgumentCaptor.forClass(HttpJsonCallOptions.class);
-
-    Mockito.when(
-            mockChannel.issueFutureUnaryCall(
-                capturedCallOptions.capture(),
-                Mockito.anyString(),
-                Mockito.any(ApiMethodDescriptor.class)))
-        .thenReturn(SettableApiFuture.create());
-
-    // Compose the call context
-    Instant priorDeadline = Instant.now().plusSeconds(5);
-    Duration timeout = Duration.ofSeconds(10);
-
-    HttpJsonCallContext callContext =
-        HttpJsonCallContext.createDefault()
-            .withChannel(mockChannel)
-            .withDeadline(priorDeadline)
-            .withTimeout(timeout);
-
-    callable.futureCall(expectedRequest, callContext);
+  @AfterClass
+  public static void destroy() {
+    executorService.shutdownNow();
+  }
 
-    // Verify that the timeout was ignored
-    assertThat(capturedCallOptions.getValue().getDeadline()).isEqualTo(priorDeadline);
+  @After
+  public void tearDown() {
+    MOCK_SERVICE.reset();
   }
 
-  @SuppressWarnings("unchecked")
   @Test
-  public void testTimeoutBeforeDeadline() {
-    HttpJsonChannel mockChannel = Mockito.mock(HttpJsonChannel.class);
-
-    String expectedRequest = "fake";
-
-    HttpJsonDirectCallable<String, String> callable = new HttpJsonDirectCallable<>(API_DESCRIPTOR);
-
-    // Mock the channel that captures the call options
-    ArgumentCaptor<HttpJsonCallOptions> capturedCallOptions =
-        ArgumentCaptor.forClass(HttpJsonCallOptions.class);
-
-    Mockito.when(
-            mockChannel.issueFutureUnaryCall(
-                capturedCallOptions.capture(),
-                Mockito.anyString(),
-                Mockito.any(ApiMethodDescriptor.class)))
-        .thenReturn(SettableApiFuture.create());
-
-    // Compose the call context
-    Duration timeout = Duration.ofSeconds(10);
-    Instant subsequentDeadline = Instant.now().plusSeconds(15);
-
-    Instant minExpectedDeadline = Instant.now().plus(timeout);
+  public void testSuccessfulUnaryResponse() throws ExecutionException, InterruptedException {
+    HttpJsonDirectCallable<Field, Field> callable =
+        new HttpJsonDirectCallable<>(FAKE_METHOD_DESCRIPTOR);
 
     HttpJsonCallContext callContext =
         HttpJsonCallContext.createDefault()
-            .withChannel(mockChannel)
-            .withDeadline(subsequentDeadline)
-            .withTimeout(timeout);
-
-    callable.futureCall(expectedRequest, callContext);
-
-    Instant maxExpectedDeadline = Instant.now().plus(timeout);
-
-    // Verify that the timeout was converted into a deadline
-    assertThat(capturedCallOptions.getValue().getDeadline()).isAtLeast(minExpectedDeadline);
-    assertThat(capturedCallOptions.getValue().getDeadline()).isAtMost(maxExpectedDeadline);
-  }
-
-  private static final class FakeRequestFormatter implements HttpRequestFormatter<String> {
-    @Override
-    public Map<String, List<String>> getQueryParamNames(String apiMessage) {
-      return ImmutableMap.of();
-    }
-
-    @Override
-    public String getRequestBody(String apiMessage) {
-      return "fake";
-    }
-
-    @Override
-    public String getPath(String apiMessage) {
-      return "/fake/path";
-    }
-
-    @Override
-    public PathTemplate getPathTemplate() {
-      return PathTemplate.create("/fake/path");
-    }
+            .withChannel(channel)
+            .withTimeout(Duration.ofSeconds(30));
+
+    Field request;
+    Field expectedResponse;
+    request =
+        expectedResponse =
+            Field.newBuilder() // "echo" service
+                .setName("imTheBestField")
+                .setNumber(2)
+                .setCardinality(Cardinality.CARDINALITY_OPTIONAL)
+                .setDefaultValue("blah")
+                .build();
+
+    MOCK_SERVICE.addResponse(expectedResponse);
+
+    Field actualResponse = callable.futureCall(request, callContext).get();
+
+    assertThat(actualResponse).isEqualTo(expectedResponse);
+    assertThat(MOCK_SERVICE.getRequestPaths().size()).isEqualTo(1);
+    String headerValue = MOCK_SERVICE.getRequestHeaders().get("header-key").iterator().next();
+    assertThat(headerValue).isEqualTo("headerValue");
   }
 
-  private static final class FakeResponseParser implements HttpResponseParser<String> {
-    @Override
-    public String parse(InputStream httpContent) {
-      return "fake";
-    }
-
-    @Override
-    public String parse(InputStream httpContent, TypeRegistry registry) {
-      return parse(httpContent);
-    }
-
-    @Override
-    public String serialize(String response) {
-      return response;
+  @Test
+  public void testErrorUnaryResponse() throws InterruptedException {
+    HttpJsonDirectCallable<Field, Field> callable =
+        new HttpJsonDirectCallable<>(FAKE_METHOD_DESCRIPTOR);
+
+    HttpJsonCallContext callContext = HttpJsonCallContext.createDefault().withChannel(channel);
+
+    Field request;
+    request =
+        Field.newBuilder() // "echo" service
+            .setName("imTheBestField")
+            .setNumber(2)
+            .setCardinality(Cardinality.CARDINALITY_OPTIONAL)
+            .setDefaultValue("blah")
+            .build();
+
+    ApiException exception =
+        ApiExceptionFactory.createException(
+            new Exception(), FakeStatusCode.of(Code.NOT_FOUND), false);
+    MOCK_SERVICE.addException(exception);
+
+    try {
+      callable.futureCall(request, callContext).get();
+      Assert.fail("No exception raised");
+    } catch (ExecutionException e) {
+      HttpResponseException respExp = (HttpResponseException) e.getCause();
+      assertThat(respExp.getStatusCode()).isEqualTo(400);
+      assertThat(respExp.getContent()).isEqualTo(exception.toString());
     }
   }
 }
diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallableTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallableTest.java
new file mode 100644
index 000000000..d79ceec4f
--- /dev/null
+++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallableTest.java
@@ -0,0 +1,367 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.core.SettableApiFuture;
+import com.google.api.gax.httpjson.ApiMethodDescriptor.MethodType;
+import com.google.api.gax.httpjson.testing.MockHttpService;
+import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.ClientContext;
+import com.google.api.gax.rpc.ResponseObserver;
+import com.google.api.gax.rpc.ServerStream;
+import com.google.api.gax.rpc.ServerStreamingCallSettings;
+import com.google.api.gax.rpc.ServerStreamingCallable;
+import com.google.api.gax.rpc.StateCheckingResponseObserver;
+import com.google.api.gax.rpc.StatusCode;
+import com.google.api.gax.rpc.StatusCode.Code;
+import com.google.api.gax.rpc.StreamController;
+import com.google.api.gax.rpc.testing.FakeCallContext;
+import com.google.common.collect.Lists;
+import com.google.common.truth.Truth;
+import com.google.protobuf.Field;
+import com.google.type.Color;
+import com.google.type.Money;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class HttpJsonDirectServerStreamingCallableTest {
+  private static final ApiMethodDescriptor<Color, Money> METHOD_SERVER_STREAMING_RECOGNIZE =
+      ApiMethodDescriptor.<Color, Money>newBuilder()
+          .setFullMethodName("google.cloud.v1.Fake/ServerStreamingRecognize")
+          .setHttpMethod("POST")
+          .setRequestFormatter(
+              ProtoMessageRequestFormatter.<Color>newBuilder()
+                  .setPath(
+                      "/fake/v1/recognize/{blue}",
+                      request -> {
+                        Map<String, String> fields = new HashMap<>();
+                        ProtoRestSerializer<Field> serializer = ProtoRestSerializer.create();
+                        serializer.putPathParam(fields, "blue", request.getBlue());
+                        return fields;
+                      })
+                  .setQueryParamsExtractor(
+                      request -> {
+                        Map<String, List<String>> fields = new HashMap<>();
+                        ProtoRestSerializer<Field> serializer = ProtoRestSerializer.create();
+                        serializer.putQueryParam(fields, "red", request.getRed());
+                        return fields;
+                      })
+                  .setRequestBodyExtractor(
+                      request ->
+                          ProtoRestSerializer.create()
+                              .toBody("*", request.toBuilder().clearBlue().clearRed().build()))
+                  .build())
+          .setResponseParser(
+              ProtoMessageResponseParser.<Money>newBuilder()
+                  .setDefaultInstance(Money.getDefaultInstance())
+                  .build())
+          .setType(MethodType.SERVER_STREAMING)
+          .build();
+
+  private static final MockHttpService MOCK_SERVICE =
+      new MockHttpService(
+          Collections.singletonList(METHOD_SERVER_STREAMING_RECOGNIZE), "google.com:443");
+
+  private static final Color DEFAULT_REQUEST = Color.newBuilder().setRed(0.5f).build();
+  private static final Color ASYNC_REQUEST = DEFAULT_REQUEST.toBuilder().setGreen(1000).build();
+  private static final Color ERROR_REQUEST = Color.newBuilder().setRed(-1).build();
+  private static final Money DEFAULT_RESPONSE =
+      Money.newBuilder().setCurrencyCode("USD").setUnits(127).build();
+  private static final Money DEFAULTER_RESPONSE =
+      Money.newBuilder().setCurrencyCode("UAH").setUnits(255).build();
+
+  private ClientContext clientContext;
+  private ServerStreamingCallSettings<Color, Money> streamingCallSettings;
+  private ServerStreamingCallable<Color, Money> streamingCallable;
+
+  private static ExecutorService executorService;
+
+  @BeforeClass
+  public static void initialize() {
+    executorService = Executors.newFixedThreadPool(2);
+  }
+
+  @AfterClass
+  public static void destroy() {
+    executorService.shutdownNow();
+  }
+
+  @Before
+  public void setUp() throws InstantiationException, IllegalAccessException, IOException {
+    ManagedHttpJsonChannel channel =
+        ManagedHttpJsonChannel.newBuilder()
+            .setEndpoint("google.com:443")
+            .setDefaultHeaders(
+                HttpJsonMetadata.newBuilder()
+                    .setHeaders(Collections.singletonMap("header-key", "headerValue"))
+                    .build())
+            .setExecutor(executorService)
+            .setHttpTransport(MOCK_SERVICE)
+            .build();
+
+    clientContext =
+        ClientContext.newBuilder()
+            .setTransportChannel(HttpJsonTransportChannel.create(channel))
+            .setDefaultCallContext(HttpJsonCallContext.of(channel, HttpJsonCallOptions.DEFAULT))
+            .build();
+    streamingCallSettings = ServerStreamingCallSettings.<Color, Money>newBuilder().build();
+    streamingCallable =
+        HttpJsonCallableFactory.createServerStreamingCallable(
+            HttpJsonCallSettings.create(METHOD_SERVER_STREAMING_RECOGNIZE),
+            streamingCallSettings,
+            clientContext);
+  }
+
+  @After
+  public void tearDown() {
+    MOCK_SERVICE.reset();
+  }
+
+  @Test
+  public void testBadContext() {
+    MOCK_SERVICE.addResponse(new Money[] {DEFAULT_RESPONSE});
+    streamingCallable =
+        HttpJsonCallableFactory.createServerStreamingCallable(
+            HttpJsonCallSettings.create(METHOD_SERVER_STREAMING_RECOGNIZE),
+            streamingCallSettings,
+            clientContext
+                .toBuilder()
+                .setDefaultCallContext(FakeCallContext.createDefault())
+                .build());
+
+    CountDownLatch latch = new CountDownLatch(1);
+
+    MoneyObserver observer = new MoneyObserver(true, latch);
+    try {
+      streamingCallable.call(DEFAULT_REQUEST, observer);
+      Assert.fail("Callable should have thrown an exception");
+    } catch (IllegalArgumentException expected) {
+      Truth.assertThat(expected)
+          .hasMessageThat()
+          .contains("context must be an instance of HttpJsonCallContext");
+    }
+  }
+
+  @Test
+  public void testServerStreamingStart() throws InterruptedException {
+    MOCK_SERVICE.addResponse(new Money[] {DEFAULT_RESPONSE});
+    CountDownLatch latch = new CountDownLatch(1);
+    MoneyObserver moneyObserver = new MoneyObserver(true, latch);
+
+    streamingCallable.call(DEFAULT_REQUEST, moneyObserver);
+
+    Truth.assertThat(moneyObserver.controller).isNotNull();
+    // wait for the task to complete, otherwise it may interfere with other tests, since they share
+    // the same MockService and unfinished request in this tes may start readind messages designated
+    // for other tests.
+    Truth.assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue();
+  }
+
+  @Test
+  public void testServerStreaming() throws InterruptedException {
+
+    MOCK_SERVICE.addResponse(new Money[] {DEFAULT_RESPONSE, DEFAULTER_RESPONSE});
+    CountDownLatch latch = new CountDownLatch(3);
+    MoneyObserver moneyObserver = new MoneyObserver(true, latch);
+
+    streamingCallable.call(DEFAULT_REQUEST, moneyObserver);
+
+    Truth.assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
+    Truth.assertThat(latch.getCount()).isEqualTo(0);
+    Truth.assertThat(moneyObserver.error).isNull();
+    Truth.assertThat(moneyObserver.response).isEqualTo(DEFAULTER_RESPONSE);
+  }
+
+  @Test
+  public void testManualFlowControl() throws Exception {
+    MOCK_SERVICE.addResponse(new Money[] {DEFAULT_RESPONSE});
+    CountDownLatch latch = new CountDownLatch(2);
+    MoneyObserver moneyObserver = new MoneyObserver(false, latch);
+
+    streamingCallable.call(DEFAULT_REQUEST, moneyObserver);
+
+    Truth.assertThat(latch.await(1000, TimeUnit.MILLISECONDS)).isFalse();
+    Truth.assertWithMessage("Received response before requesting it")
+        .that(moneyObserver.response)
+        .isNull();
+
+    moneyObserver.controller.request(1);
+    Truth.assertThat(latch.await(1000, TimeUnit.MILLISECONDS)).isTrue();
+
+    Truth.assertThat(moneyObserver.response).isEqualTo(DEFAULT_RESPONSE);
+    Truth.assertThat(moneyObserver.completed).isTrue();
+  }
+
+  @Test
+  public void testCancelClientCall() throws Exception {
+    MOCK_SERVICE.addResponse(new Money[] {DEFAULT_RESPONSE});
+    CountDownLatch latch = new CountDownLatch(1);
+    MoneyObserver moneyObserver = new MoneyObserver(false, latch);
+
+    streamingCallable.call(ASYNC_REQUEST, moneyObserver);
+
+    moneyObserver.controller.cancel();
+    moneyObserver.controller.request(1);
+    Truth.assertThat(latch.await(500, TimeUnit.MILLISECONDS)).isTrue();
+
+    Truth.assertThat(moneyObserver.error).isInstanceOf(CancellationException.class);
+    Truth.assertThat(moneyObserver.error).hasMessageThat().isEqualTo("User cancelled stream");
+  }
+
+  @Test
+  public void testOnResponseError() throws Throwable {
+    MOCK_SERVICE.addException(404, new RuntimeException("some error"));
+
+    CountDownLatch latch = new CountDownLatch(1);
+    MoneyObserver moneyObserver = new MoneyObserver(true, latch);
+
+    streamingCallable.call(ERROR_REQUEST, moneyObserver);
+    Truth.assertThat(latch.await(1000, TimeUnit.MILLISECONDS)).isTrue();
+
+    Truth.assertThat(moneyObserver.error).isInstanceOf(ApiException.class);
+    Truth.assertThat(((ApiException) moneyObserver.error).getStatusCode().getCode())
+        .isEqualTo(Code.NOT_FOUND);
+    Truth.assertThat(moneyObserver.error)
+        .hasMessageThat()
+        .isEqualTo(
+            "com.google.api.client.http.HttpResponseException: 404\n"
+                + "POST https://google.com:443/fake/v1/recognize/0.0?red=-1.0\n"
+                + "java.lang.RuntimeException: some error");
+  }
+
+  @Test
+  public void testObserverErrorCancelsCall() throws Throwable {
+    MOCK_SERVICE.addResponse(new Money[] {DEFAULT_RESPONSE});
+    final RuntimeException expectedCause = new RuntimeException("some error");
+    final SettableApiFuture<Throwable> actualErrorF = SettableApiFuture.create();
+
+    ResponseObserver<Money> moneyObserver =
+        new StateCheckingResponseObserver<Money>() {
+          @Override
+          protected void onStartImpl(StreamController controller) {}
+
+          @Override
+          protected void onResponseImpl(Money response) {
+            throw expectedCause;
+          }
+
+          @Override
+          protected void onErrorImpl(Throwable t) {
+            actualErrorF.set(t);
+          }
+
+          @Override
+          protected void onCompleteImpl() {
+            actualErrorF.set(null);
+          }
+        };
+
+    streamingCallable.call(DEFAULT_REQUEST, moneyObserver);
+    Throwable actualError = actualErrorF.get(11500, TimeUnit.MILLISECONDS);
+
+    Truth.assertThat(actualError).isInstanceOf(ApiException.class);
+    Truth.assertThat(((ApiException) actualError).getStatusCode().getCode())
+        .isEqualTo(StatusCode.Code.CANCELLED);
+
+    // gax httpjson transport layer is responsible for the immediate cancellation
+    Truth.assertThat(actualError.getCause()).isInstanceOf(StatusRuntimeException.class);
+    // and the client error is cause for httpjson transport layer to cancel it
+    Truth.assertThat(actualError.getCause().getCause()).isSameInstanceAs(expectedCause);
+  }
+
+  @Test
+  public void testBlockingServerStreaming() {
+    MOCK_SERVICE.addResponse(new Money[] {DEFAULT_RESPONSE});
+    Color request = Color.newBuilder().setRed(0.5f).build();
+    ServerStream<Money> response = streamingCallable.call(request);
+    List<Money> responseData = Lists.newArrayList(response);
+
+    Money expected = Money.newBuilder().setCurrencyCode("USD").setUnits(127).build();
+    Truth.assertThat(responseData).containsExactly(expected);
+  }
+
+  static class MoneyObserver extends StateCheckingResponseObserver<Money> {
+    private final boolean autoFlowControl;
+    private final CountDownLatch latch;
+
+    volatile StreamController controller;
+    volatile Money response;
+    volatile Throwable error;
+    volatile boolean completed;
+
+    MoneyObserver(boolean autoFlowControl, CountDownLatch latch) {
+      this.autoFlowControl = autoFlowControl;
+      this.latch = latch;
+    }
+
+    @Override
+    protected void onStartImpl(StreamController controller) {
+      this.controller = controller;
+      if (!autoFlowControl) {
+        controller.disableAutoInboundFlowControl();
+      }
+    }
+
+    @Override
+    protected void onResponseImpl(Money value) {
+      response = value;
+      latch.countDown();
+    }
+
+    @Override
+    protected void onErrorImpl(Throwable t) {
+      error = t;
+      latch.countDown();
+    }
+
+    @Override
+    protected void onCompleteImpl() {
+      completed = true;
+      latch.countDown();
+    }
+  }
+}
diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java
index 44672b28b..f2846f6a0 100644
--- a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java
+++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java
@@ -31,19 +31,14 @@
 
 import com.google.api.client.http.EmptyContent;
 import com.google.api.client.http.HttpRequest;
-import com.google.api.client.json.gson.GsonFactory;
 import com.google.api.client.testing.http.MockHttpTransport;
-import com.google.api.core.SettableApiFuture;
 import com.google.api.gax.httpjson.testing.FakeApiMessage;
 import com.google.api.pathtemplate.PathTemplate;
-import com.google.auth.Credentials;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.common.truth.Truth;
-import com.google.protobuf.TypeRegistry;
 import java.io.IOException;
-import java.io.InputStream;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
@@ -51,39 +46,20 @@
 import java.util.TreeMap;
 import org.junit.BeforeClass;
 import org.junit.Test;
-import org.threeten.bp.Instant;
+import org.mockito.Mockito;
 
 public class HttpRequestRunnableTest {
-  private static HttpJsonCallOptions fakeCallOptions;
   private static CatMessage catMessage;
   private static final String ENDPOINT = "https://www.googleapis.com/animals/v1/projects/";
   private static HttpRequestFormatter<CatMessage> catFormatter;
   private static HttpResponseParser<EmptyMessage> catParser;
-  private static PathTemplate nameTemplate = PathTemplate.create("name/{name}");
-  private static Set<String> queryParams =
+  private static final PathTemplate nameTemplate = PathTemplate.create("name/{name}");
+  private static final Set<String> queryParams =
       Sets.newTreeSet(Lists.newArrayList("food", "size", "gibberish"));
 
   @SuppressWarnings("unchecked")
   @BeforeClass
   public static void setUp() {
-    fakeCallOptions =
-        new HttpJsonCallOptions() {
-          @Override
-          public Instant getDeadline() {
-            return null;
-          }
-
-          @Override
-          public Credentials getCredentials() {
-            return null;
-          }
-
-          @Override
-          public TypeRegistry getTypeRegistry() {
-            return null;
-          }
-        };
-
     catMessage =
         new CatMessage(
             ImmutableMap.of(
@@ -131,23 +107,7 @@ public PathTemplate getPathTemplate() {
           }
         };
 
-    catParser =
-        new HttpResponseParser<EmptyMessage>() {
-          @Override
-          public EmptyMessage parse(InputStream httpContent) {
-            return null;
-          }
-
-          @Override
-          public EmptyMessage parse(InputStream httpContent, TypeRegistry registry) {
-            return null;
-          }
-
-          @Override
-          public String serialize(EmptyMessage response) {
-            return null;
-          }
-        };
+    catParser = Mockito.mock(HttpResponseParser.class);
   }
 
   @Test
@@ -161,15 +121,14 @@ public void testRequestUrl() throws IOException {
             .build();
 
     HttpRequestRunnable<CatMessage, EmptyMessage> httpRequestRunnable =
-        HttpRequestRunnable.<CatMessage, EmptyMessage>newBuilder()
-            .setHttpJsonCallOptions(fakeCallOptions)
-            .setEndpoint(ENDPOINT)
-            .setRequest(catMessage)
-            .setApiMethodDescriptor(methodDescriptor)
-            .setHttpTransport(new MockHttpTransport())
-            .setJsonFactory(new GsonFactory())
-            .setResponseFuture(SettableApiFuture.create())
-            .build();
+        new HttpRequestRunnable<>(
+            catMessage,
+            methodDescriptor,
+            ENDPOINT,
+            HttpJsonCallOptions.newBuilder().build(),
+            new MockHttpTransport(),
+            HttpJsonMetadata.newBuilder().build(),
+            (result) -> {});
 
     HttpRequest httpRequest = httpRequestRunnable.createHttpRequest();
     Truth.assertThat(httpRequest.getContent()).isInstanceOf(EmptyContent.class);
@@ -188,15 +147,15 @@ public void testRequestUrlUnnormalized() throws IOException {
             .build();
 
     HttpRequestRunnable<CatMessage, EmptyMessage> httpRequestRunnable =
-        HttpRequestRunnable.<CatMessage, EmptyMessage>newBuilder()
-            .setHttpJsonCallOptions(fakeCallOptions)
-            .setEndpoint("www.googleapis.com/animals/v1/projects")
-            .setRequest(catMessage)
-            .setApiMethodDescriptor(methodDescriptor)
-            .setHttpTransport(new MockHttpTransport())
-            .setJsonFactory(new GsonFactory())
-            .setResponseFuture(SettableApiFuture.create())
-            .build();
+        new HttpRequestRunnable<>(
+            catMessage,
+            methodDescriptor,
+            "www.googleapis.com/animals/v1/projects",
+            HttpJsonCallOptions.newBuilder().build(),
+            new MockHttpTransport(),
+            HttpJsonMetadata.newBuilder().build(),
+            (result) -> {});
+
     HttpRequest httpRequest = httpRequestRunnable.createHttpRequest();
     Truth.assertThat(httpRequest.getContent()).isInstanceOf(EmptyContent.class);
     String expectedUrl =
@@ -217,15 +176,15 @@ public void testRequestUrlUnnormalizedPatch() throws IOException {
             .build();
 
     HttpRequestRunnable<CatMessage, EmptyMessage> httpRequestRunnable =
-        HttpRequestRunnable.<CatMessage, EmptyMessage>newBuilder()
-            .setHttpJsonCallOptions(fakeCallOptions)
-            .setEndpoint("www.googleapis.com/animals/v1/projects")
-            .setRequest(catMessage)
-            .setApiMethodDescriptor(methodDescriptor)
-            .setHttpTransport(new MockHttpTransport())
-            .setJsonFactory(new GsonFactory())
-            .setResponseFuture(SettableApiFuture.create())
-            .build();
+        new HttpRequestRunnable<>(
+            catMessage,
+            methodDescriptor,
+            "www.googleapis.com/animals/v1/projects",
+            HttpJsonCallOptions.newBuilder().build(),
+            new MockHttpTransport(),
+            HttpJsonMetadata.newBuilder().build(),
+            (result) -> {});
+
     HttpRequest httpRequest = httpRequestRunnable.createHttpRequest();
     Truth.assertThat(httpRequest.getContent()).isInstanceOf(EmptyContent.class);
     String expectedUrl =
diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/MockHttpServiceTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/MockHttpServiceTest.java
index 3571275e9..d41b974ae 100644
--- a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/MockHttpServiceTest.java
+++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/MockHttpServiceTest.java
@@ -55,6 +55,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.io.Reader;
 import java.util.List;
 import java.util.Map;
 import org.junit.Before;
@@ -104,6 +105,11 @@ public PetMessage parse(InputStream httpContent, TypeRegistry registry) {
           return parse(httpContent);
         }
 
+        @Override
+        public PetMessage parse(Reader httpContent, TypeRegistry registry) {
+          return null;
+        }
+
         @Override
         public String serialize(PetMessage response) {
           return ((List<String>) response.getFieldValue("type")).get(0);
diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageJsonStreamIteratorTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageJsonStreamIteratorTest.java
new file mode 100644
index 000000000..c9836db9a
--- /dev/null
+++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageJsonStreamIteratorTest.java
@@ -0,0 +1,238 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ *     * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *     * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ *     * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.google.api.gax.httpjson;
+
+import com.google.common.truth.Truth;
+import com.google.protobuf.Field;
+import com.google.protobuf.Int64Value;
+import com.google.protobuf.Option;
+import com.google.protobuf.util.JsonFormat;
+import java.io.IOException;
+import java.io.StringReader;
+import org.junit.Test;
+
+public class ProtoMessageJsonStreamIteratorTest {
+
+  @Test
+  public void testEmpty() throws IOException {
+    String jsonData = "[]";
+    ProtoMessageJsonStreamIterator streamIter =
+        new ProtoMessageJsonStreamIterator(new StringReader(jsonData));
+
+    Truth.assertThat(streamIter.hasNext()).isFalse();
+    streamIter.close();
+  }
+
+  @Test
+  public void testSingleElement() throws IOException {
+    Field[] expectedData =
+        new Field[] {
+          Field.newBuilder()
+              .setName("cat")
+              .addOptions(Option.newBuilder().setName("haha").build())
+              .addOptions(Option.newBuilder().setName("hoho").build())
+              .setNumber(1)
+              .setDefaultValue("mew")
+              .build()
+        };
+
+    String jsonData =
+        "[{\n"
+            + "  \"number\": 1,\n"
+            + "  \"name\": \"cat\",\n"
+            + "  \"options\": [{\n"
+            + "    \"name\": \"haha\"\n"
+            + "  }, {\n"
+            + "    \"name\": \"hoho\"\n"
+            + "  }],\n"
+            + "  \"defaultValue\": \"mew\"\n"
+            + "}]";
+
+    ProtoMessageJsonStreamIterator streamIter =
+        new ProtoMessageJsonStreamIterator(new StringReader(jsonData));
+
+    Truth.assertThat(streamIter.hasNext()).isTrue();
+    Field.Builder builder = Field.newBuilder();
+    JsonFormat.parser().merge(streamIter.next(), builder);
+    Truth.assertThat(builder.build()).isEqualTo(expectedData[0]);
+
+    Truth.assertThat(streamIter.hasNext()).isFalse();
+
+    streamIter.close();
+    // closing a closed iterator should be no-op.
+    streamIter.close();
+  }
+
+  @Test
+  public void testProtobufWrapperObjects() throws IOException {
+    Int64Value[] expectedData =
+        new Int64Value[] {
+          Int64Value.newBuilder().setValue(1234567889999977L).build(),
+          Int64Value.newBuilder().setValue(2234567889999977L).build(),
+          Int64Value.newBuilder().setValue(3234567889999977L).build()
+        };
+
+    String jsonData = "[\"1234567889999977\", \t    \"2234567889999977\",\n\"3234567889999977\"]";
+
+    ProtoMessageJsonStreamIterator streamIter =
+        new ProtoMessageJsonStreamIterator(new StringReader(jsonData));
+
+    Truth.assertThat(streamIter.hasNext()).isTrue();
+    Int64Value.Builder builder = Int64Value.newBuilder();
+    JsonFormat.parser().merge(streamIter.next(), builder);
+    Truth.assertThat(builder.build()).isEqualTo(expectedData[0]);
+
+    Truth.assertThat(streamIter.hasNext()).isTrue();
+    builder = Int64Value.newBuilder();
+    JsonFormat.parser().merge(streamIter.next(), builder);
+    Truth.assertThat(builder.build()).isEqualTo(expectedData[1]);
+
+    Truth.assertThat(streamIter.hasNext()).isTrue();
+    builder = Int64Value.newBuilder();
+    JsonFormat.parser().merge(streamIter.next(), builder);
+    Truth.assertThat(builder.build()).isEqualTo(expectedData[2]);
+
+    Truth.assertThat(streamIter.hasNext()).isFalse();
+
+    streamIter.close();
+  }
+
+  @Test
+  public void testMultipleElements() throws IOException {
+    Field[] expectedData =
+        new Field[] {
+          Field.newBuilder()
+              .setName("cat")
+              .addOptions(Option.newBuilder().setName("haha").build())
+              .addOptions(Option.newBuilder().setName("hoho").build())
+              .setNumber(1)
+              .setDefaultValue("mew")
+              .build(),
+          Field.newBuilder()
+              .setName("dog")
+              .addOptions(Option.newBuilder().setName("muu").build())
+              .setNumber(2)
+              .setDefaultValue("woof")
+              .build(),
+          Field.newBuilder()
+              .setName("cow")
+              .addOptions(Option.newBuilder().setName("bee").build())
+              .setNumber(3)
+              .setDefaultValue("muu")
+              .build()
+        };
+
+    String jsonData =
+        "[{\n"
+            + "  \"number\": 1,\n"
+            + "  \"name\": \"cat\",\n"
+            + "  \"options\": [{\n"
+            + "    \"name\": \"haha\"\n"
+            + "  }, {\n"
+            + "    \"name\": \"hoho\"\n"
+            + "  }],\n"
+            + "  \"defaultValue\": \"mew\"\n"
+            + "},\n"
+            + "{\n"
+            + "  \"number\": 2,\n"
+            + "  \"name\": \"dog\",\n"
+            + "  \"options\": [{\n"
+            + "    \"name\": \"muu\"\n"
+            + "  }],\n"
+            + "  \"defaultValue\": \"woof\"\n"
+            + "},\n"
+            + "{\n"
+            + "  \"number\": 3,\n"
+            + "  \"name\": \"cow\",\n"
+            + "  \"options\": [{\n"
+            + "    \"name\": \"bee\"\n"
+            + "  }],\n"
+            + "  \"defaultValue\": \"muu\"\n"
+            + "}]";
+
+    ProtoMessageJsonStreamIterator streamIter =
+        new ProtoMessageJsonStreamIterator(new StringReader(jsonData));
+
+    Truth.assertThat(streamIter.hasNext()).isTrue();
+    Field.Builder builder = Field.newBuilder();
+    JsonFormat.parser().merge(streamIter.next(), builder);
+    Truth.assertThat(builder.build()).isEqualTo(expectedData[0]);
+
+    Truth.assertThat(streamIter.hasNext()).isTrue();
+    builder = Field.newBuilder();
+    JsonFormat.parser().merge(streamIter.next(), builder);
+    Truth.assertThat(builder.build()).isEqualTo(expectedData[1]);
+
+    Truth.assertThat(streamIter.hasNext()).isTrue();
+    builder = Field.newBuilder();
+    JsonFormat.parser().merge(streamIter.next(), builder);
+    Truth.assertThat(builder.build()).isEqualTo(expectedData[2]);
+
+    Truth.assertThat(streamIter.hasNext()).isFalse();
+
+    streamIter.close();
+  }
+
+  @Test
+  public void testEscapedString() throws IOException {
+    Field expectedData =
+        Field.newBuilder()
+            .setName(
+                "[{\n"
+                    + "\"fInt32\": 23,\n"
+                    + "\"fInt64\": \"1234567889999977\",\n"
+                    + "\"fDouble\": 1234.343232226,\n"
+                    + "\"fKingdom\": \"ARCHAEBACTERIA\"\n"
+                    + "}]")
+            .build();
+
+    String jsonData =
+        "[{\n"
+            + "  \"name\": \"[{\\n"
+            + "\\\"fInt32\\\": 23,\\n"
+            + "\\\"fInt64\\\": \\\"1234567889999977\\\",\\n"
+            + "\\\"fDouble\\\": 1234.343232226,\\n"
+            + "\\\"fKingdom\\\": \\\"ARCHAEBACTERIA\\\"\\n"
+            + "}]\"\n"
+            + "}]";
+
+    ProtoMessageJsonStreamIterator streamIter =
+        new ProtoMessageJsonStreamIterator(new StringReader(jsonData));
+
+    Truth.assertThat(streamIter.hasNext()).isTrue();
+    Field.Builder builder = Field.newBuilder();
+    JsonFormat.parser().merge(streamIter.next(), builder);
+    Truth.assertThat(builder.build()).isEqualTo(expectedData);
+    Truth.assertThat(streamIter.hasNext()).isFalse();
+
+    streamIter.close();
+  }
+}
diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoRestSerializerTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoRestSerializerTest.java
index 16199dd40..29d648965 100644
--- a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoRestSerializerTest.java
+++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoRestSerializerTest.java
@@ -34,9 +34,8 @@
 import com.google.protobuf.Field;
 import com.google.protobuf.Field.Cardinality;
 import com.google.protobuf.Option;
-import java.io.ByteArrayInputStream;
 import java.io.IOException;
-import java.nio.charset.StandardCharsets;
+import java.io.StringReader;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
@@ -84,21 +83,14 @@ public void toJson() {
   @Test
   public void fromJson() {
     Field fieldFromJson =
-        requestSerializer.fromJson(
-            new ByteArrayInputStream(fieldJson.getBytes(StandardCharsets.UTF_8)),
-            StandardCharsets.UTF_8,
-            Field.newBuilder());
-
+        requestSerializer.fromJson(new StringReader(fieldJson), Field.newBuilder());
     Truth.assertThat(fieldFromJson).isEqualTo(field);
   }
 
   @Test
   public void fromJsonInvalidJson() {
     try {
-      requestSerializer.fromJson(
-          new ByteArrayInputStream("heh".getBytes(StandardCharsets.UTF_8)),
-          StandardCharsets.UTF_8,
-          Field.newBuilder());
+      requestSerializer.fromJson(new StringReader("heh"), Field.newBuilder());
       Assert.fail();
     } catch (RestSerializationException e) {
       Truth.assertThat(e.getCause()).isInstanceOf(IOException.class);
diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/MockHttpService.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/MockHttpService.java
index e6fb4d586..a682088ed 100644
--- a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/MockHttpService.java
+++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/testing/MockHttpService.java
@@ -36,6 +36,7 @@
 import com.google.api.client.testing.http.MockLowLevelHttpRequest;
 import com.google.api.client.testing.http.MockLowLevelHttpResponse;
 import com.google.api.gax.httpjson.ApiMethodDescriptor;
+import com.google.api.gax.httpjson.ApiMethodDescriptor.MethodType;
 import com.google.api.pathtemplate.PathTemplate;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
@@ -49,14 +50,18 @@
 /**
  * Mocks an HTTPTransport. Expected responses and exceptions can be added to a queue from which this
  * mock HttpTransport polls when it relays a response.
+ *
+ * <p>As required by {@link MockHttpTransport} this implementation is thread-safe, but it is not
+ * idempotent (as a typical service would be) and must be used with extra caution. Mocked responses
+ * are returned in FIFO order and if multiple threads read from the same MockHttpService
+ * simultaneously, they may be getting responses intended for other consumers.
  */
 public final class MockHttpService extends MockHttpTransport {
-
   private final Multimap<String, String> requestHeaders = LinkedListMultimap.create();
   private final List<String> requestPaths = new LinkedList<>();
   private final Queue<HttpResponseFactory> responseHandlers = new LinkedList<>();
-  private List<ApiMethodDescriptor> serviceMethodDescriptors;
-  private String endpoint;
+  private final List<ApiMethodDescriptor> serviceMethodDescriptors;
+  private final String endpoint;
 
   /**
    * Create a MockHttpService.
@@ -68,133 +73,182 @@ public final class MockHttpService extends MockHttpTransport {
    */
   public MockHttpService(List<ApiMethodDescriptor> serviceMethodDescriptors, String pathPrefix) {
     this.serviceMethodDescriptors = ImmutableList.copyOf(serviceMethodDescriptors);
-    endpoint = pathPrefix;
+    this.endpoint = pathPrefix;
   }
 
   @Override
-  public LowLevelHttpRequest buildRequest(final String method, final String url) {
+  public synchronized LowLevelHttpRequest buildRequest(String method, String url) {
     requestPaths.add(url);
-    return new MockLowLevelHttpRequest() {
-      @Override
-      public void addHeader(String name, String value) {
-        requestHeaders.put(name, value);
-      }
-
-      @Override
-      public LowLevelHttpResponse execute() {
-        return getHttpResponse(method, url);
-      }
-    };
+    return new MockHttpRequest(this, method, url);
   }
 
   /** Add an ApiMessage to the response queue. */
-  public void addResponse(final Object response) {
-    responseHandlers.add(
-        new MockHttpService.HttpResponseFactory() {
-          @Override
-          public MockLowLevelHttpResponse getHttpResponse(String httpMethod, String fullTargetUrl) {
-            MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
-            Preconditions.checkArgument(
-                serviceMethodDescriptors != null,
-                "MockHttpService has null serviceMethodDescriptors.");
-
-            String relativePath = getRelativePath(fullTargetUrl);
-
-            for (ApiMethodDescriptor methodDescriptor : serviceMethodDescriptors) {
-              // Check the comment in com.google.api.gax.httpjson.HttpRequestRunnable.buildRequest()
-              // method for details why it is needed.
-              String descriptorHttpMethod = methodDescriptor.getHttpMethod();
-              if (!httpMethod.equals(descriptorHttpMethod)) {
-                if (!(HttpMethods.PATCH.equals(descriptorHttpMethod)
-                    && HttpMethods.POST.equals(httpMethod))) {
-                  continue;
-                }
-              }
-
-              PathTemplate pathTemplate = methodDescriptor.getRequestFormatter().getPathTemplate();
-              // Server figures out which RPC method is called based on the endpoint path pattern.
-              if (!pathTemplate.matches(relativePath)) {
-                continue;
-              }
-
-              // Emulate the server's creation of an HttpResponse from the response message
-              // instance.
-              String httpContent = methodDescriptor.getResponseParser().serialize(response);
-
-              httpResponse.setContent(httpContent.getBytes());
-              httpResponse.setStatusCode(200);
-              return httpResponse;
-            }
-
-            // Return 404 when none of this server's endpoint templates match the given URL.
-            httpResponse.setContent(
-                String.format("Method not found for path '%s'", relativePath).getBytes());
-            httpResponse.setStatusCode(404);
-            return httpResponse;
-          }
-        });
+  public synchronized void addResponse(Object response) {
+    responseHandlers.add(new MessageResponseFactory(endpoint, serviceMethodDescriptors, response));
   }
 
   /** Add an expected null response (empty HTTP response body). */
-  public void addNullResponse() {
+  public synchronized void addNullResponse() {
     responseHandlers.add(
-        new MockHttpService.HttpResponseFactory() {
-          @Override
-          public MockLowLevelHttpResponse getHttpResponse(String httpMethod, String targetUrl) {
-            return new MockLowLevelHttpResponse().setStatusCode(200);
-          }
-        });
+        (httpMethod, targetUrl) -> new MockLowLevelHttpResponse().setStatusCode(200));
   }
 
   /** Add an Exception to the response queue. */
-  public void addException(final Exception exception) {
-    responseHandlers.add(
-        new MockHttpService.HttpResponseFactory() {
-          @Override
-          public MockLowLevelHttpResponse getHttpResponse(String httpMethod, String targetUrl) {
-            MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
-            httpResponse.setStatusCode(400);
-            httpResponse.setContent(exception.toString().getBytes());
-            httpResponse.setContentEncoding("text/plain");
-            return httpResponse;
-          }
-        });
+  public synchronized void addException(Exception exception) {
+    addException(400, exception);
+  }
+
+  public synchronized void addException(int statusCode, Exception exception) {
+    responseHandlers.add(new ExceptionResponseFactory(statusCode, exception));
   }
 
   /** Get the FIFO list of URL paths to which requests were sent. */
-  public List<String> getRequestPaths() {
+  public synchronized List<String> getRequestPaths() {
     return requestPaths;
   }
 
   /** Get the FIFO list of request headers sent. */
-  public Multimap<String, String> getRequestHeaders() {
+  public synchronized Multimap<String, String> getRequestHeaders() {
     return ImmutableListMultimap.copyOf(requestHeaders);
   }
 
+  private synchronized void putRequestHeader(String name, String value) {
+    requestHeaders.put(name, value);
+  }
+
+  private synchronized MockLowLevelHttpResponse getHttpResponse(String method, String url) {
+    Preconditions.checkArgument(!responseHandlers.isEmpty());
+    return responseHandlers.poll().getHttpResponse(method, url);
+  }
+
   /* Reset the expected response queue, the method descriptor, and the logged request paths list. */
-  public void reset() {
+  public synchronized void reset() {
     responseHandlers.clear();
     requestPaths.clear();
     requestHeaders.clear();
   }
 
-  private String getRelativePath(String fullTargetUrl) {
-    // relativePath will be repeatedly truncated until it contains only
-    // the path template substring of the endpoint URL.
-    String relativePath = fullTargetUrl.replaceFirst(endpoint, "");
-    int queryParamIndex = relativePath.indexOf("?");
-    queryParamIndex = queryParamIndex < 0 ? relativePath.length() : queryParamIndex;
-    relativePath = relativePath.substring(0, queryParamIndex);
+  private interface HttpResponseFactory {
+    MockLowLevelHttpResponse getHttpResponse(String httpMethod, String targetUrl);
+  }
 
-    return relativePath;
+  private static class MockHttpRequest extends MockLowLevelHttpRequest {
+    private final MockHttpService service;
+    private final String method;
+    private final String url;
+
+    public MockHttpRequest(MockHttpService service, String method, String url) {
+      this.service = service;
+      this.method = method;
+      this.url = url;
+    }
+
+    @Override
+    public void addHeader(String name, String value) {
+      service.putRequestHeader(name, value);
+    }
+
+    @Override
+    public LowLevelHttpResponse execute() {
+      return service.getHttpResponse(method, url);
+    }
   }
 
-  private MockLowLevelHttpResponse getHttpResponse(String httpMethod, String targetUrl) {
-    Preconditions.checkArgument(!responseHandlers.isEmpty());
-    return responseHandlers.poll().getHttpResponse(httpMethod, targetUrl);
+  private static class ExceptionResponseFactory implements HttpResponseFactory {
+    private final int statusCode;
+    private final Exception exception;
+
+    public ExceptionResponseFactory(int statusCode, Exception exception) {
+      this.statusCode = statusCode;
+      this.exception = exception;
+    }
+
+    @Override
+    public MockLowLevelHttpResponse getHttpResponse(String httpMethod, String targetUrl) {
+      MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+      httpResponse.setStatusCode(statusCode);
+      httpResponse.setContent(exception.toString().getBytes());
+      httpResponse.setContentEncoding("text/plain");
+      return httpResponse;
+    }
   }
 
-  private interface HttpResponseFactory {
-    MockLowLevelHttpResponse getHttpResponse(String httpMethod, String targetUrl);
+  private static class MessageResponseFactory implements HttpResponseFactory {
+    private final List<ApiMethodDescriptor> serviceMethodDescriptors;
+    private final Object response;
+    private final String endpoint;
+
+    public MessageResponseFactory(
+        String endpoint, List<ApiMethodDescriptor> serviceMethodDescriptors, Object response) {
+      this.endpoint = endpoint;
+      this.serviceMethodDescriptors = ImmutableList.copyOf(serviceMethodDescriptors);
+      this.response = response;
+    }
+
+    @Override
+    public MockLowLevelHttpResponse getHttpResponse(String httpMethod, String fullTargetUrl) {
+      MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+
+      String relativePath = getRelativePath(fullTargetUrl);
+
+      for (ApiMethodDescriptor methodDescriptor : serviceMethodDescriptors) {
+        // Check the comment in com.google.api.gax.httpjson.HttpRequestRunnable.buildRequest()
+        // method for details why it is needed.
+        String descriptorHttpMethod = methodDescriptor.getHttpMethod();
+        if (!httpMethod.equals(descriptorHttpMethod)) {
+          if (!(HttpMethods.PATCH.equals(descriptorHttpMethod)
+              && HttpMethods.POST.equals(httpMethod))) {
+            continue;
+          }
+        }
+
+        PathTemplate pathTemplate = methodDescriptor.getRequestFormatter().getPathTemplate();
+        // Server figures out which RPC method is called based on the endpoint path pattern.
+        if (!pathTemplate.matches(relativePath)) {
+          continue;
+        }
+
+        // Emulate the server's creation of an HttpResponse from the response message
+        // instance.
+        String httpContent;
+        if (methodDescriptor.getType() == MethodType.SERVER_STREAMING) {
+          // Quick and dirty json array construction. Good enough for
+          Object[] responseArray = (Object[]) response;
+          StringBuilder sb = new StringBuilder();
+          sb.append('[');
+          for (Object responseElement : responseArray) {
+            if (sb.length() > 1) {
+              sb.append(',');
+            }
+            sb.append(methodDescriptor.getResponseParser().serialize(responseElement));
+          }
+          sb.append(']');
+          httpContent = sb.toString();
+        } else {
+          httpContent = methodDescriptor.getResponseParser().serialize(response);
+        }
+
+        httpResponse.setContent(httpContent.getBytes());
+        httpResponse.setStatusCode(200);
+        return httpResponse;
+      }
+
+      // Return 404 when none of this server's endpoint templates match the given URL.
+      httpResponse.setContent(
+          String.format("Method not found for path '%s'", relativePath).getBytes());
+      httpResponse.setStatusCode(404);
+      return httpResponse;
+    }
+
+    private String getRelativePath(String fullTargetUrl) {
+      // relativePath will be repeatedly truncated until it contains only
+      // the path template substring of the endpoint URL.
+      String relativePath = fullTargetUrl.replaceFirst(endpoint, "");
+      int queryParamIndex = relativePath.indexOf("?");
+      queryParamIndex = queryParamIndex < 0 ? relativePath.length() : queryParamIndex;
+      relativePath = relativePath.substring(0, queryParamIndex);
+
+      return relativePath;
+    }
   }
 }
diff --git a/gax/build.gradle b/gax/build.gradle
index 7792c20d0..8ca285cf4 100644
--- a/gax/build.gradle
+++ b/gax/build.gradle
@@ -37,6 +37,7 @@ task generateProjectProperties {
 sourceSets.main.output.dir generatedOutputDir, builtBy: generateProjectProperties
 
 jar {
+  duplicatesStrategy = 'include'
   manifest {
     attributes 'Specification-Title': project.name,
       'Specification-Version': project.version,

From c86cf8b4b4d063ec7a73d666d46454ed84031feb Mon Sep 17 00:00:00 2001
From: vam-google <vam@google.com>
Date: Wed, 12 Jan 2022 14:43:29 -0800
Subject: [PATCH 2/7] roollback `duplicatesStrategy = 'include'` change in
 build.gradle

---
 gax/build.gradle | 1 -
 1 file changed, 1 deletion(-)

diff --git a/gax/build.gradle b/gax/build.gradle
index 8ca285cf4..7792c20d0 100644
--- a/gax/build.gradle
+++ b/gax/build.gradle
@@ -37,7 +37,6 @@ task generateProjectProperties {
 sourceSets.main.output.dir generatedOutputDir, builtBy: generateProjectProperties
 
 jar {
-  duplicatesStrategy = 'include'
   manifest {
     attributes 'Specification-Title': project.name,
       'Specification-Version': project.version,

From d0faae4eabf48f9affce84c5147876a0778cedf7 Mon Sep 17 00:00:00 2001
From: vam-google <vam@google.com>
Date: Thu, 13 Jan 2022 17:04:48 -0800
Subject: [PATCH 3/7] address PR feedback

---
 .../google/api/gax/httpjson/HttpJsonApiExceptionFactory.java | 5 +----
 .../com/google/api/gax/httpjson/HttpJsonCallContext.java     | 2 ++
 .../google/api/gax/httpjson/ProtoMessageResponseParser.java  | 2 +-
 3 files changed, 4 insertions(+), 5 deletions(-)

diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java
index af70e8c48..6c0cdec09 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java
@@ -56,10 +56,7 @@ ApiException create(Throwable throwable) {
       StatusRuntimeException e = (StatusRuntimeException) throwable;
       StatusCode statusCode = HttpJsonStatusCode.of(e.getStatusCode());
       return createApiException(
-          throwable,
-          HttpJsonStatusCode.of(e.getStatusCode()),
-          e.getMessage(),
-          retryableCodes.contains(statusCode.getCode()));
+          throwable, statusCode, e.getMessage(), retryableCodes.contains(statusCode.getCode()));
     } else if (throwable instanceof CancellationException) {
       return ApiExceptionFactory.createException(
           throwable, HttpJsonStatusCode.of(Code.CANCELLED), false);
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
index acc36e735..bdb7b157b 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
@@ -315,11 +315,13 @@ public HttpJsonCallOptions getCallOptions() {
   }
 
   @Deprecated
+  @Nullable
   public Instant getDeadline() {
     return getCallOptions() != null ? getCallOptions().getDeadline() : null;
   }
 
   @Deprecated
+  @Nullable
   public Credentials getCredentials() {
     return getCallOptions() != null ? getCallOptions().getCredentials() : null;
   }
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java
index 84d39418d..2820b2c56 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java
@@ -46,7 +46,7 @@ public class ProtoMessageResponseParser<ResponseT extends Message>
   private final ResponseT defaultInstance;
   private final TypeRegistry defaultRegistry;
 
-  protected ProtoMessageResponseParser(ResponseT defaultInstance, TypeRegistry defaultRegistry) {
+  private ProtoMessageResponseParser(ResponseT defaultInstance, TypeRegistry defaultRegistry) {
     this.defaultInstance = defaultInstance;
     this.defaultRegistry = defaultRegistry;
   }

From 27748aa212e526442bb5cd8ed0c3b55acf3b534c Mon Sep 17 00:00:00 2001
From: vam-google <vam@google.com>
Date: Sat, 15 Jan 2022 19:53:41 -0800
Subject: [PATCH 4/7] add missing stream timeout configs to
 `HttpJsonCallContext`

---
 .../api/gax/httpjson/HttpJsonCallContext.java | 100 ++++++++++++++++--
 .../httpjson/HttpJsonStubCallableFactory.java |  49 +++++++--
 2 files changed, 129 insertions(+), 20 deletions(-)

diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
index bdb7b157b..9433d1c00 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
@@ -63,12 +63,14 @@
 public final class HttpJsonCallContext implements ApiCallContext {
   private final HttpJsonChannel channel;
   private final HttpJsonCallOptions callOptions;
-  private final Duration timeout;
+  @Nullable private final Duration timeout;
+  @Nullable private final Duration streamWaitTimeout;
+  @Nullable private final Duration streamIdleTimeout;
   private final ImmutableMap<String, List<String>> extraHeaders;
   private final ApiCallContextOptions options;
   private final ApiTracer tracer;
-  private final RetrySettings retrySettings;
-  private final ImmutableSet<StatusCode.Code> retryableCodes;
+  @Nullable private final RetrySettings retrySettings;
+  @Nullable private final ImmutableSet<StatusCode.Code> retryableCodes;
 
   /** Returns an empty instance. */
   public static HttpJsonCallContext createDefault() {
@@ -76,6 +78,8 @@ public static HttpJsonCallContext createDefault() {
         null,
         HttpJsonCallOptions.newBuilder().build(),
         null,
+        null,
+        null,
         ImmutableMap.of(),
         ApiCallContextOptions.getDefaultOptions(),
         null,
@@ -88,6 +92,8 @@ public static HttpJsonCallContext of(HttpJsonChannel channel, HttpJsonCallOption
         channel,
         options,
         null,
+        null,
+        null,
         ImmutableMap.of(),
         ApiCallContextOptions.getDefaultOptions(),
         null,
@@ -99,6 +105,8 @@ private HttpJsonCallContext(
       HttpJsonChannel channel,
       HttpJsonCallOptions callOptions,
       Duration timeout,
+      Duration streamWaitTimeout,
+      Duration streamIdleTimeout,
       ImmutableMap<String, List<String>> extraHeaders,
       ApiCallContextOptions options,
       ApiTracer tracer,
@@ -107,6 +115,8 @@ private HttpJsonCallContext(
     this.channel = channel;
     this.callOptions = callOptions;
     this.timeout = timeout;
+    this.streamWaitTimeout = streamWaitTimeout;
+    this.streamIdleTimeout = streamIdleTimeout;
     this.extraHeaders = extraHeaders;
     this.options = options;
     this.tracer = tracer;
@@ -162,6 +172,16 @@ public HttpJsonCallContext merge(ApiCallContext inputCallContext) {
       newTimeout = this.timeout;
     }
 
+    Duration newStreamWaitTimeout = httpJsonCallContext.streamWaitTimeout;
+    if (newStreamWaitTimeout == null) {
+      newStreamWaitTimeout = streamWaitTimeout;
+    }
+
+    Duration newStreamIdleTimeout = httpJsonCallContext.streamIdleTimeout;
+    if (newStreamIdleTimeout == null) {
+      newStreamIdleTimeout = streamIdleTimeout;
+    }
+
     ImmutableMap<String, List<String>> newExtraHeaders =
         Headers.mergeHeaders(extraHeaders, httpJsonCallContext.extraHeaders);
 
@@ -186,6 +206,8 @@ public HttpJsonCallContext merge(ApiCallContext inputCallContext) {
         newChannel,
         newCallOptions,
         newTimeout,
+        newStreamWaitTimeout,
+        newStreamIdleTimeout,
         newExtraHeaders,
         newOptions,
         newTracer,
@@ -227,6 +249,8 @@ public HttpJsonCallContext withTimeout(Duration timeout) {
         this.channel,
         this.callOptions,
         timeout,
+        this.streamWaitTimeout,
+        this.streamIdleTimeout,
         this.extraHeaders,
         this.options,
         this.tracer,
@@ -241,25 +265,65 @@ public Duration getTimeout() {
   }
 
   @Override
-  public ApiCallContext withStreamWaitTimeout(@Nonnull Duration streamWaitTimeout) {
-    throw new UnsupportedOperationException("Http/json transport does not support streaming");
+  public HttpJsonCallContext withStreamWaitTimeout(@Nullable Duration streamWaitTimeout) {
+    if (streamWaitTimeout != null) {
+      Preconditions.checkArgument(
+          streamWaitTimeout.compareTo(Duration.ZERO) >= 0, "Invalid timeout: < 0 s");
+    }
+
+    return new HttpJsonCallContext(
+        this.channel,
+        this.callOptions,
+        this.timeout,
+        streamWaitTimeout,
+        this.streamIdleTimeout,
+        this.extraHeaders,
+        this.options,
+        this.tracer,
+        this.retrySettings,
+        this.retryableCodes);
   }
 
-  @Nullable
+  /**
+   * The stream wait timeout set for this context.
+   *
+   * @see ApiCallContext#withStreamWaitTimeout(Duration)
+   */
   @Override
+  @Nullable
   public Duration getStreamWaitTimeout() {
-    throw new UnsupportedOperationException("Http/json transport does not support streaming");
+    return streamWaitTimeout;
   }
 
   @Override
-  public ApiCallContext withStreamIdleTimeout(@Nonnull Duration streamIdleTimeout) {
-    throw new UnsupportedOperationException("Http/json transport does not support streaming");
+  public HttpJsonCallContext withStreamIdleTimeout(@Nullable Duration streamIdleTimeout) {
+    if (streamIdleTimeout != null) {
+      Preconditions.checkArgument(
+          streamIdleTimeout.compareTo(Duration.ZERO) >= 0, "Invalid timeout: < 0 s");
+    }
+
+    return new HttpJsonCallContext(
+        this.channel,
+        this.callOptions,
+        this.timeout,
+        this.streamWaitTimeout,
+        streamIdleTimeout,
+        this.extraHeaders,
+        this.options,
+        this.tracer,
+        this.retrySettings,
+        this.retryableCodes);
   }
 
-  @Nullable
+  /**
+   * The stream idle timeout set for this context.
+   *
+   * @see ApiCallContext#withStreamIdleTimeout(Duration)
+   */
   @Override
+  @Nullable
   public Duration getStreamIdleTimeout() {
-    throw new UnsupportedOperationException("Http/json transport does not support streaming");
+    return streamIdleTimeout;
   }
 
   @BetaApi("The surface for extra headers is not stable yet and may change in the future.")
@@ -272,6 +336,8 @@ public ApiCallContext withExtraHeaders(Map<String, List<String>> extraHeaders) {
         this.channel,
         this.callOptions,
         this.timeout,
+        this.streamWaitTimeout,
+        this.streamIdleTimeout,
         newExtraHeaders,
         this.options,
         this.tracer,
@@ -293,6 +359,8 @@ public <T> ApiCallContext withOption(Key<T> key, T value) {
         this.channel,
         this.callOptions,
         this.timeout,
+        this.streamWaitTimeout,
+        this.streamIdleTimeout,
         this.extraHeaders,
         newOptions,
         this.tracer,
@@ -337,6 +405,8 @@ public HttpJsonCallContext withRetrySettings(RetrySettings retrySettings) {
         this.channel,
         this.callOptions,
         this.timeout,
+        this.streamWaitTimeout,
+        this.streamIdleTimeout,
         this.extraHeaders,
         this.options,
         this.tracer,
@@ -355,6 +425,8 @@ public HttpJsonCallContext withRetryableCodes(Set<StatusCode.Code> retryableCode
         this.channel,
         this.callOptions,
         this.timeout,
+        this.streamWaitTimeout,
+        this.streamIdleTimeout,
         this.extraHeaders,
         this.options,
         this.tracer,
@@ -367,6 +439,8 @@ public HttpJsonCallContext withChannel(HttpJsonChannel newChannel) {
         newChannel,
         this.callOptions,
         this.timeout,
+        this.streamWaitTimeout,
+        this.streamIdleTimeout,
         this.extraHeaders,
         this.options,
         this.tracer,
@@ -379,6 +453,8 @@ public HttpJsonCallContext withCallOptions(HttpJsonCallOptions newCallOptions) {
         this.channel,
         newCallOptions,
         this.timeout,
+        this.streamWaitTimeout,
+        this.streamIdleTimeout,
         this.extraHeaders,
         this.options,
         this.tracer,
@@ -411,6 +487,8 @@ public HttpJsonCallContext withTracer(@Nonnull ApiTracer newTracer) {
         this.channel,
         this.callOptions,
         this.timeout,
+        this.streamWaitTimeout,
+        this.streamIdleTimeout,
         this.extraHeaders,
         this.options,
         newTracer,
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStubCallableFactory.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStubCallableFactory.java
index 89992d9f8..d5ac992f6 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStubCallableFactory.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStubCallableFactory.java
@@ -36,6 +36,9 @@
 import com.google.api.gax.rpc.OperationCallSettings;
 import com.google.api.gax.rpc.OperationCallable;
 import com.google.api.gax.rpc.PagedCallSettings;
+import com.google.api.gax.rpc.ServerStreamingCallSettings;
+import com.google.api.gax.rpc.ServerStreamingCallable;
+import com.google.api.gax.rpc.StreamingCallSettings;
 import com.google.api.gax.rpc.UnaryCallSettings;
 import com.google.api.gax.rpc.UnaryCallable;
 
@@ -48,9 +51,9 @@ public interface HttpJsonStubCallableFactory<
    * code.
    *
    * @param httpJsonCallSettings the http/json call settings
-   * @param callSettings {@link UnaryCallSettings} to configure the method-level settings with.
-   * @param clientContext {@link ClientContext} to use to connect to the service.
-   * @return {@link UnaryCallable} callable object.
+   * @param callSettings {@link UnaryCallSettings} to configure the method-level settings with
+   * @param clientContext {@link ClientContext} to use to connect to the service
+   * @return {@link UnaryCallable} callable object
    */
   <RequestT, ResponseT> UnaryCallable<RequestT, ResponseT> createUnaryCallable(
       HttpJsonCallSettings<RequestT, ResponseT> httpJsonCallSettings,
@@ -62,9 +65,9 @@ <RequestT, ResponseT> UnaryCallable<RequestT, ResponseT> createUnaryCallable(
    * generated code.
    *
    * @param httpJsonCallSettings the http/json call settings
-   * @param pagedCallSettings {@link PagedCallSettings} to configure the paged settings with.
-   * @param clientContext {@link ClientContext} to use to connect to the service.
-   * @return {@link UnaryCallable} callable object.
+   * @param pagedCallSettings {@link PagedCallSettings} to configure the paged settings with
+   * @param clientContext {@link ClientContext} to use to connect to the service
+   * @return {@link UnaryCallable} callable object
    */
   <RequestT, ResponseT, PagedListResponseT>
       UnaryCallable<RequestT, PagedListResponseT> createPagedCallable(
@@ -78,19 +81,47 @@ UnaryCallable<RequestT, PagedListResponseT> createPagedCallable(
    *
    * @param httpJsonCallSettings the http/json call settings
    * @param batchingCallSettings {@link BatchingCallSettings} to configure the batching related
-   *     settings with.
-   * @param clientContext {@link ClientContext} to use to connect to the service.
-   * @return {@link UnaryCallable} callable object.
+   *     settings with
+   * @param clientContext {@link ClientContext} to use to connect to the service
+   * @return {@link UnaryCallable} callable object
    */
   <RequestT, ResponseT> UnaryCallable<RequestT, ResponseT> createBatchingCallable(
       HttpJsonCallSettings<RequestT, ResponseT> httpJsonCallSettings,
       BatchingCallSettings<RequestT, ResponseT> batchingCallSettings,
       ClientContext clientContext);
 
+  /**
+   * Creates a callable object that represents a long-running operation. Designed for use by
+   * generated code.
+   *
+   * @param httpJsonCallSettings the http/json call settings
+   * @param operationCallSettings {@link OperationCallSettings} to configure the method-level
+   *     settings with
+   * @param clientContext {@link ClientContext} to use to connect to the service
+   * @param operationsStub opertation stub to use to poll for updates on the Operation
+   * @return {@link OperationCallable} callable object
+   */
   <RequestT, ResponseT, MetadataT>
       OperationCallable<RequestT, ResponseT, MetadataT> createOperationCallable(
           HttpJsonCallSettings<RequestT, OperationT> httpJsonCallSettings,
           OperationCallSettings<RequestT, ResponseT, MetadataT> operationCallSettings,
           ClientContext clientContext,
           OperationsStub operationsStub);
+
+  /**
+   * Create a server-streaming callable with. Designed for use by generated code.
+   *
+   * @param httpJsonCallSettings the gRPC call settings
+   * @param streamingCallSettings {@link StreamingCallSettings} to configure the method-level
+   *     settings with.
+   * @param clientContext {@link ClientContext} to use to connect to the service.
+   */
+  default <RequestT, ResponseT>
+      ServerStreamingCallable<RequestT, ResponseT> createServerStreamingCallable(
+          HttpJsonCallSettings<RequestT, ResponseT> httpJsonCallSettings,
+          ServerStreamingCallSettings<RequestT, ResponseT> streamingCallSettings,
+          ClientContext clientContext) {
+    return HttpJsonCallableFactory.createServerStreamingCallable(
+        httpJsonCallSettings, streamingCallSettings, clientContext);
+  }
 }

From 2e2da06134aa6ceefbb5b1d3726ca1fe1ad5c1db Mon Sep 17 00:00:00 2001
From: vam-google <vam@google.com>
Date: Sat, 15 Jan 2022 22:08:42 -0800
Subject: [PATCH 5/7] cleanup and rename a few things

---
 .../api/gax/httpjson/HttpJsonApiExceptionFactory.java  |  4 ++--
 .../api/gax/httpjson/HttpJsonClientCallImpl.java       | 10 ++++------
 ...eption.java => HttpJsonStatusRuntimeException.java} |  4 ++--
 .../api/gax/httpjson/HttpJsonStubCallableFactory.java  |  7 +++----
 .../HttpJsonDirectServerStreamingCallableTest.java     |  2 +-
 5 files changed, 12 insertions(+), 15 deletions(-)
 rename gax-httpjson/src/main/java/com/google/api/gax/httpjson/{StatusRuntimeException.java => HttpJsonStatusRuntimeException.java} (92%)

diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java
index 6c0cdec09..a48f0b5a2 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonApiExceptionFactory.java
@@ -52,8 +52,8 @@ ApiException create(Throwable throwable) {
       boolean canRetry = retryableCodes.contains(statusCode.getCode());
       String message = e.getStatusMessage();
       return createApiException(throwable, statusCode, message, canRetry);
-    } else if (throwable instanceof StatusRuntimeException) {
-      StatusRuntimeException e = (StatusRuntimeException) throwable;
+    } else if (throwable instanceof HttpJsonStatusRuntimeException) {
+      HttpJsonStatusRuntimeException e = (HttpJsonStatusRuntimeException) throwable;
       StatusCode statusCode = HttpJsonStatusCode.of(e.getStatusCode());
       return createApiException(
           throwable, statusCode, e.getMessage(), retryableCodes.contains(statusCode.getCode()));
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
index 8ea3ae4c3..be76644b5 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
@@ -67,10 +67,8 @@ final class HttpJsonClientCallImpl<RequestT, ResponseT>
   //
   private final Lock lock = new ReentrantLock();
 
-  //
-  // An active delivery loops counter, used to make sure there is only one a See delivery() method
-  // comments, which explain the purpose of this field.
-  //
+  // An active delivery loops counter, used to make sure there is only one active delivery loop.
+  // See delivery() method comments for details.
   @GuardedBy("lock")
   private int activeDeliveryLoops = 0;
 
@@ -353,8 +351,8 @@ private void deliver() {
       } catch (Throwable e) {
         // Exceptions in message delivery result into cancellation of the call to stay consistent
         // with other transport implementations.
-        StatusRuntimeException ex =
-            new StatusRuntimeException(499, "Exception in message delivery", e);
+        HttpJsonStatusRuntimeException ex =
+            new HttpJsonStatusRuntimeException(499, "Exception in message delivery", e);
         // If we are already closed the exception will be swallowed, which is the best thing we
         // can do in such an unlikely situation (otherwise we would stay forever in the delivery
         // loop).
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/StatusRuntimeException.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStatusRuntimeException.java
similarity index 92%
rename from gax-httpjson/src/main/java/com/google/api/gax/httpjson/StatusRuntimeException.java
rename to gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStatusRuntimeException.java
index 6406e21a4..a1b9b1c1b 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/StatusRuntimeException.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStatusRuntimeException.java
@@ -34,12 +34,12 @@
  * HTTP status code in RuntimeException form, for propagating status code information via
  * exceptions.
  */
-public class StatusRuntimeException extends RuntimeException {
+public class HttpJsonStatusRuntimeException extends RuntimeException {
   private static final long serialVersionUID = -5390915748330242256L;
 
   private final int statusCode;
 
-  public StatusRuntimeException(int statusCode, String message, Throwable cause) {
+  public HttpJsonStatusRuntimeException(int statusCode, String message, Throwable cause) {
     super(message, cause);
     this.statusCode = statusCode;
   }
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStubCallableFactory.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStubCallableFactory.java
index d5ac992f6..59b0ceaed 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStubCallableFactory.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonStubCallableFactory.java
@@ -112,16 +112,15 @@ OperationCallable<RequestT, ResponseT, MetadataT> createOperationCallable(
    * Create a server-streaming callable with. Designed for use by generated code.
    *
    * @param httpJsonCallSettings the gRPC call settings
-   * @param streamingCallSettings {@link StreamingCallSettings} to configure the method-level
-   *     settings with.
+   * @param callSettings {@link StreamingCallSettings} to configure the method-level settings with.
    * @param clientContext {@link ClientContext} to use to connect to the service.
    */
   default <RequestT, ResponseT>
       ServerStreamingCallable<RequestT, ResponseT> createServerStreamingCallable(
           HttpJsonCallSettings<RequestT, ResponseT> httpJsonCallSettings,
-          ServerStreamingCallSettings<RequestT, ResponseT> streamingCallSettings,
+          ServerStreamingCallSettings<RequestT, ResponseT> callSettings,
           ClientContext clientContext) {
     return HttpJsonCallableFactory.createServerStreamingCallable(
-        httpJsonCallSettings, streamingCallSettings, clientContext);
+        httpJsonCallSettings, callSettings, clientContext);
   }
 }
diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallableTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallableTest.java
index d79ceec4f..094b09e49 100644
--- a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallableTest.java
+++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonDirectServerStreamingCallableTest.java
@@ -308,7 +308,7 @@ protected void onCompleteImpl() {
         .isEqualTo(StatusCode.Code.CANCELLED);
 
     // gax httpjson transport layer is responsible for the immediate cancellation
-    Truth.assertThat(actualError.getCause()).isInstanceOf(StatusRuntimeException.class);
+    Truth.assertThat(actualError.getCause()).isInstanceOf(HttpJsonStatusRuntimeException.class);
     // and the client error is cause for httpjson transport layer to cancel it
     Truth.assertThat(actualError.getCause().getCause()).isSameInstanceAs(expectedCause);
   }

From 0f7edc814c791a3028486141e9d480c128b170b3 Mon Sep 17 00:00:00 2001
From: vam-google <vam@google.com>
Date: Tue, 18 Jan 2022 14:34:15 -0800
Subject: [PATCH 6/7] replace ReentrantLock with synchronized. Simplify active
 delivery loop detection

---
 .../gax/httpjson/HttpJsonClientCallImpl.java  | 68 +++++--------------
 1 file changed, 17 insertions(+), 51 deletions(-)

diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
index be76644b5..974e04949 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
@@ -44,8 +44,6 @@
 import java.util.Queue;
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.Executor;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.GuardedBy;
 
@@ -65,12 +63,11 @@ final class HttpJsonClientCallImpl<RequestT, ResponseT>
   //
   // A lock to guard the state of this call (and the response stream).
   //
-  private final Lock lock = new ReentrantLock();
+  private final Object lock = new Object();
 
-  // An active delivery loops counter, used to make sure there is only one active delivery loop.
-  // See delivery() method comments for details.
+  // An active delivery loop marker.
   @GuardedBy("lock")
-  private int activeDeliveryLoops = 0;
+  private boolean inDelivery = false;
 
   // A queue to keep "scheduled" calls to HttpJsonClientCall.Listener<ResponseT> in a form of tasks.
   // It may seem like an overkill, but it exists to implement the following listeners contract:
@@ -141,8 +138,7 @@ final class HttpJsonClientCallImpl<RequestT, ResponseT>
   @Override
   public void setResult(RunnableResult runnableResult) {
     Preconditions.checkNotNull(runnableResult);
-    lock.lock();
-    try {
+    synchronized (lock) {
       if (closed) {
         return;
       }
@@ -152,8 +148,6 @@ public void setResult(RunnableResult runnableResult) {
         pendingNotifications.offer(
             new OnHeadersNotificationTask<>(listener, runnableResult.getResponseHeaders()));
       }
-    } finally {
-      lock.unlock();
     }
 
     // trigger delivery loop if not already running
@@ -164,8 +158,7 @@ public void setResult(RunnableResult runnableResult) {
   public void start(Listener<ResponseT> responseListener, HttpJsonMetadata requestHeaders) {
     Preconditions.checkNotNull(responseListener);
     Preconditions.checkNotNull(requestHeaders);
-    lock.lock();
-    try {
+    synchronized (lock) {
       if (closed) {
         return;
       }
@@ -177,8 +170,6 @@ public void start(Listener<ResponseT> responseListener, HttpJsonMetadata request
               .putAll(requestHeaders.getHeaders())
               .build();
       this.requestHeaders = requestHeaders.toBuilder().setHeaders(mergedHeaders).build();
-    } finally {
-      lock.unlock();
     }
   }
 
@@ -187,14 +178,11 @@ public void request(int numMessages) {
     if (numMessages < 0) {
       throw new IllegalArgumentException("numMessages must be non-negative");
     }
-    lock.lock();
-    try {
+    synchronized (lock) {
       if (closed) {
         return;
       }
       pendingNumMessages += numMessages;
-    } finally {
-      lock.unlock();
     }
 
     // trigger delivery loop if not already running
@@ -208,11 +196,8 @@ public void cancel(@Nullable String message, @Nullable Throwable cause) {
       actualCause = new CancellationException(message);
     }
 
-    lock.lock();
-    try {
+    synchronized (lock) {
       close(499, message, actualCause, true);
-    } finally {
-      lock.unlock();
     }
 
     // trigger delivery loop if not already running
@@ -223,8 +208,7 @@ public void cancel(@Nullable String message, @Nullable Throwable cause) {
   public void sendMessage(RequestT message) {
     Preconditions.checkNotNull(message);
     HttpRequestRunnable<RequestT, ResponseT> localRunnable;
-    lock.lock();
-    try {
+    synchronized (lock) {
       if (closed) {
         return;
       }
@@ -243,8 +227,6 @@ public void sendMessage(RequestT message) {
               requestHeaders,
               this);
       localRunnable = requestRunnable;
-    } finally {
-      lock.unlock();
     }
     executor.execute(localRunnable);
   }
@@ -273,19 +255,13 @@ private void deliver() {
         // make sure that the activeDeliveryLoops counter stays correct.
         //
         // Note, we must enter the loop before doing the check.
-        lock.lock();
-        try {
-          if (newActiveDeliveryLoop) {
-            activeDeliveryLoops = Math.max(1, activeDeliveryLoops + 1);
-            newActiveDeliveryLoop = false;
-          }
-          if (activeDeliveryLoops > 1) {
-            activeDeliveryLoops--;
+        synchronized (lock) {
+          if (inDelivery && newActiveDeliveryLoop) {
             // EXIT delivery loop because another active delivery loop has been detected.
             break;
           }
-        } finally {
-          lock.unlock();
+          newActiveDeliveryLoop = false;
+          inDelivery = true;
         }
 
         if (Thread.interrupted()) {
@@ -299,10 +275,9 @@ private void deliver() {
         // indefinitely deep in the stack if delivery logic is called recursively via listeners.
         notifyListeners();
 
-        // The blocking try block around message reading and cancellation notification processing
+        // The synchronized block around message reading and cancellation notification processing
         // logic
-        lock.lock();
-        try {
+        synchronized (lock) {
           if (allMessagesConsumed) {
             // allMessagesProcessed was set to true on previous loop iteration. We do it this
             // way to make sure that notifyListeners() is called in between consuming the last
@@ -334,7 +309,7 @@ private void deliver() {
             if (pendingNotifications.isEmpty()) {
               // EXIT delivery loop because there is no more work left to do. This is expected to be
               // the only active delivery loop.
-              activeDeliveryLoops--;
+              inDelivery = false;
               break;
             } else {
               // We still have some stuff in notiticationTasksQueue so continue the loop, most
@@ -344,9 +319,6 @@ private void deliver() {
           }
           pendingNumMessages--;
           allMessagesConsumed = consumeMessageFromStream();
-
-        } finally {
-          lock.unlock();
         }
       } catch (Throwable e) {
         // Exceptions in message delivery result into cancellation of the call to stay consistent
@@ -356,13 +328,10 @@ private void deliver() {
         // If we are already closed the exception will be swallowed, which is the best thing we
         // can do in such an unlikely situation (otherwise we would stay forever in the delivery
         // loop).
-        lock.lock();
-        try {
+        synchronized (lock) {
           // Close the call immediately marking it cancelled. If already closed close() will have no
           // effect.
           close(ex.getStatusCode(), ex.getMessage(), ex, true);
-        } finally {
-          lock.unlock();
         }
       }
     }
@@ -370,15 +339,12 @@ private void deliver() {
 
   private void notifyListeners() {
     while (true) {
-      lock.lock();
       NotificationTask<ResponseT> notification;
-      try {
+      synchronized (lock) {
         if (pendingNotifications.isEmpty()) {
           return;
         }
         notification = pendingNotifications.poll();
-      } finally {
-        lock.unlock();
       }
       notification.call();
     }

From 912e5045065cba1912ed2f293b18a95f1871aa42 Mon Sep 17 00:00:00 2001
From: vam-google <vam@google.com>
Date: Fri, 21 Jan 2022 02:54:36 -0800
Subject: [PATCH 7/7] address PR feedback

---
 .../java/com/google/api/gax/httpjson/HttpJsonClientCall.java  | 2 +-
 .../com/google/api/gax/httpjson/HttpJsonClientCallImpl.java   | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCall.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCall.java
index 39fc7d0eb..16ecd6795 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCall.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCall.java
@@ -146,7 +146,7 @@ public void onClose(int statusCode, HttpJsonMetadata trailers) {}
    * Close the call for request message sending. Incoming response messages are unaffected. This
    * should be called when no more messages will be sent from the client.
    */
-  public void halfClose() {}
+  public abstract void halfClose();
 
   /**
    * Send a request message to the server. May be called zero or more times but for unary and server
diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
index 974e04949..42be4d28c 100644
--- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
+++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonClientCallImpl.java
@@ -49,9 +49,9 @@
 
 /**
  * This class servers as main implementation of {@link HttpJsonClientCall} for rest transport and is
- * expected to be used for every rest call. It currently supports unary and server-streaming
+ * expected to be used for every REST call. It currently supports unary and server-streaming
  * workflows. The overall behavior and surface of the class mimics as close as possible behavior of
- * a corresponding ClientCall implementations in gRPC transport.
+ * the corresponding ClientCall implementation in gRPC transport.
  *
  * <p>This class is thread-safe.
  *