com.google.api.gax.httpjson.HttpJsonClientCallImpl Maven / Gradle / Ivy
Show all versions of gax-httpjson Show documentation
/*
* 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.api.gax.rpc.StatusCode;
import com.google.common.base.Preconditions;
import com.google.errorprone.annotations.concurrent.GuardedBy;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.CancellationException;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;
/**
* This class serves 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
* the corresponding ClientCall implementation in gRPC transport.
*
* This class is thread-safe.
*
* @param call request type
* @param call response type
*/
final class HttpJsonClientCallImpl
extends HttpJsonClientCall implements ResultListener {
//
// A lock to guard the state of this call (and the response stream).
//
private final Object lock = new Object();
// An active delivery loop marker.
@GuardedBy("lock")
private boolean inDelivery = false;
// A queue to keep "scheduled" calls to HttpJsonClientCall.Listener 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> pendingNotifications = new ArrayDeque<>();
//
// Immutable API method-specific data.
//
private final HttpJsonCallOptions callOptions;
private final String endpoint;
private final ApiMethodDescriptor methodDescriptor;
private final HttpTransport httpTransport;
private final Executor executor;
private final ScheduledExecutorService deadlineCancellationExecutor;
//
// Request-specific data (provided by client code) before we get a response.
//
@GuardedBy("lock")
private HttpJsonMetadata requestHeaders;
@GuardedBy("lock")
private Listener listener;
@GuardedBy("lock")
private int pendingNumMessages;
//
// Response-specific data (received from server).
//
@GuardedBy("lock")
private HttpRequestRunnable requestRunnable;
@GuardedBy("lock")
private RunnableResult runnableResult;
@GuardedBy("lock")
private ProtoMessageJsonStreamIterator responseStreamIterator;
@GuardedBy("lock")
private volatile boolean closed;
// Store the timeout future created by the deadline schedule executor. The future
// can be cancelled if a response (either an error or valid payload) has been
// received before the timeout. This value may be null if the RPC does not have a
// timeout.
@GuardedBy("lock")
private volatile ScheduledFuture> timeoutFuture;
HttpJsonClientCallImpl(
ApiMethodDescriptor methodDescriptor,
String endpoint,
HttpJsonCallOptions callOptions,
HttpTransport httpTransport,
Executor executor,
ScheduledExecutorService deadlineCancellationExecutor) {
this.methodDescriptor = methodDescriptor;
this.endpoint = endpoint;
this.callOptions = callOptions;
this.httpTransport = httpTransport;
this.executor = executor;
this.deadlineCancellationExecutor = deadlineCancellationExecutor;
this.closed = false;
}
@Override
public void setResult(RunnableResult runnableResult) {
Preconditions.checkNotNull(runnableResult);
synchronized (lock) {
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()));
}
}
// trigger delivery loop if not already running
deliver();
}
@Override
public void start(Listener responseListener, HttpJsonMetadata requestHeaders) {
Preconditions.checkNotNull(responseListener);
Preconditions.checkNotNull(requestHeaders);
synchronized (lock) {
if (closed) {
return;
}
Preconditions.checkState(this.listener == null, "The call is already started");
this.listener = responseListener;
this.requestHeaders = requestHeaders;
// Use the timeout duration value instead of calculating the future Instant
// Only schedule the deadline if the RPC timeout has been set in the RetrySettings
Duration timeout = callOptions.getTimeout();
if (timeout != null) {
// The future timeout value is guaranteed to not be a negative value as the
// RetryAlgorithm will not retry
long timeoutMs = timeout.toMillis();
// Assign the scheduled future so that it can be cancelled if the timeout task
// is not needed (response received prior to timeout)
timeoutFuture =
this.deadlineCancellationExecutor.schedule(
this::timeout, timeoutMs, TimeUnit.MILLISECONDS);
}
}
}
// Notify the FutureListener that the there is a timeout exception from this RPC
// call (DEADLINE_EXCEEDED). For retrying RPCs, this code is returned for every attempt
// that exceeds the timeout. The RetryAlgorithm will check both the timing and code to
// ensure another attempt is made.
private void timeout() {
// There is a race between the deadline scheduler and response being returned from
// the server. The deadline scheduler has priority as it will clear out the pending
// notifications queue and add the DEADLINE_EXCEEDED event once it is able to obtain
// the lock.
synchronized (lock) {
close(
StatusCode.Code.DEADLINE_EXCEEDED.getHttpStatusCode(),
"Deadline exceeded",
new HttpJsonStatusRuntimeException(
StatusCode.Code.DEADLINE_EXCEEDED.getHttpStatusCode(), "Deadline exceeded", null),
true);
}
// trigger delivery loop if not already running
deliver();
}
@Override
public void request(int numMessages) {
if (numMessages < 0) {
throw new IllegalArgumentException("numMessages must be non-negative");
}
synchronized (lock) {
if (closed) {
return;
}
pendingNumMessages += numMessages;
}
// 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);
}
synchronized (lock) {
close(499, message, actualCause, true);
}
// trigger delivery loop if not already running
deliver();
}
@Override
public void sendMessage(RequestT message) {
Preconditions.checkNotNull(message);
HttpRequestRunnable localRunnable;
synchronized (lock) {
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;
}
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.
synchronized (lock) {
if (inDelivery && newActiveDeliveryLoop) {
// EXIT delivery loop because another active delivery loop has been detected.
break;
}
newActiveDeliveryLoop = false;
inDelivery = true;
}
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 synchronized block around message reading and cancellation notification processing
// logic
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
// 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.
inDelivery = false;
break;
} else {
// We still have some stuff in notificationTasksQueue so continue the loop, most
// likely we will finally terminate on the next cycle.
continue;
}
}
pendingNumMessages--;
allMessagesConsumed = consumeMessageFromStream();
}
} catch (Throwable e) {
// Exceptions in message delivery result into cancellation of the call to stay consistent
// with other transport implementations.
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).
synchronized (lock) {
// Close the call immediately marking it cancelled. If already closed, close() will have
// no effect.
close(ex.getStatusCode(), ex.getMessage(), ex, true);
}
}
}
}
private void notifyListeners() {
while (true) {
NotificationTask notification;
synchronized (lock) {
if (pendingNotifications.isEmpty()) {
return;
}
notification = pendingNotifications.poll();
}
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 streaming 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 terminateImmediately) {
try {
if (closed) {
return;
}
closed = true;
// Cancel the timeout future if there is a timeout associated with the RPC
if (timeoutFuture != null) {
// The timeout method also invokes close() and the second invocation of close()
// will be guarded by the closed check above. No need to interrupt the timeout
// task as running the timeout task is quick.
timeoutFuture.cancel(false);
timeoutFuture = null;
}
// 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 metadataBuilder = HttpJsonMetadata.newBuilder();
if (runnableResult != null && runnableResult.getTrailers() != null) {
metadataBuilder = runnableResult.getTrailers().toBuilder();
}
metadataBuilder.setException(cause);
metadataBuilder.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 (terminateImmediately) {
// 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, metadataBuilder.build()));
} catch (Throwable e) {
// suppress stream closing exceptions in favor of the actual call closing cause. This method
// should not throw, otherwise we may be 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 {
private final Listener listener;
NotificationTask(Listener listener) {
this.listener = listener;
}
protected Listener getListener() {
return listener;
}
abstract void call();
}
private static class OnHeadersNotificationTask extends NotificationTask {
private final HttpJsonMetadata responseHeaders;
OnHeadersNotificationTask(Listener listener, HttpJsonMetadata responseHeaders) {
super(listener);
this.responseHeaders = responseHeaders;
}
public void call() {
getListener().onHeaders(responseHeaders);
}
}
private static class OnMessageNotificationTask extends NotificationTask {
private final ResponseT message;
OnMessageNotificationTask(Listener listener, ResponseT message) {
super(listener);
this.message = message;
}
public void call() {
getListener().onMessage(message);
}
}
private static class OnCloseNotificationTask extends NotificationTask {
private final int statusCode;
private final HttpJsonMetadata trailers;
OnCloseNotificationTask(
Listener listener, int statusCode, HttpJsonMetadata trailers) {
super(listener);
this.statusCode = statusCode;
this.trailers = trailers;
}
public void call() {
getListener().onClose(statusCode, trailers);
}
}
}