com.tinspx.util.net.okhttp.OkHttpContext.java Source code

Java tutorial

Introduction

Here is the source code for com.tinspx.util.net.okhttp.OkHttpContext.java

Source

/* Copyright (C) 2013-2014 Ian Teune <ian.teune@gmail.com>
 * 
 * Permission is hereby granted, free of charge, to any person obtaining
 * a copy of this software and associated documentation files (the
 * "Software"), to deal in the Software without restriction, including
 * without limitation the rights to use, copy, modify, merge, publish,
 * distribute, sublicense, and/or sell copies of the Software, and to
 * permit persons to whom the Software is furnished to do so, subject to
 * the following conditions:
 * 
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
 * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
 * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
 * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 */
package com.tinspx.util.net.okhttp;

import static com.google.common.base.Preconditions.*;
import com.google.common.base.Function;
import com.google.common.base.Optional;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.base.Strings;
import com.google.common.base.Throwables;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.io.Closer;
import com.google.common.net.HttpHeaders;
import com.google.common.primitives.Ints;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.Monitor;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture;
import com.squareup.okhttp.Call;
import com.squareup.okhttp.Callback;
import com.squareup.okhttp.MediaType;
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.RequestBody;
import com.tinspx.util.base.Errors;
import com.tinspx.util.collect.CollectUtils;
import com.tinspx.util.concurrent.FutureUtils;
import com.tinspx.util.io.BAOutputStream;
import com.tinspx.util.io.ByteUtils;
import com.tinspx.util.io.DecodingChannel;
import com.tinspx.util.io.DecodingOutputStream;
import com.tinspx.util.io.DetectingOutputStream;
import com.tinspx.util.io.StringUtils;
import com.tinspx.util.io.callbacks.ByteChannelListener;
import com.tinspx.util.io.charset.ByteContentCharDet;
import com.tinspx.util.io.charset.CharContentCharDet;
import com.tinspx.util.io.charset.CharsetDetector;
import com.tinspx.util.io.charset.HtmlCharDet;
import com.tinspx.util.io.charset.XmlCharDet;
import com.tinspx.util.net.Request;
import com.tinspx.util.net.RequestCallback;
import com.tinspx.util.net.RequestContext;
import com.tinspx.util.net.RequestException;
import com.tinspx.util.net.Requests;
import com.tinspx.util.net.Response;
import java.io.IOException;
import java.io.InputStream;
import java.net.CookieManager;
import java.nio.ByteBuffer;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.zip.InflaterInputStream;
import javax.annotation.Nullable;
import javax.annotation.WillClose;
import javax.annotation.WillNotClose;
import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;
import lombok.AccessLevel;
import lombok.NonNull;
import lombok.experimental.FieldDefaults;
import lombok.extern.slf4j.Slf4j;
import okio.BufferedSink;

/**
 * A {@code RequestContext} that runs requests using a provided
 * {@link OkHttpClient}. The supplied {@code OkHttpClient} should be fully
 * configured before {@link #create(OkHttpClient, boolean) creating} an
 * {@code OkHttpContext}.
 * <p>
 * Using this class requires at least version 2.1 of
 * <a href="http://square.github.io/okhttp/">OkHttp</a> to be added to your
 * project as a dependency:
 * <pre>{@code
 * <dependency>
 *     <groupId>com.squareup.okhttp</groupId>
 *     <artifactId>okhttp</artifactId>
 *     <version>2.1.0</version>
 * </dependency>
 * }</pre>
 * 
 * @author Ian
 */
@Slf4j
@FieldDefaults(level = AccessLevel.PRIVATE)
@ThreadSafe
class OkHttpContext implements RequestContext {
    /**
     * TODO:
     * add tests, need to add a general RequestContext test framework
     * make sure submitted requests are not altered
     * set follow redirects in constructor?
     */

    /**
     * same as {@link #create(OkHttpClient, boolean) create(client, false)}.
     */
    public static OkHttpContext create(OkHttpClient client) {
        return new OkHttpContext(client, false);
    }

    /**
     * Creates a new {@code OkHttpContext} using the supplied
     * {@link OkHttpClient} to execute requests. If {@code debug} is true,
     * simple debug messages will be printed for all submitted requests.
     * <p>
     * A {@link OkHttpClient#clone() copy} of {@code client} is made; therefore,
     * any subsequent changes made to {@code client} will have no effect on the
     * returned {@code OkHttpContext}.
     *
     * @see #client()
     */
    public static OkHttpContext create(OkHttpClient client, boolean debug) {
        return new OkHttpContext(client, debug);
    }

