@Override
    public void channelRead0(ChannelHandlerContext ctx, HttpObject in) {
      long currentChunkReceiveTime = System.currentTimeMillis();
      boolean recognized = false;
      if (in instanceof HttpResponse) {
        recognized = true;
        long responseReceiveStart = currentChunkReceiveTime - requestStartTime;
        perfClientMetrics.timeToFirstResponseChunkInMs.update(responseReceiveStart);
        logger.trace("Response receive has started on channel {}. Took {} ms", ctx.channel(), responseReceiveStart);
        response = (HttpResponse) in;
        if (response.status().code() >= 200 && response.status().code() < 300) {
          logger.trace("Request succeeded");
          if (response.headers().contains("Location")) {
            logger.info(response.headers().get("Location"));
          }
        } else if (response.status().code() >= 300 && response.status().code() < 400) {
          logger.warn("Redirection code {} and headers were {}", response.status().code(), response.headers());
        } else {
          logger.error("Response error code {} and headers were {}", response.status().code(), response.headers());
        }
      }
      if (in instanceof HttpContent) {
        recognized = true;
        perfClientMetrics.delayBetweenChunkReceiveInMs.update(currentChunkReceiveTime - lastChunkReceiveTime);
        chunksReceived++;
        int bytesReceivedThisTime = ((HttpContent) in).content().readableBytes();
        sizeReceived += bytesReceivedThisTime;
        perfClientMetrics.bytesReceiveRate.mark(bytesReceivedThisTime);
        if (in instanceof LastHttpContent) {
          long requestRoundTripTime = currentChunkReceiveTime - requestStartTime;
          perfClientMetrics.requestRoundTripTimeInMs.update(requestRoundTripTime);
          perfClientMetrics.getContentSizeInBytes.update(sizeReceived);
          perfClientMetrics.getChunkCount.update(chunksReceived);
          logger.trace(
              "Final content received on channel {}. Took {} ms. Total chunks received - {}. Total size received - {}",
              ctx.channel(), requestRoundTripTime, chunksReceived, sizeReceived);
          if (HttpUtil.isKeepAlive(response) && isRunning) {
            logger.trace("Sending new request on channel {}", ctx.channel());
            sendRequest(ctx);
          } else if (!isRunning) {
            logger.info("Closing channel {} because NettyPerfClient has been shutdown", ctx.channel());
            ctx.close();
          } else {
            perfClientMetrics.requestResponseError.inc();
            logger.error("Channel {} not kept alive. Last response status was {} and header was {}", ctx.channel(),
                response.status(), response.headers());
            ctx.close();
          }
        }
      }
      if (!recognized) {
        throw new IllegalStateException("Unexpected HttpObject - " + in.getClass());
      }
      lastChunkReceiveTime = currentChunkReceiveTime;
    }
Custom handler that sends out the request and receives and processes the response.
TODO support GET-after-POST and DELETE-after-POST operations for race condition testing.
ambry-tools/src/main/java/com/github/ambry/tools/perf/rest/NettyPerfClient.java