Home | History | Annotate | Download | only in http
      1 /*
      2  *  Licensed to the Apache Software Foundation (ASF) under one or more
      3  *  contributor license agreements.  See the NOTICE file distributed with
      4  *  this work for additional information regarding copyright ownership.
      5  *  The ASF licenses this file to You under the Apache License, Version 2.0
      6  *  (the "License"); you may not use this file except in compliance with
      7  *  the License.  You may obtain a copy of the License at
      8  *
      9  *     http://www.apache.org/licenses/LICENSE-2.0
     10  *
     11  *  Unless required by applicable law or agreed to in writing, software
     12  *  distributed under the License is distributed on an "AS IS" BASIS,
     13  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     14  *  See the License for the specific language governing permissions and
     15  *  limitations under the License.
     16  */
     17 
     18 package com.squareup.okhttp.internal.http;
     19 
     20 import com.squareup.okhttp.Address;
     21 import com.squareup.okhttp.Connection;
     22 import com.squareup.okhttp.OkResponseCache;
     23 import com.squareup.okhttp.ResponseSource;
     24 import com.squareup.okhttp.TunnelRequest;
     25 import com.squareup.okhttp.internal.Dns;
     26 import com.squareup.okhttp.internal.Platform;
     27 import com.squareup.okhttp.internal.Util;
     28 import java.io.ByteArrayInputStream;
     29 import java.io.IOException;
     30 import java.io.InputStream;
     31 import java.io.OutputStream;
     32 import java.net.CacheRequest;
     33 import java.net.CacheResponse;
     34 import java.net.CookieHandler;
     35 import java.net.Proxy;
     36 import java.net.URI;
     37 import java.net.URISyntaxException;
     38 import java.net.URL;
     39 import java.net.UnknownHostException;
     40 import java.util.Collections;
     41 import java.util.Date;
     42 import java.util.HashMap;
     43 import java.util.List;
     44 import java.util.Map;
     45 import java.util.zip.GZIPInputStream;
     46 import javax.net.ssl.HostnameVerifier;
     47 import javax.net.ssl.SSLSocketFactory;
     48 
     49 import static com.squareup.okhttp.internal.Util.EMPTY_BYTE_ARRAY;
     50 import static com.squareup.okhttp.internal.Util.getDefaultPort;
     51 import static com.squareup.okhttp.internal.Util.getEffectivePort;
     52 
     53 /**
     54  * Handles a single HTTP request/response pair. Each HTTP engine follows this
     55  * lifecycle:
     56  * <ol>
     57  * <li>It is created.
     58  * <li>The HTTP request message is sent with sendRequest(). Once the request
     59  * is sent it is an error to modify the request headers. After
     60  * sendRequest() has been called the request body can be written to if
     61  * it exists.
     62  * <li>The HTTP response message is read with readResponse(). After the
     63  * response has been read the response headers and body can be read.
     64  * All responses have a response body input stream, though in some
     65  * instances this stream is empty.
     66  * </ol>
     67  *
     68  * <p>The request and response may be served by the HTTP response cache, by the
     69  * network, or by both in the event of a conditional GET.
     70  *
     71  * <p>This class may hold a socket connection that needs to be released or
     72  * recycled. By default, this socket connection is held when the last byte of
     73  * the response is consumed. To release the connection when it is no longer
     74  * required, use {@link #automaticallyReleaseConnectionToPool()}.
     75  */
     76 public class HttpEngine {
     77   private static final CacheResponse GATEWAY_TIMEOUT_RESPONSE = new CacheResponse() {
     78     @Override public Map<String, List<String>> getHeaders() throws IOException {
     79       Map<String, List<String>> result = new HashMap<String, List<String>>();
     80       result.put(null, Collections.singletonList("HTTP/1.1 504 Gateway Timeout"));
     81       return result;
     82     }
     83     @Override public InputStream getBody() throws IOException {
     84       return new ByteArrayInputStream(EMPTY_BYTE_ARRAY);
     85     }
     86   };
     87   public static final int HTTP_CONTINUE = 100;
     88 
     89   protected final HttpURLConnectionImpl policy;
     90 
     91   protected final String method;
     92 
     93   private ResponseSource responseSource;
     94 
     95   protected Connection connection;
     96   protected RouteSelector routeSelector;
     97   private OutputStream requestBodyOut;
     98 
     99   private Transport transport;
    100 
    101   private InputStream responseTransferIn;
    102   private InputStream responseBodyIn;
    103 
    104   private CacheResponse cacheResponse;
    105   private CacheRequest cacheRequest;
    106 
    107   /** The time when the request headers were written, or -1 if they haven't been written yet. */
    108   long sentRequestMillis = -1;
    109 
    110   /**
    111    * True if this client added an "Accept-Encoding: gzip" header field and is
    112    * therefore responsible for also decompressing the transfer stream.
    113    */
    114   private boolean transparentGzip;
    115 
    116   final URI uri;
    117 
    118   final RequestHeaders requestHeaders;
    119 
    120   /** Null until a response is received from the network or the cache. */
    121   ResponseHeaders responseHeaders;
    122 
    123   // The cache response currently being validated on a conditional get. Null
    124   // if the cached response doesn't exist or doesn't need validation. If the
    125   // conditional get succeeds, these will be used for the response headers and
    126   // body. If it fails, these be closed and set to null.
    127   private ResponseHeaders cachedResponseHeaders;
    128   private InputStream cachedResponseBody;
    129 
    130   /**
    131    * True if the socket connection should be released to the connection pool
    132    * when the response has been fully read.
    133    */
    134   private boolean automaticallyReleaseConnectionToPool;
    135 
    136   /** True if the socket connection is no longer needed by this engine. */
    137   private boolean connectionReleased;
    138 
    139   /**
    140    * @param requestHeaders the client's supplied request headers. This class
    141    * creates a private copy that it can mutate.
    142    * @param connection the connection used for an intermediate response
    143    * immediately prior to this request/response pair, such as a same-host
    144    * redirect. This engine assumes ownership of the connection and must
    145    * release it when it is unneeded.
    146    */
    147   public HttpEngine(HttpURLConnectionImpl policy, String method, RawHeaders requestHeaders,
    148       Connection connection, RetryableOutputStream requestBodyOut) throws IOException {
    149     this.policy = policy;
    150     this.method = method;
    151     this.connection = connection;
    152     this.requestBodyOut = requestBodyOut;
    153 
    154     try {
    155       uri = Platform.get().toUriLenient(policy.getURL());
    156     } catch (URISyntaxException e) {
    157       throw new IOException(e);
    158     }
    159 
    160     this.requestHeaders = new RequestHeaders(uri, new RawHeaders(requestHeaders));
    161   }
    162 
    163   public URI getUri() {
    164     return uri;
    165   }
    166 
    167   /**
    168    * Figures out what the response source will be, and opens a socket to that
    169    * source if necessary. Prepares the request headers and gets ready to start
    170    * writing the request body if it exists.
    171    */
    172   public final void sendRequest() throws IOException {
    173     if (responseSource != null) {
    174       return;
    175     }
    176 
    177     prepareRawRequestHeaders();
    178     initResponseSource();
    179     if (policy.responseCache instanceof OkResponseCache) {
    180       ((OkResponseCache) policy.responseCache).trackResponse(responseSource);
    181     }
    182 
    183     // The raw response source may require the network, but the request
    184     // headers may forbid network use. In that case, dispose of the network
    185     // response and use a GATEWAY_TIMEOUT response instead, as specified
    186     // by http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.4.
    187     if (requestHeaders.isOnlyIfCached() && responseSource.requiresConnection()) {
    188       if (responseSource == ResponseSource.CONDITIONAL_CACHE) {
    189         Util.closeQuietly(cachedResponseBody);
    190       }
    191       this.responseSource = ResponseSource.CACHE;
    192       this.cacheResponse = GATEWAY_TIMEOUT_RESPONSE;
    193       RawHeaders rawResponseHeaders = RawHeaders.fromMultimap(cacheResponse.getHeaders(), true);
    194       setResponse(new ResponseHeaders(uri, rawResponseHeaders), cacheResponse.getBody());
    195     }
    196 
    197     if (responseSource.requiresConnection()) {
    198       sendSocketRequest();
    199     } else if (connection != null) {
    200       policy.connectionPool.recycle(connection);
    201       connection = null;
    202     }
    203   }
    204 
    205   /**
    206    * Initialize the source for this response. It may be corrected later if the
    207    * request headers forbids network use.
    208    */
    209   private void initResponseSource() throws IOException {
    210     responseSource = ResponseSource.NETWORK;
    211     if (!policy.getUseCaches() || policy.responseCache == null) {
    212       return;
    213     }
    214 
    215     CacheResponse candidate =
    216         policy.responseCache.get(uri, method, requestHeaders.getHeaders().toMultimap(false));
    217     if (candidate == null) {
    218       return;
    219     }
    220 
    221     Map<String, List<String>> responseHeadersMap = candidate.getHeaders();
    222     cachedResponseBody = candidate.getBody();
    223     if (!acceptCacheResponseType(candidate)
    224         || responseHeadersMap == null
    225         || cachedResponseBody == null) {
    226       Util.closeQuietly(cachedResponseBody);
    227       return;
    228     }
    229 
    230     RawHeaders rawResponseHeaders = RawHeaders.fromMultimap(responseHeadersMap, true);
    231     cachedResponseHeaders = new ResponseHeaders(uri, rawResponseHeaders);
    232     long now = System.currentTimeMillis();
    233     this.responseSource = cachedResponseHeaders.chooseResponseSource(now, requestHeaders);
    234     if (responseSource == ResponseSource.CACHE) {
    235       this.cacheResponse = candidate;
    236       setResponse(cachedResponseHeaders, cachedResponseBody);
    237     } else if (responseSource == ResponseSource.CONDITIONAL_CACHE) {
    238       this.cacheResponse = candidate;
    239     } else if (responseSource == ResponseSource.NETWORK) {
    240       Util.closeQuietly(cachedResponseBody);
    241     } else {
    242       throw new AssertionError();
    243     }
    244   }
    245 
    246   private void sendSocketRequest() throws IOException {
    247     if (connection == null) {
    248       connect();
    249     }
    250 
    251     if (transport != null) {
    252       throw new IllegalStateException();
    253     }
    254 
    255     transport = (Transport) connection.newTransport(this);
    256 
    257     if (hasRequestBody() && requestBodyOut == null) {
    258       // Create a request body if we don't have one already. We'll already
    259       // have one if we're retrying a failed POST.
    260       requestBodyOut = transport.createRequestBody();
    261     }
    262   }
    263 
    264   /** Connect to the origin server either directly or via a proxy. */
    265   protected final void connect() throws IOException {
    266     if (connection != null) {
    267       return;
    268     }
    269     if (routeSelector == null) {
    270       String uriHost = uri.getHost();
    271       if (uriHost == null) {
    272         throw new UnknownHostException(uri.toString());
    273       }
    274       SSLSocketFactory sslSocketFactory = null;
    275       HostnameVerifier hostnameVerifier = null;
    276       if (uri.getScheme().equalsIgnoreCase("https")) {
    277         sslSocketFactory = policy.sslSocketFactory;
    278         hostnameVerifier = policy.hostnameVerifier;
    279       }
    280       Address address = new Address(uriHost, getEffectivePort(uri), sslSocketFactory,
    281           hostnameVerifier, policy.requestedProxy);
    282       routeSelector =
    283           new RouteSelector(address, uri, policy.proxySelector, policy.connectionPool, Dns.DEFAULT);
    284     }
    285     connection = routeSelector.next();
    286     if (!connection.isConnected()) {
    287       connection.connect(policy.getConnectTimeout(), policy.getReadTimeout(), getTunnelConfig());
    288       policy.connectionPool.maybeShare(connection);
    289     }
    290     connected(connection);
    291     if (connection.getProxy() != policy.requestedProxy) {
    292       // Update the request line if the proxy changed; it may need a host name.
    293       requestHeaders.getHeaders().setRequestLine(getRequestLine());
    294     }
    295   }
    296 
    297   /**
    298    * Called after a socket connection has been created or retrieved from the
    299    * pool. Subclasses use this hook to get a reference to the TLS data.
    300    */
    301   protected void connected(Connection connection) {
    302   }
    303 
    304   /**
    305    * Called immediately before the transport transmits HTTP request headers.
    306    * This is used to observe the sent time should the request be cached.
    307    */
    308   public void writingRequestHeaders() {
    309     if (sentRequestMillis != -1) {
    310       throw new IllegalStateException();
    311     }
    312     sentRequestMillis = System.currentTimeMillis();
    313   }
    314 
    315   /**
    316    * @param body the response body, or null if it doesn't exist or isn't
    317    * available.
    318    */
    319   private void setResponse(ResponseHeaders headers, InputStream body) throws IOException {
    320     if (this.responseBodyIn != null) {
    321       throw new IllegalStateException();
    322     }
    323     this.responseHeaders = headers;
    324     if (body != null) {
    325       initContentStream(body);
    326     }
    327   }
    328 
    329   boolean hasRequestBody() {
    330     return method.equals("POST") || method.equals("PUT");
    331   }
    332 
    333   /** Returns the request body or null if this request doesn't have a body. */
    334   public final OutputStream getRequestBody() {
    335     if (responseSource == null) {
    336       throw new IllegalStateException();
    337     }
    338     return requestBodyOut;
    339   }
    340 
    341   public final boolean hasResponse() {
    342     return responseHeaders != null;
    343   }
    344 
    345   public final RequestHeaders getRequestHeaders() {
    346     return requestHeaders;
    347   }
    348 
    349   public final ResponseHeaders getResponseHeaders() {
    350     if (responseHeaders == null) {
    351       throw new IllegalStateException();
    352     }
    353     return responseHeaders;
    354   }
    355 
    356   public final int getResponseCode() {
    357     if (responseHeaders == null) {
    358       throw new IllegalStateException();
    359     }
    360     return responseHeaders.getHeaders().getResponseCode();
    361   }
    362 
    363   public final InputStream getResponseBody() {
    364     if (responseHeaders == null) {
    365       throw new IllegalStateException();
    366     }
    367     return responseBodyIn;
    368   }
    369 
    370   public final CacheResponse getCacheResponse() {
    371     return cacheResponse;
    372   }
    373 
    374   public final Connection getConnection() {
    375     return connection;
    376   }
    377 
    378   /**
    379    * Returns true if {@code cacheResponse} is of the right type. This
    380    * condition is necessary but not sufficient for the cached response to
    381    * be used.
    382    */
    383   protected boolean acceptCacheResponseType(CacheResponse cacheResponse) {
    384     return true;
    385   }
    386 
    387   private void maybeCache() throws IOException {
    388     // Are we caching at all?
    389     if (!policy.getUseCaches() || policy.responseCache == null) {
    390       return;
    391     }
    392 
    393     // Should we cache this response for this request?
    394     if (!responseHeaders.isCacheable(requestHeaders)) {
    395       return;
    396     }
    397 
    398     // Offer this request to the cache.
    399     cacheRequest = policy.responseCache.put(uri, policy.getHttpConnectionToCache());
    400   }
    401 
    402   /**
    403    * Cause the socket connection to be released to the connection pool when
    404    * it is no longer needed. If it is already unneeded, it will be pooled
    405    * immediately. Otherwise the connection is held so that redirects can be
    406    * handled by the same connection.
    407    */
    408   public final void automaticallyReleaseConnectionToPool() {
    409     automaticallyReleaseConnectionToPool = true;
    410     if (connection != null && connectionReleased) {
    411       policy.connectionPool.recycle(connection);
    412       connection = null;
    413     }
    414   }
    415 
    416   /**
    417    * Releases this engine so that its resources may be either reused or
    418    * closed. Also call {@link #automaticallyReleaseConnectionToPool} unless
    419    * the connection will be used to follow a redirect.
    420    */
    421   public final void release(boolean streamCancelled) {
    422     // If the response body comes from the cache, close it.
    423     if (responseBodyIn == cachedResponseBody) {
    424       Util.closeQuietly(responseBodyIn);
    425     }
    426 
    427     if (!connectionReleased && connection != null) {
    428       connectionReleased = true;
    429 
    430       if (transport == null || !transport.makeReusable(streamCancelled, requestBodyOut,
    431           responseTransferIn)) {
    432         Util.closeQuietly(connection);
    433         connection = null;
    434       } else if (automaticallyReleaseConnectionToPool) {
    435         policy.connectionPool.recycle(connection);
    436         connection = null;
    437       }
    438     }
    439   }
    440 
    441   private void initContentStream(InputStream transferStream) throws IOException {
    442     responseTransferIn = transferStream;
    443     if (transparentGzip && responseHeaders.isContentEncodingGzip()) {
    444       // If the response was transparently gzipped, remove the gzip header field
    445       // so clients don't double decompress. http://b/3009828
    446       //
    447       // Also remove the Content-Length in this case because it contains the
    448       // length 528 of the gzipped response. This isn't terribly useful and is
    449       // dangerous because 529 clients can query the content length, but not
    450       // the content encoding.
    451       responseHeaders.stripContentEncoding();
    452       responseHeaders.stripContentLength();
    453       responseBodyIn = new GZIPInputStream(transferStream);
    454     } else {
    455       responseBodyIn = transferStream;
    456     }
    457   }
    458 
    459   /**
    460    * Returns true if the response must have a (possibly 0-length) body.
    461    * See RFC 2616 section 4.3.
    462    */
    463   public final boolean hasResponseBody() {
    464     int responseCode = responseHeaders.getHeaders().getResponseCode();
    465 
    466     // HEAD requests never yield a body regardless of the response headers.
    467     if (method.equals("HEAD")) {
    468       return false;
    469     }
    470 
    471     if ((responseCode < HTTP_CONTINUE || responseCode >= 200)
    472         && responseCode != HttpURLConnectionImpl.HTTP_NO_CONTENT
    473         && responseCode != HttpURLConnectionImpl.HTTP_NOT_MODIFIED) {
    474       return true;
    475     }
    476 
    477     // If the Content-Length or Transfer-Encoding headers disagree with the
    478     // response code, the response is malformed. For best compatibility, we
    479     // honor the headers.
    480     if (responseHeaders.getContentLength() != -1 || responseHeaders.isChunked()) {
    481       return true;
    482     }
    483 
    484     return false;
    485   }
    486 
    487   /**
    488    * Populates requestHeaders with defaults and cookies.
    489    *
    490    * <p>This client doesn't specify a default {@code Accept} header because it
    491    * doesn't know what content types the application is interested in.
    492    */
    493   private void prepareRawRequestHeaders() throws IOException {
    494     requestHeaders.getHeaders().setRequestLine(getRequestLine());
    495 
    496     if (requestHeaders.getUserAgent() == null) {
    497       requestHeaders.setUserAgent(getDefaultUserAgent());
    498     }
    499 
    500     if (requestHeaders.getHost() == null) {
    501       requestHeaders.setHost(getOriginAddress(policy.getURL()));
    502     }
    503 
    504     if ((connection == null || connection.getHttpMinorVersion() != 0)
    505         && requestHeaders.getConnection() == null) {
    506       requestHeaders.setConnection("Keep-Alive");
    507     }
    508 
    509     if (requestHeaders.getAcceptEncoding() == null) {
    510       transparentGzip = true;
    511       requestHeaders.setAcceptEncoding("gzip");
    512     }
    513 
    514     if (hasRequestBody() && requestHeaders.getContentType() == null) {
    515       requestHeaders.setContentType("application/x-www-form-urlencoded");
    516     }
    517 
    518     long ifModifiedSince = policy.getIfModifiedSince();
    519     if (ifModifiedSince != 0) {
    520       requestHeaders.setIfModifiedSince(new Date(ifModifiedSince));
    521     }
    522 
    523     CookieHandler cookieHandler = policy.cookieHandler;
    524     if (cookieHandler != null) {
    525       requestHeaders.addCookies(
    526           cookieHandler.get(uri, requestHeaders.getHeaders().toMultimap(false)));
    527     }
    528   }
    529 
    530   /**
    531    * Returns the request status line, like "GET / HTTP/1.1". This is exposed
    532    * to the application by {@link HttpURLConnectionImpl#getHeaderFields}, so
    533    * it needs to be set even if the transport is SPDY.
    534    */
    535   String getRequestLine() {
    536     String protocol =
    537         (connection == null || connection.getHttpMinorVersion() != 0) ? "HTTP/1.1" : "HTTP/1.0";
    538     return method + " " + requestString() + " " + protocol;
    539   }
    540 
    541   private String requestString() {
    542     URL url = policy.getURL();
    543     if (includeAuthorityInRequestLine()) {
    544       return url.toString();
    545     } else {
    546       return requestPath(url);
    547     }
    548   }
    549 
    550   /**
    551    * Returns the path to request, like the '/' in 'GET / HTTP/1.1'. Never
    552    * empty, even if the request URL is. Includes the query component if it
    553    * exists.
    554    */
    555   public static String requestPath(URL url) {
    556     String fileOnly = url.getFile();
    557     if (fileOnly == null) {
    558       return "/";
    559     } else if (!fileOnly.startsWith("/")) {
    560       return "/" + fileOnly;
    561     } else {
    562       return fileOnly;
    563     }
    564   }
    565 
    566   /**
    567    * Returns true if the request line should contain the full URL with host
    568    * and port (like "GET http://android.com/foo HTTP/1.1") or only the path
    569    * (like "GET /foo HTTP/1.1").
    570    *
    571    * <p>This is non-final because for HTTPS it's never necessary to supply the
    572    * full URL, even if a proxy is in use.
    573    */
    574   protected boolean includeAuthorityInRequestLine() {
    575     return connection == null
    576         ? policy.usingProxy() // A proxy was requested.
    577         : connection.getProxy().type() == Proxy.Type.HTTP; // A proxy was selected.
    578   }
    579 
    580   public static String getDefaultUserAgent() {
    581     String agent = System.getProperty("http.agent");
    582     return agent != null ? agent : ("Java" + System.getProperty("java.version"));
    583   }
    584 
    585   public static String getOriginAddress(URL url) {
    586     int port = url.getPort();
    587     String result = url.getHost();
    588     if (port > 0 && port != getDefaultPort(url.getProtocol())) {
    589       result = result + ":" + port;
    590     }
    591     return result;
    592   }
    593 
    594   /**
    595    * Flushes the remaining request header and body, parses the HTTP response
    596    * headers and starts reading the HTTP response body if it exists.
    597    */
    598   public final void readResponse() throws IOException {
    599     if (hasResponse()) {
    600       return;
    601     }
    602 
    603     if (responseSource == null) {
    604       throw new IllegalStateException("readResponse() without sendRequest()");
    605     }
    606 
    607     if (!responseSource.requiresConnection()) {
    608       return;
    609     }
    610 
    611     if (sentRequestMillis == -1) {
    612       if (requestBodyOut instanceof RetryableOutputStream) {
    613         int contentLength = ((RetryableOutputStream) requestBodyOut).contentLength();
    614         requestHeaders.setContentLength(contentLength);
    615       }
    616       transport.writeRequestHeaders();
    617     }
    618 
    619     if (requestBodyOut != null) {
    620       requestBodyOut.close();
    621       if (requestBodyOut instanceof RetryableOutputStream) {
    622         transport.writeRequestBody((RetryableOutputStream) requestBodyOut);
    623       }
    624     }
    625 
    626     transport.flushRequest();
    627 
    628     responseHeaders = transport.readResponseHeaders();
    629     responseHeaders.setLocalTimestamps(sentRequestMillis, System.currentTimeMillis());
    630 
    631     if (responseSource == ResponseSource.CONDITIONAL_CACHE) {
    632       if (cachedResponseHeaders.validate(responseHeaders)) {
    633         release(false);
    634         ResponseHeaders combinedHeaders = cachedResponseHeaders.combine(responseHeaders);
    635         setResponse(combinedHeaders, cachedResponseBody);
    636         if (policy.responseCache instanceof OkResponseCache) {
    637           OkResponseCache httpResponseCache = (OkResponseCache) policy.responseCache;
    638           httpResponseCache.trackConditionalCacheHit();
    639           httpResponseCache.update(cacheResponse, policy.getHttpConnectionToCache());
    640         }
    641         return;
    642       } else {
    643         Util.closeQuietly(cachedResponseBody);
    644       }
    645     }
    646 
    647     if (hasResponseBody()) {
    648       maybeCache(); // reentrant. this calls into user code which may call back into this!
    649     }
    650 
    651     initContentStream(transport.getTransferStream(cacheRequest));
    652   }
    653 
    654   protected TunnelRequest getTunnelConfig() {
    655     return null;
    656   }
    657 
    658   public void receiveHeaders(RawHeaders headers) throws IOException {
    659     CookieHandler cookieHandler = policy.cookieHandler;
    660     if (cookieHandler != null) {
    661       cookieHandler.put(uri, headers.toMultimap(true));
    662     }
    663   }
    664 }
    665