    final OkHttpClient client;
    final boolean debug;

    /**
     * true when shutdown through {@link #shutdown()}
     */
    @GuardedBy("monitor")
    volatile boolean shutdown;
    /**
     * the current number of active requests
     */
    @GuardedBy("monitor")
    int requests;

    final Monitor monitor = new Monitor();
    final Monitor.Guard terminated = new Monitor.Guard(monitor) {
        @Override
        public boolean isSatisfied() {
            return shutdown && requests <= 0;
        }
    };

    final Runnable decrementRequests = new Runnable() {
        @Override
        public void run() {
            monitor.enter();
            try {
                requests--;
            } finally {
                monitor.leave();
            }
        }
    };

    private OkHttpContext(@NonNull OkHttpClient client, boolean debug) {
        //todo: always set no follow redirects, rather should the user be allowed
        //to set this? this will effect the request creation/submisstion
        //document info about the cookie handler
        client = client.clone();
        client.setFollowRedirects(false);
        if (client.getCookieHandler() == null) {
            client.setCookieHandler(new CookieManager());
        }
        this.client = client;
        this.debug = debug;
    }

    /**
     * Returns a {@link OkHttpClient#clone() copy} of the underlying
     * {@code OkHttpClient}. Changes made to the returned {@code OkHttpClient}
     * will have no effect on this {@code OkHttpContext}.
     */
    public OkHttpClient client() {
        return client.clone();
    }

    /**
     * Determines if this {@code OkHttpContext} has been configured to print
     * simple debug messages for all submitted requests.
     */
    public boolean debug() {
        return debug;
    }

    private void incrementRequests() throws InterruptedException {
        monitor.enterInterruptibly();
        try {
            requests++;
        } finally {
            monitor.leave();
        }
    }

    /**
     * Shuts down this {@code OkHttpContext}. Already submitted requests are
     * allowed to complete, but any additional requests submitted through
     * {@link #apply(Request) apply} will be rejected, throwing a
     * {@code RejectedExecutionException}. This has no effect on the underlying
     * {@link #client() OkHttpClient}, which can still be used externally to
     * this {@code OkHttpContext}.
     */
    public void shutdown() {
        monitor.enter();
        try {
            shutdown = true;
        } finally {
            monitor.leave();
        }
    }

    /**
     * Determines if this {@code OkHttpContext} has been shutdown through
     * {@link #shutdown()}.
     *
     * @return true if shutdown
     */
    public boolean isShutdown() {
        return shutdown;
    }

    /**
     * Waits until {@link #isShutdown() shutdown} and there are no
     * {@link #activeRequests() active requests}.
     *
     * @see #isTerminated()
     * @return true if {@link #isTerminated() terminated}, false if the timeout
     * was reached before termination
     */
    public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
        return monitor.waitFor(terminated, timeout, unit);
    }

    /**
     * Determines if this {@code OkHttpContext} has been
     * {@link #isShutdown() shutdown} and there are no
     * {@link #activeRequests() active requests}.
     *
     * @see #awaitTermination(long, TimeUnit)
     * @return true if terminated
     */
    public boolean isTerminated() {
        monitor.enter();
        try {
            return terminated.isSatisfied();
        } finally {
            monitor.leave();
        }
    }

    /**
     * Returns the number of active requests that have not yet completed.
     */
    public int activeRequests() {
        monitor.enter();
        try {
            return requests;
        } finally {
            monitor.leave();
        }
    }

    private void checkNotShutdown() {
        if (shutdown) {
            throw new RejectedExecutionException("OkHttpContext shutdown");
        }
    }

    @Override
    public ListenableFuture<Response> apply(Request request) throws Exception {
        RequestRunner runner = new RequestRunner(request);
        runner.submit();
        return runner.future;
    }

    static final Function<String, String> notNullTrimUpper = StringUtils.stringFunction().notNull().trim()
            .uppercaseAscii().get();

    static final Function<String, String> notNullTrimLower = StringUtils.stringFunction().notNull().trim()
            .lowercaseAscii().get();

    static final String CONTENT_TYPE = notNullTrimLower.apply(HttpHeaders.CONTENT_TYPE);
    static final String CONTENT_LENGTH = notNullTrimLower.apply(HttpHeaders.CONTENT_LENGTH);

    static @Nullable RequestBody createRequestBody(Request request) throws IOException {
        if (!request.hasBody()) {
            return null;
        }
        final com.tinspx.util.net.RequestBody body = request.body().get();
        String contentType = null;
        long len = -1;
        for (Map.Entry<String, String> header : body.headers().entries()) {
            String normalized = notNullTrimLower.apply(header.getKey());
            if (CONTENT_TYPE.equals(normalized)) {
                contentType = header.getValue().trim();
            } else if (CONTENT_LENGTH.equals(normalized)) {
                //invalid length will throw NumberFormatException
                //TODO: ignore this header and just use the body.size()?
                //maybe only use the header if hasKnownSize is false
                len = Long.parseLong(header.getValue().trim());
                checkState(len >= 0, "%s has an invalid Content-Length (%s)", body, len);
            } else {
                request.addHeader(header.getKey(), header.getValue());
            }
        }
        //content-type is not required
        final MediaType mediaType;
        if (contentType != null) {
            mediaType = MediaType.parse(contentType);
            checkState(mediaType != null, "%s has an invalid Content-Type (%s)", body, contentType);
        } else {
            mediaType = null;
        }
        final long contentLength = len;

        return new RequestBody() {
            final Object lock = new Object();
            /**
             * saving the exception is a hack that should work until okhttp
             * fixes RequestBody.
             */
            @GuardedBy("lock")
            IOException last;

            @Override
            public long contentLength() {
                if (contentLength >= 0) {
                    return contentLength;
                }
                try {
                    if (body.hasKnownSize()) {
                        return body.size();
                    }
                } catch (IOException ex) {
                    log.error("polling size of {}", body, ex);
                    synchronized (lock) {
                        last = ex;
                    }
                }
                return -1;
            }

            @Override
            public MediaType contentType() {
                return mediaType;
            }

            @Override
            public void writeTo(BufferedSink sink) throws IOException {
                synchronized (lock) {
                    if (last != null) {
                        IOException ex = last;
                        last = null;
                        throw ex;
                    }
                }
                body.copyTo(sink.outputStream());
            }
        };
    }

    /**
     * heavily influenced by {@code URLConnectionContext.RequestRunner}
     */
    class RequestRunner implements Callback {
        /**
         * The request being executed
         */
        final Request r;
        /**
         * future returned the user
         */
        final SettableFuture<Response> future;
        /**
         * the internal future used set the result/exception; an internal future
         * is kept so that {@link #future} can be canceled and not effect this
         * future
         */
        final SettableFuture<Response> internal;
        /**
         * true if this is the first request being executed, false if this is
         * a redirection of the first request
         */
        final boolean first;
        /**
         * set when a redirection is necessary
         */
        @Nullable
        Request redirect;
        /**
         * the response of {@link #r}
         */
        @Nullable
        Response response;
        /**
         * true if the response has a body and the body was not fully read due
         * to cancellation
         */
        boolean stoppedEarly;
        /**
         * saved Call used to cancel the request if cancelled
         */
        Call call;
        /**
         * true when either onResponse or onFailure callback has been invoked
         */
        @GuardedBy("this")
        boolean called;

        RequestRunner(Request request) {
            r = checkNotNull(request);
            future = SettableFuture.create();
            internal = SettableFuture.create();
            FutureUtils.linkFutures(internal, future);
            first = true;
        }

        RequestRunner(RequestRunner runner) {
            r = checkNotNull(runner.redirect);
            future = runner.future;
            internal = runner.internal;
            first = false;
        }

        private boolean isCancelled() {
            return future.isCancelled() || r.isCancelled();
        }

        public void submit() throws Exception {
            boolean enqueued = false;
            Throwable error = null;
            try {
                enqueued = doSubmit();
            } catch (Throwable t) {
                error = t;
            }
            if (!enqueued) {
                if (error != null) {
                    doFailure(error);
                } else {
                    assert isCancelled();
                    cancelAndSet(first);
                }
            }
            if (first && error != null) {
                Throwables.propagateIfInstanceOf(error, Exception.class);
                throw Throwables.propagate(error);
            }
        }

        /**
         * submits the request and returns true if the request was enqueued
         */
        private boolean doSubmit() throws Exception {
            r.start(OkHttpContext.this);
            r.setSubmissionMillisIfAbsent();
            checkNotShutdown();
            if (isCancelled() || (!first && r.onRedirect()) || r.onSetup() || future.isCancelled()) {
                return false; //cancelled
            }
            if (debug) {
                log.info("{}: {}", first ? "connect" : "redirect", r);
            }
            //now build the request
            com.squareup.okhttp.Request.Builder b = new com.squareup.okhttp.Request.Builder();
            b.url(r.uri().toURL());
            if (!Strings.isNullOrEmpty(r.tag())) {
                b.tag(r.tag());
            } else {
                b.tag(r.id());
            }
            b.method(notNullTrimUpper.apply(r.method()), createRequestBody(r));
            for (Map.Entry<String, String> header : r.headers()) {
                b.addHeader(header.getKey(), header.getValue());
            }

            @SuppressWarnings("LocalVariableHidesMemberVariable")
            OkHttpClient client = null;

            if (r.hasCookieHandler()) {
                if (client == null) {
                    client = OkHttpContext.this.client.clone();
                }
                client.setCookieHandler(r.cookieHandler().get());
            } else {
                r.cookieHandler(OkHttpContext.this.client.getCookieHandler());
            }

            if (Boolean.TRUE.equals(r.get(Request.HIDE_REDIRECTS))) {
                if (client == null) {
                    client = OkHttpContext.this.client.clone();
                }
                client.setFollowRedirects(true);
            }

            int timeout = asInt(r.get(Request.CONNET_TIMEOUT_MILLIS));
            if (timeout >= 0) {
                if (client == null) {
                    client = OkHttpContext.this.client.clone();
                }
                client.setConnectTimeout(timeout, TimeUnit.MILLISECONDS);
            }
            timeout = asInt(r.get(Request.WRITE_TIMEOUT_MILLIS));
            if (timeout >= 0) {
                if (client == null) {
                    client = OkHttpContext.this.client.clone();
                }
                client.setWriteTimeout(timeout, TimeUnit.MILLISECONDS);
            }
            timeout = asInt(r.get(Request.READ_TIMEOUT_MILLIS));
            if (timeout >= 0) {
                if (client == null) {
                    client = OkHttpContext.this.client.clone();
                }
                client.setReadTimeout(timeout, TimeUnit.MILLISECONDS);
            }

            if (OkHttpContext.this.client.getCache() != null && Boolean.FALSE.equals(r.get(Request.USE_CACHE))) {
                if (client == null) {
                    client = OkHttpContext.this.client.clone();
                }
                client.setCache(null);
            }

            if (first && r.asClass().isPresent()) {
                //only send this error on the first request
                r.onError(Errors.message("OkHttpContext does not support asClass(%s)",
                        r.asClass().get().getClass().getName()));
            }

            if (client == null) {
                client = OkHttpContext.this.client;
            }
            call = client.newCall(b.build());
            if (first) {
                incrementRequests();
                internal.addListener(decrementRequests, MoreExecutors.directExecutor());
            }
            call.enqueue(this);
            r.addCancelListener(new Runnable() {
                @Override
                public void run() {
                    synchronized (RequestRunner.this) {
                        if (called) {
                            return;
                        }
                    }
                    call.cancel();
                    //schedule cancellation because callbacks may have long
                    //long running code
                    scheduleCancellation();
                }
            });
            future.addListener(new Runnable() {
                @Override
                public void run() {
                    if (internal.isDone()) {
                        return;
                    }
                    //internal has not completed so the future must be cancelled
                    synchronized (RequestRunner.this) {
                        if (called) {
                            return;
                        }
                    }
                    call.cancel();
                    scheduleCancellation();
                }
            }, MoreExecutors.directExecutor());

            return true;
        }

        private void scheduleCancellation() {
            try {
                client.getDispatcher().getExecutorService().execute(new Runnable() {
                    @Override
                    public void run() {
                        synchronized (RequestRunner.this) {
                            if (called) {
                                return;
                            }
                        }
                        cancelAndSet(false);
                    }
                });
            } catch (Throwable t) {
                log.error("cancelling {}", r, t);
                cancelAndSet(false);
            }
        }

        @Override
        public void onFailure(com.squareup.okhttp.Request request, IOException e) {
            synchronized (this) {
                called = true;
            }
            doFailure(e);
        }

        @Override
        public void onResponse(com.squareup.okhttp.Response okr) {
            synchronized (this) {
                called = true;
            }
            try {
                processResponse(okr);
            } catch (Throwable t) {
                doFailure(t);
            }
        }

        public void processResponse(com.squareup.okhttp.Response okr) throws Exception {
            final Closer closer = Closer.create();
            try {
                closer.register(okr.body());
                buildResponse(okr);
                if (okr.body() != null) {
                    if (isCancelled()) {
                        stoppedEarly = true;
                    } else {
                        doRead(okr.body().byteStream());
                    }
                }

                if (stoppedEarly) {
                    assert isCancelled();
                    cancelAndSet(false);
                } else {
                    if (redirect == null) {
                        buildRedirect();
                    }
                    if (redirect != null) {
                        if (isCancelled()) {
                            //redirect pending but cancelled
                            cancelAndSet(false, "redirect pending");
                        } else {
                            new RequestRunner(this).submit();
                        }
                    } else {
                        doSuccess();
                        internal.set(response);
                    }
                }
            } catch (Throwable t) {
                throw closer.rethrow(t, Exception.class);
            } finally {
                closer.close();
            }
        }

        /**
         * converts the okhttp response into tinspx Response, but does not
         * process the response body.
         */
        private Response buildResponse(com.squareup.okhttp.Response okr) throws IOException {
            assert response == null : response;
            response = r.response(okr.request().uri(), okr.code(), okr.message());
            final int len = okr.headers().size();
            for (int i = 0; i < len; i++) {
                response.headers().add(okr.headers().name(i), okr.headers().value(i));
            }
            return response;
        }

        private void buildRedirect() throws IOException {
            assert response != null;
            if (r.redirectHandler().shouldRedirect(response)) {
                redirect = r.redirectHandler().apply(response.redirect());
            }
        }

        /**
         * Reads {@code in} and produces all of the necessary
         * {@link ContentCallback} events. {@code in} must be the raw input
         * stream and should not be wrapped in some filtering stream.
         */
        @SuppressWarnings("ThrowFromFinallyBlock")
        void doRead(final @WillClose InputStream in) throws Exception {
            boolean complete = false;
            List<RequestCallback> callbacks = Collections.emptyList();
            final Closer closer = Closer.create();
            try {
                closer.register(in);
                //ensure cleanup even if assertions fail
                assert in != null;
                assert response != null;

                boolean cancelled = false;
                if (r.hasCallbacks()) {
                    callbacks = Lists.newArrayListWithCapacity(Math.max(4, r.callbacks().size() + 2));
                    Set<RequestCallback> processed = Sets.newHashSet();
                    boolean redirectBuilt = false;

                    for (RequestCallback rc : r.callbacks()) {
                        processed.add(rc);
                        boolean start = true;
                        if (rc.ignoreRedirects()) {
                            if (!redirectBuilt) {
                                redirectBuilt = true;
                                buildRedirect();
                            }
                            start = redirect == null;
                        }
                        if (start && rc.onContentStart(response)) {
                            callbacks.add(rc);
                        }
                        if (cancelled = isCancelled()) {
                            break;
                        }
                    }
                    if (!cancelled) {
                        //allow additional callbacks to be added in onContentStart
                        for (RequestCallback rc : r.callbacks()) {
                            if (processed.contains(rc)) {
                                continue;
                            }
                            boolean start = true;
                            if (rc.ignoreRedirects()) {
                                if (!redirectBuilt) {
                                    redirectBuilt = true;
                                    buildRedirect();
                                }
                                start = redirect == null;
                            }
                            if (start && rc.onContentStart(response)) {
                                callbacks.add(rc);
                            }
                            if (cancelled = isCancelled()) {
                                break;
                            }
                        }
                    }
                }

                if (cancelled) {
                    stoppedEarly = true;
                } else {
                    doRead(response.headers().contentLength(), in, callbacks);
                }
                complete = true;
            } catch (Throwable t) {
                throw closer.rethrow(t, Exception.class);
            } finally {
                //if onContentStart has been called, onContentComplete MUST be called
                Throwable error = null;
                for (RequestCallback rc : callbacks) {
                    try {
                        rc.onContentComplete(response);
                    } catch (Throwable t) {
                        error = t;
                        log.error("onContentComplete: {}", response, t);
                    }
                }
                closer.close(); //may throw IOException
                if (complete && error != null) {
                    Throwables.propagateIfInstanceOf(error, Exception.class);
                    throw Throwables.propagate(error);
                }
            }
        }

        /**
         * Reads {@code in} and sends the
         * {@link RequestCallback#onContent(Response, ByteBuffer) onContent}
         * events. Returns true if {@code in} was fully read. The stream may not
         * be fully read if there are no callbacks and both
         * {@link Request#asByteSource()} and {@link Request#asCharSequence()}
         * are false.
         * 
         * @param length the content length header value, must be nonnegative
         * @param compressed true if {@code in} is decompressing stream
         * @param in the stream to read
         * @param callbacks non-null, possibly empty, list of callbacks.
         * callbacks that return false from onContent will be removed
         * @return true if {@code in} was fully read, false if there still may
         * be some bytes available
         * @throws IOException 
         */
        boolean doRead(long length, @WillNotClose InputStream in, List<RequestCallback> callbacks)
                throws IOException {
            assert length >= 0 : length;
            final boolean canBuffer = length <= Integer.MAX_VALUE;
            if (canBuffer && in instanceof InflaterInputStream && response.isText()) {
                length = Ints.saturatedCast(length * 4); //25% compression
            }

            BAOutputStream buffer = null;
            if (r.asByteSource()) {
                if (canBuffer) {
                    buffer = new BAOutputStream(length > 0 ? (int) length : BUF_SIZE);
                    while (buffer.write(in, WRITE_SIZE) >= WRITE_SIZE) {
                        if (isCancelled()) {
                            //give it one last chance to complete
                            if (buffer.write(in, WRITE_SIZE) >= WRITE_SIZE) {
                                stoppedEarly = true;
                                break;
                            }
                        }
                    }
                } else {
                    r.onError(Errors.message("content length (%s) is too large to buffer", length));
                }
            }

            DecodingChannel decoder = null;
            if (r.asCharSequence()) {
                if (canBuffer) {
                    decoder = buildDecoder((int) length);
                } else {
                    r.onError(Errors.message("content length (%s) is too large to decode as CharSequence", length));
                }
            }

            boolean complete = false;
            if (buffer != null) {
                if (decoder != null) {
                    decoder.write(buffer.toByteBuffer());
                }
                if (!callbacks.isEmpty()) {
                    doRead(buffer.toByteBuffer(), callbacks);
                }
                complete = !stoppedEarly;
            } else if (!callbacks.isEmpty()) {
                if (decoder != null) {
                    callbacks.add(Requests.wrap(new ByteChannelListener(decoder)));
                }
                complete = doRead(in, callbacks);
            } else if (decoder != null) {
                while (ByteUtils.copy(in, decoder, WRITE_SIZE) >= WRITE_SIZE) {
                    if (isCancelled()) {
                        //give it one last chance to complete
                        if (ByteUtils.copy(in, decoder, WRITE_SIZE) >= WRITE_SIZE) {
                            stoppedEarly = true;
                            break;
                        }
                    }
                }
                complete = !stoppedEarly;
            }

            if (buffer != null) {
                response.asByteSource(buffer.asByteSource());
            }
            if (decoder != null) {
                decoder.close();
                response.decodeCharset(decoder.charset());
                response.asCharSequence(decoder.content());
            }
            return complete;
        }

        /**
         * Reads {@code in} and sends the onContent events. Will not fully
         * read the stream if {@code callbacks} is empty or becomes empty.
         * 
         * @param in the stream to read
         * @param callbacks non-null, possibly empty, list of callbacks.
         * callbacks that return false from onContent will be removed
         * @return true if {@code in} was fully read, false if there still may
         * be some bytes available
         * @throws IOException 
         */
        boolean doRead(@WillNotClose InputStream in, List<RequestCallback> callbacks) throws IOException {
            final ByteBuffer buf = BYTE_BUFFER.get();
            for (int i = 1; !callbacks.isEmpty(); i++) {
                //check cancellation every 4th read
                boolean cancelled = (i & 3) == 0 && isCancelled();
                buf.clear();
                ByteUtils.copy(in, buf);
                final boolean eof = buf.hasRemaining();
                buf.flip();
                if (!buf.hasRemaining()) {
                    return true;
                }
                doRead(buf, callbacks);
                if (eof) {
                    return true;
                }
                if (cancelled) {
                    stoppedEarly = true;
                    return false;
                }
            }
            return false;
        }

        /**
         * Sends {@code buf} to all {@code callbacks} in their onContent
         * method. If any callback returns false from onContent, it is
         * removed from the list.
         */
        void doRead(ByteBuffer buf, List<RequestCallback> callbacks) {
            final int lim = buf.limit(), pos = buf.position();
            Iterator<RequestCallback> iter = callbacks.iterator();
            while (iter.hasNext()) {
                RequestCallback next = iter.next();
                if (!next.onContent(response, buf)) {
                    iter.remove();
                }
                buf.limit(lim).position(pos);
            }
        }

        private boolean shouldDetectCharset() {
            return r.detectCharset() && !response.hasDecodeCharset();
        }

        private DecodingChannel buildDecoder(int expectedSize) {
            assert response != null;
            expectedSize = expectedSize > 0 ? expectedSize : BUF_SIZE;
            Iterable<CharsetDetector> detectors = Collections.emptySet();
            boolean detect = shouldDetectCharset();
            if (detect) {
                if (r.hasCharsetDetectors()) {
                    detectors = Iterables.filter(r.charsetDetectors(), CONTENT_PRED);
                }
                Optional<CharContentCharDet> cdet = getContentDetector(response);
                if (cdet.isPresent() && !Iterables.any(detectors, Predicates.instanceOf(cdet.get().getClass()))) {
                    detectors = CollectUtils.concat(detectors, cdet.get());
                }
                if (Iterables.isEmpty(detectors)) {
                    detect = false;
                }
            }
            if (detect) {
                return DetectingOutputStream.builder().charsetDetectors(detectors)
                        .decoderFunction(r.decoderFunction()).charsetFunction(r.charsetFunction())
                        .expectedSize(expectedSize).defaultCharset(r.decodeCharset()).build();
            } else {
                return new DecodingOutputStream(
                        r.decoderFunction().apply(response.decodeCharset().or(r.decodeCharset())), expectedSize);
            }
        }

        private void doSuccess() {
            assert response != null;
            if (internal.isDone()) {
                return;
            }
            response.onSuccess(false);
            if (debug) {
                log.info("complete: {}", response);
            }
        }

        private void doFailure(Throwable error) {
            assert error != null;
            if (response == null) {
                response = r.response();
            }
            if (internal.isDone()) {
                return;
            }
            response.onFailure(error, false);
            internal.setException(new RequestException(response, error));
            if (debug) {
                log.info("failed: {}", response, error);
            }
        }

        private void cancelAndSet(boolean propagate) {
            cancelAndSet(propagate, "user cancelled");
        }

        private void cancelAndSet(boolean propagate, String msg) {
            try {
                doCancel(propagate);
            } finally {
                internal.setException(new RequestException(response, new CancellationException(msg)));
            }
        }

        private void doCancel(boolean propagate) {
            if (response == null) {
                response = r.response();
            }
            if (internal.isDone()) {
                return;
            }
            if (debug) {
                log.info("cancelled: {}", response);
            }
            response.onCancel(propagate);
        }
    }

    /**
     * If obj is a Number and is non-negative, returns the int value, otherwise
     * returns -1
     */
    private static int asInt(@Nullable Object obj) {
        if (obj instanceof Number) {
            int value = ((Number) obj).intValue();
            return value < 0 ? -1 : value;
        } else {
            return -1;
        }
    }

    private static Optional<CharContentCharDet> getContentDetector(Response response) {
        if (response.isHtml()) {
            return Optional.<CharContentCharDet>of(new HtmlCharDet());
        } else if (response.isXml()) {
            return Optional.<CharContentCharDet>of(new XmlCharDet());
        } else {
            return Optional.absent();
        }
    }

    @SuppressWarnings("unchecked")
    private static final Predicate<CharsetDetector> CONTENT_PRED = Predicates
            .or(Predicates.instanceOf(ByteContentCharDet.class), Predicates.instanceOf(CharContentCharDet.class));

    private static final int BUF_SIZE = 1024 * 16;
    private static final int WRITE_SIZE = BUF_SIZE * 4;
    private static final ThreadLocal<ByteBuffer> BYTE_BUFFER = new ThreadLocal<ByteBuffer>() {
        @Override
        protected ByteBuffer initialValue() {
            return ByteBuffer.allocate(BUF_SIZE);
        }
    };
}