Rollup of upstream OkHttp and Okio changes

OkHttp from: b5811711b141b230e4e58f577c79cfbf4c2d4028
to: 3c61fdb2ba9d1ebe0419b93cfbd4e94ffc857fe3

Okio from: b40f99a950cb407eff52537a97420bd253a64f63
to: b76b6903ef05546c5aef249ea6b2b679bc43094b

Both "to" are head as of 20150505.

Patches applied cleanly without conflicts except for
okio/okio/src/test/java/okio/BufferedSourceTest.java
which has local Android changes to account for Android
CTS only supporting Junit 4.10.

There are various changes included most of which will
not affect Android.

OkHttp changes of note for Android:

1) Improvements to TLS negotiation.
Upstream commit 60f5406dcc094d0431420139bd002e8bdd4ea5d5
https://github.com/square/okhttp/pull/1388

2) Fix for CTS tests on Android.
Upstream commit fb155c47661ede5da395dfb4e620867263b8c8e7
https://github.com/square/okhttp/pull/1555

3) Switch to using Okio for form URL encoding
Upstream commit 2a4c1f288d284d3266b5aec4decb167a3af0a976
https://github.com/square/okhttp/pull/1563

4) Fix Vary caching on Android.
Upstream commit b7baf23d86305762ea4e42adc4054c0840eca5ca
https://github.com/square/okhttp/pull/1590

5) Report some TLS issues during negotiation (not all)
Upstream commit 71ead1911be28c1cae1eef765abf23724b776981
https://github.com/square/okhttp/pull/1596

Okio changes of note for Android:

1) Fix for truncated GZIP streams
Upstream commit 3e25d85bc4ad3c6f1622b0438b3976804958fbfb
https://github.com/square/okhttp/issues/1540

Additional android-specific changes:

Suppress a new test that requires JUnit 4.11 and Gson in the
Android.mk file.

(cherry-picked from 7aeaaefc891f6221f4b2cce536b1c1e816e09794)
Bug: 20566983

Change-Id: Ib818478513ec712b1391b82e2376fc410eaaa737
diff --git a/Android.mk b/Android.mk
index 6a78d18..b8e8222 100644
--- a/Android.mk
+++ b/Android.mk
@@ -32,6 +32,16 @@
 okhttp_test_src_files += $(call all-java-files-under,okhttp-ws/src/main/java)
 okhttp_test_src_files += $(call all-java-files-under,okhttp-ws-tests/src/test/java)
 
+# Exclude tests Android currently has problems with:
+# 1) Parameterized (requires JUnit 4.11).
+# 2) New dependencies like gson.
+okhttp_test_src_excludes := \
+    okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformUrlTest.java \
+    okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformTestRun.java
+
+okhttp_test_src_files := \
+    $(filter-out $(okhttp_test_src_excludes), $(okhttp_test_src_files))
+
 include $(CLEAR_VARS)
 LOCAL_MODULE := okhttp
 LOCAL_MODULE_TAGS := optional
diff --git a/checkstyle.xml b/checkstyle.xml
index f725be3..fc173af 100644
--- a/checkstyle.xml
+++ b/checkstyle.xml
@@ -66,7 +66,9 @@
     <module name="LineLength">

       <property name="max" value="100"/>

     </module>

-    <module name="MethodLength"/>

+    <module name="MethodLength">

+      <property name="max" value="200"/>

+    </module>

 

 

     <!-- Checks for whitespace                               -->

diff --git a/mockwebserver/pom.xml b/mockwebserver/pom.xml
index 86d2503..3603a84 100644
--- a/mockwebserver/pom.xml
+++ b/mockwebserver/pom.xml
@@ -20,6 +20,12 @@
     </dependency>
     <dependency>
       <groupId>com.squareup.okhttp</groupId>
+      <artifactId>okhttp-testing-support</artifactId>
+      <version>${project.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.squareup.okhttp</groupId>
       <artifactId>okhttp-ws</artifactId>
       <version>${project.version}</version>
     </dependency>
diff --git a/okcurl/pom.xml b/okcurl/pom.xml
index 467c170..d167ce1 100644
--- a/okcurl/pom.xml
+++ b/okcurl/pom.xml
@@ -19,6 +19,12 @@
       <version>${project.version}</version>
     </dependency>
     <dependency>
+      <groupId>com.squareup.okhttp</groupId>
+      <artifactId>okhttp-testing-support</artifactId>
+      <version>${project.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
       <groupId>org.bouncycastle</groupId>
       <artifactId>bcprov-jdk15on</artifactId>
     </dependency>
diff --git a/okcurl/src/test/java/com/squareup/okhttp/curl/MainTest.java b/okcurl/src/test/java/com/squareup/okhttp/curl/MainTest.java
index 0cc065c..80b8665 100644
--- a/okcurl/src/test/java/com/squareup/okhttp/curl/MainTest.java
+++ b/okcurl/src/test/java/com/squareup/okhttp/curl/MainTest.java
@@ -34,10 +34,10 @@
   }
 
   @Test public void put() throws IOException {
-    Request request = fromArgs("-X", "PUT", "http://example.com").createRequest();
+    Request request = fromArgs("-X", "PUT", "-d", "foo", "http://example.com").createRequest();
     assertEquals("PUT", request.method());
     assertEquals("http://example.com", request.urlString());
-    assertEquals(0, request.body().contentLength());
+    assertEquals(3, request.body().contentLength());
   }
 
   @Test public void dataPost() {
diff --git a/okhttp-android-support/pom.xml b/okhttp-android-support/pom.xml
index 74f15fc..3bf11e9 100644
--- a/okhttp-android-support/pom.xml
+++ b/okhttp-android-support/pom.xml
@@ -16,6 +16,12 @@
   <dependencies>
     <dependency>
       <groupId>com.squareup.okhttp</groupId>
+      <artifactId>okhttp-testing-support</artifactId>
+      <version>${project.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.squareup.okhttp</groupId>
       <artifactId>okhttp-urlconnection</artifactId>
       <version>${project.version}</version>
     </dependency>
diff --git a/okhttp-android-support/src/main/java/com/squareup/okhttp/AndroidShimResponseCache.java b/okhttp-android-support/src/main/java/com/squareup/okhttp/AndroidShimResponseCache.java
index 488d3d6..4986c38 100644
--- a/okhttp-android-support/src/main/java/com/squareup/okhttp/AndroidShimResponseCache.java
+++ b/okhttp-android-support/src/main/java/com/squareup/okhttp/AndroidShimResponseCache.java
@@ -66,7 +66,11 @@
   }
 
   @Override public CacheRequest put(URI uri, URLConnection urlConnection) throws IOException {
-    Response okResponse = JavaApiConverter.createOkResponse(uri, urlConnection);
+    Response okResponse = JavaApiConverter.createOkResponseForCachePut(uri, urlConnection);
+    if (okResponse == null) {
+      // The URLConnection is not cacheable or could not be converted. Stop.
+      return null;
+    }
     com.squareup.okhttp.internal.http.CacheRequest okCacheRequest =
         delegate.internalCache.put(okResponse);
     if (okCacheRequest == null) {
diff --git a/okhttp-android-support/src/main/java/com/squareup/okhttp/internal/huc/CacheAdapter.java b/okhttp-android-support/src/main/java/com/squareup/okhttp/internal/huc/CacheAdapter.java
index 13a34c0..e13c575 100644
--- a/okhttp-android-support/src/main/java/com/squareup/okhttp/internal/huc/CacheAdapter.java
+++ b/okhttp-android-support/src/main/java/com/squareup/okhttp/internal/huc/CacheAdapter.java
@@ -48,12 +48,12 @@
     if (javaResponse == null) {
       return null;
     }
-    return JavaApiConverter.createOkResponse(request, javaResponse);
+    return JavaApiConverter.createOkResponseForCacheGet(request, javaResponse);
   }
 
   @Override public CacheRequest put(Response response) throws IOException {
     URI uri = response.request().uri();
-    HttpURLConnection connection = JavaApiConverter.createJavaUrlConnection(response);
+    HttpURLConnection connection = JavaApiConverter.createJavaUrlConnectionForCachePut(response);
     final java.net.CacheRequest request = delegate.put(uri, connection);
     if (request == null) {
       return null;
diff --git a/okhttp-android-support/src/main/java/com/squareup/okhttp/internal/huc/JavaApiConverter.java b/okhttp-android-support/src/main/java/com/squareup/okhttp/internal/huc/JavaApiConverter.java
index 2c5f738..026c45f 100644
--- a/okhttp-android-support/src/main/java/com/squareup/okhttp/internal/huc/JavaApiConverter.java
+++ b/okhttp-android-support/src/main/java/com/squareup/okhttp/internal/huc/JavaApiConverter.java
@@ -19,10 +19,13 @@
 import com.squareup.okhttp.Headers;
 import com.squareup.okhttp.MediaType;
 import com.squareup.okhttp.Request;
+import com.squareup.okhttp.RequestBody;
 import com.squareup.okhttp.Response;
 import com.squareup.okhttp.ResponseBody;
+import com.squareup.okhttp.internal.Internal;
 import com.squareup.okhttp.internal.Util;
 import com.squareup.okhttp.internal.http.CacheRequest;
+import com.squareup.okhttp.internal.http.HttpMethod;
 import com.squareup.okhttp.internal.http.OkHeaders;
 import com.squareup.okhttp.internal.http.StatusLine;
 import java.io.IOException;
@@ -39,6 +42,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import javax.net.ssl.HostnameVerifier;
 import javax.net.ssl.HttpsURLConnection;
 import javax.net.ssl.SSLPeerUnverifiedException;
@@ -51,23 +55,42 @@
  * Helper methods that convert between Java and OkHttp representations.
  */
 public final class JavaApiConverter {
+  private static final RequestBody EMPTY_REQUEST_BODY = RequestBody.create(null, new byte[0]);
 
   private JavaApiConverter() {
   }
 
   /**
    * Creates an OkHttp {@link Response} using the supplied {@link URI} and {@link URLConnection}
-   * to supply the data. The URLConnection is assumed to already be connected.
+   * to supply the data. The URLConnection is assumed to already be connected. If this method
+   * returns {@code null} the response is uncacheable.
    */
-  public static Response createOkResponse(URI uri, URLConnection urlConnection) throws IOException {
+  public static Response createOkResponseForCachePut(URI uri, URLConnection urlConnection)
+      throws IOException {
+
     HttpURLConnection httpUrlConnection = (HttpURLConnection) urlConnection;
 
     Response.Builder okResponseBuilder = new Response.Builder();
 
     // Request: Create one from the URL connection.
-    // A connected HttpURLConnection does not permit access to request headers.
-    Map<String, List<String>> requestHeaders = null;
-    Request okRequest = createOkRequest(uri, httpUrlConnection.getRequestMethod(), requestHeaders);
+    Headers responseHeaders = createHeaders(urlConnection.getHeaderFields());
+    // Some request headers are needed for Vary caching.
+    Headers varyHeaders = varyHeaders(urlConnection, responseHeaders);
+    if (varyHeaders == null) {
+      return null;
+    }
+
+    // OkHttp's Call API requires a placeholder body; the real body will be streamed separately.
+    String requestMethod = httpUrlConnection.getRequestMethod();
+    RequestBody placeholderBody = HttpMethod.requiresRequestBody(requestMethod)
+        ? EMPTY_REQUEST_BODY
+        : null;
+
+    Request okRequest = new Request.Builder()
+        .url(uri.toString())
+        .method(requestMethod, placeholderBody)
+        .headers(varyHeaders)
+        .build();
     okResponseBuilder.request(okRequest);
 
     // Status line
@@ -76,6 +99,10 @@
     okResponseBuilder.code(statusLine.code);
     okResponseBuilder.message(statusLine.message);
 
+    // A network response is required for the Cache to find any Vary headers it needs.
+    Response networkResponse = okResponseBuilder.build();
+    okResponseBuilder.networkResponse(networkResponse);
+
     // Response headers
     Headers okHeaders = extractOkResponseHeaders(httpUrlConnection);
     okResponseBuilder.headers(okHeaders);
@@ -107,15 +134,91 @@
   }
 
   /**
+   * Returns headers for the header names and values in the {@link Map}.
+   */
+  private static Headers createHeaders(Map<String, List<String>> headers) {
+    Headers.Builder builder = new Headers.Builder();
+    for (Map.Entry<String, List<String>> header : headers.entrySet()) {
+      if (header.getKey() == null || header.getValue() == null) {
+        continue;
+      }
+      String name = header.getKey().trim();
+      for (String value : header.getValue()) {
+        String trimmedValue = value.trim();
+        Internal.instance.addLenient(builder, name, trimmedValue);
+      }
+    }
+    return builder.build();
+  }
+
+  private static Headers varyHeaders(URLConnection urlConnection, Headers responseHeaders) {
+    if (OkHeaders.hasVaryAll(responseHeaders)) {
+      // "*" means that this will be treated as uncacheable anyway.
+      return null;
+    }
+    Set<String> varyFields = OkHeaders.varyFields(responseHeaders);
+    if (varyFields.isEmpty()) {
+      return new Headers.Builder().build();
+    }
+
+    // This probably indicates another HTTP stack is trying to use the shared ResponseCache.
+    // We cannot guarantee this case will work properly because we cannot reliably extract *all*
+    // the request header values, and we can't get multiple Vary request header values.
+    // We also can't be sure about the Accept-Encoding behavior of other stacks.
+    if (!(urlConnection instanceof CacheHttpURLConnection
+        || urlConnection instanceof CacheHttpsURLConnection)) {
+      return null;
+    }
+
+    // This is the case we expect: The URLConnection is from a call to
+    // JavaApiConverter.createJavaUrlConnection() and we have access to the user's request headers.
+    Map<String, List<String>> requestProperties = urlConnection.getRequestProperties();
+    Headers.Builder result = new Headers.Builder();
+    for (String fieldName : varyFields) {
+      List<String> fieldValues = requestProperties.get(fieldName);
+      if (fieldValues == null) {
+        if (fieldName.equals("Accept-Encoding")) {
+          // Accept-Encoding is special. If OkHttp sees Accept-Encoding is unset it will add
+          // "gzip". We don't have access to the request that was actually made so we must do the
+          // same.
+          result.add("Accept-Encoding", "gzip");
+        }
+      } else {
+        for (String fieldValue : fieldValues) {
+          Internal.instance.addLenient(result, fieldName, fieldValue);
+        }
+      }
+    }
+    return result.build();
+  }
+
+  /**
    * Creates an OkHttp {@link Response} using the supplied {@link Request} and {@link CacheResponse}
    * to supply the data.
    */
-  static Response createOkResponse(Request request, CacheResponse javaResponse)
+  static Response createOkResponseForCacheGet(Request request, CacheResponse javaResponse)
       throws IOException {
+
+    // Build a cache request for the response to use.
+    Headers responseHeaders = createHeaders(javaResponse.getHeaders());
+    Headers varyHeaders;
+    if (OkHeaders.hasVaryAll(responseHeaders)) {
+      // "*" means that this will be treated as uncacheable anyway.
+      varyHeaders = new Headers.Builder().build();
+    } else {
+      varyHeaders = OkHeaders.varyHeaders(request.headers(), responseHeaders);
+    }
+
+    Request cacheRequest = new Request.Builder()
+        .url(request.url())
+        .method(request.method(), null)
+        .headers(varyHeaders)
+        .build();
+
     Response.Builder okResponseBuilder = new Response.Builder();
 
-    // Request: Use the one provided.
-    okResponseBuilder.request(request);
+    // Request: Use the cacheRequest we built.
+    okResponseBuilder.request(cacheRequest);
 
     // Status line: Java has this as one of the headers.
     StatusLine statusLine = StatusLine.parse(extractStatusLine(javaResponse));
@@ -163,10 +266,14 @@
    */
   public static Request createOkRequest(
       URI uri, String requestMethod, Map<String, List<String>> requestHeaders) {
+    // OkHttp's Call API requires a placeholder body; the real body will be streamed separately.
+    RequestBody placeholderBody = HttpMethod.requiresRequestBody(requestMethod)
+        ? EMPTY_REQUEST_BODY
+        : null;
 
     Request.Builder builder = new Request.Builder()
         .url(uri.toString())
-        .method(requestMethod, null);
+        .method(requestMethod, placeholderBody);
 
     if (requestHeaders != null) {
       Headers headers = extractOkHeaders(requestHeaders);
@@ -268,7 +375,7 @@
    * Creates an {@link java.net.HttpURLConnection} of the correct subclass from the supplied OkHttp
    * {@link Response}.
    */
-  static HttpURLConnection createJavaUrlConnection(Response okResponse) {
+  static HttpURLConnection createJavaUrlConnectionForCachePut(Response okResponse) {
     Request request = okResponse.request();
     // Create an object of the correct class in case the ResponseCache uses instanceof.
     if (request.isHttps()) {
@@ -320,7 +427,7 @@
         continue;
       }
       for (String value : javaHeader.getValue()) {
-        okHeadersBuilder.add(name, value);
+        Internal.instance.addLenient(okHeadersBuilder, name, value);
       }
     }
     return okHeadersBuilder.build();
@@ -475,9 +582,11 @@
 
     @Override
     public Map<String, List<String>> getRequestProperties() {
-      // This is to preserve RI and compatibility with OkHttp's HttpURLConnectionImpl. There seems
-      // no good reason why this should fail while getRequestProperty() is ok.
-      throw throwRequestHeaderAccessException();
+      // The RI and OkHttp's HttpURLConnectionImpl fail this call after connect() as required by the
+      // spec. There seems no good reason why this should fail while getRequestProperty() is ok.
+      // We don't fail here, because we need all request header values for caching Vary responses
+      // correctly.
+      return OkHeaders.toMultimap(request.headers(), null);
     }
 
     @Override
diff --git a/okhttp-android-support/src/test/java/com/squareup/okhttp/internal/huc/JavaApiConverterTest.java b/okhttp-android-support/src/test/java/com/squareup/okhttp/internal/huc/JavaApiConverterTest.java
index 7766da3..227765a 100644
--- a/okhttp-android-support/src/test/java/com/squareup/okhttp/internal/huc/JavaApiConverterTest.java
+++ b/okhttp-android-support/src/test/java/com/squareup/okhttp/internal/huc/JavaApiConverterTest.java
@@ -18,16 +18,13 @@
 import com.squareup.okhttp.Handshake;
 import com.squareup.okhttp.Headers;
 import com.squareup.okhttp.MediaType;
-import com.squareup.okhttp.OkHttpClient;
-import com.squareup.okhttp.OkUrlFactory;
 import com.squareup.okhttp.Protocol;
 import com.squareup.okhttp.Request;
 import com.squareup.okhttp.RequestBody;
 import com.squareup.okhttp.Response;
 import com.squareup.okhttp.ResponseBody;
-import com.squareup.okhttp.internal.SslContextBuilder;
+import com.squareup.okhttp.internal.Internal;
 import com.squareup.okhttp.internal.Util;
-import com.squareup.okhttp.mockwebserver.MockResponse;
 import com.squareup.okhttp.mockwebserver.rule.MockWebServerRule;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
@@ -37,7 +34,6 @@
 import java.net.HttpURLConnection;
 import java.net.SecureCacheResponse;
 import java.net.URI;
-import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.security.Principal;
 import java.security.cert.Certificate;
@@ -51,14 +47,10 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import javax.net.ssl.HostnameVerifier;
 import javax.net.ssl.HttpsURLConnection;
-import javax.net.ssl.SSLContext;
 import javax.net.ssl.SSLPeerUnverifiedException;
-import javax.net.ssl.SSLSession;
 import okio.Buffer;
 import okio.BufferedSource;
-import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -68,7 +60,6 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
@@ -102,109 +93,13 @@
       + "fl2WRY8hb4x+zRrwsFaLEpdEvqcjOQ==\n"
       + "-----END CERTIFICATE-----");
 
-  private static final SSLContext sslContext = SslContextBuilder.localhost();
-  private static final HostnameVerifier NULL_HOSTNAME_VERIFIER = new HostnameVerifier() {
-    public boolean verify(String hostname, SSLSession session) {
-      return true;
-    }
-  };
-
   @Rule public MockWebServerRule server = new MockWebServerRule();
 
-  private OkHttpClient client;
-
-  private HttpURLConnection connection;
-
   @Before public void setUp() throws Exception {
-    client = new OkHttpClient();
+    Internal.initializeInstanceForTests();
   }
 
-  @After public void tearDown() throws Exception {
-    if (connection != null) {
-      connection.disconnect();
-    }
-  }
-
-  @Test public void createOkResponse_fromOkHttpUrlConnection() throws Exception {
-    testCreateOkResponseInternal(new OkHttpURLConnectionFactory(client), false /* isSecure */);
-  }
-
-  @Test public void createOkResponse_fromJavaHttpUrlConnection() throws Exception {
-    testCreateOkResponseInternal(new JavaHttpURLConnectionFactory(), false /* isSecure */);
-  }
-
-  @Test public void createOkResponse_fromOkHttpsUrlConnection() throws Exception {
-    testCreateOkResponseInternal(new OkHttpURLConnectionFactory(client), true /* isSecure */);
-  }
-
-  @Test public void createOkResponse_fromJavaHttpsUrlConnection() throws Exception {
-    testCreateOkResponseInternal(new JavaHttpURLConnectionFactory(), true /* isSecure */);
-  }
-
-  private void testCreateOkResponseInternal(HttpURLConnectionFactory httpUrlConnectionFactory,
-      boolean isSecure) throws Exception {
-    String statusLine = "HTTP/1.1 200 Fantastic";
-    String body = "Nothing happens";
-    final URL serverUrl;
-    MockResponse mockResponse = new MockResponse()
-        .setStatus(statusLine)
-        .addHeader("xyzzy", "baz")
-        .setBody(body);
-    if (isSecure) {
-      serverUrl = configureHttpsServer(
-          mockResponse);
-
-      assertEquals("https", serverUrl.getProtocol());
-    } else {
-      serverUrl = configureServer(
-          mockResponse);
-      assertEquals("http", serverUrl.getProtocol());
-    }
-
-    connection = httpUrlConnectionFactory.open(serverUrl);
-    if (isSecure) {
-      HttpsURLConnection httpsUrlConnection = (HttpsURLConnection) connection;
-      httpsUrlConnection.setSSLSocketFactory(sslContext.getSocketFactory());
-      httpsUrlConnection.setHostnameVerifier(NULL_HOSTNAME_VERIFIER);
-    }
-    connection.setRequestProperty("snake", "bird");
-    connection.connect();
-    Response response = JavaApiConverter.createOkResponse(serverUrl.toURI(), connection);
-
-    // Check the response.request()
-    Request request = response.request();
-    assertEquals(isSecure, request.isHttps());
-    assertEquals(serverUrl.toURI(), request.uri());
-    assertNull(request.body());
-    Headers okRequestHeaders = request.headers();
-    // In Java the request headers are unavailable for a connected HttpURLConnection.
-    assertEquals(0, okRequestHeaders.size());
-    assertEquals("GET", request.method());
-
-    // Check the response
-    assertEquals(Protocol.HTTP_1_1, response.protocol());
-    assertEquals(200, response.code());
-    assertEquals("Fantastic", response.message());
-    Headers okResponseHeaders = response.headers();
-    assertEquals("baz", okResponseHeaders.get("xyzzy"));
-    if (isSecure) {
-      Handshake handshake = response.handshake();
-      assertNotNull(handshake);
-      HttpsURLConnection httpsURLConnection = (HttpsURLConnection) connection;
-      assertNotNullAndEquals(httpsURLConnection.getCipherSuite(), handshake.cipherSuite());
-      assertEquals(httpsURLConnection.getLocalPrincipal(), handshake.localPrincipal());
-      assertNotNullAndEquals(httpsURLConnection.getPeerPrincipal(), handshake.peerPrincipal());
-      assertNotNull(httpsURLConnection.getServerCertificates());
-      assertEquals(Arrays.asList(httpsURLConnection.getServerCertificates()),
-          handshake.peerCertificates());
-      assertNull(httpsURLConnection.getLocalCertificates());
-    } else {
-      assertNull(response.handshake());
-    }
-    assertEquals(body, response.body().string());
-  }
-
-  @Test public void createOkResponse_fromCacheResponse() throws Exception {
+  @Test public void createOkResponseForCacheGet() throws Exception {
     final String statusLine = "HTTP/1.1 200 Fantastic";
     URI uri = new URI("http://foo/bar");
     Request request = new Request.Builder().url(uri.toURL()).build();
@@ -221,8 +116,11 @@
       }
     };
 
-    Response response = JavaApiConverter.createOkResponse(request, cacheResponse);
-    assertSame(request, response.request());
+    Response response = JavaApiConverter.createOkResponseForCacheGet(request, cacheResponse);
+    Request cacheRequest = response.request();
+    assertEquals(request.url(), cacheRequest.url());
+    assertEquals(request.method(), cacheRequest.method());
+    assertEquals(0, request.headers().size());
 
     assertEquals(Protocol.HTTP_1_1, response.protocol());
     assertEquals(200, response.code());
@@ -234,7 +132,7 @@
   }
 
   /** Test for https://code.google.com/p/android/issues/detail?id=160522 */
-  @Test public void createOkResponse_fromCacheResponseWithMissingStatusLine() throws Exception {
+  @Test public void createOkResponseForCacheGet_withMissingStatusLine() throws Exception {
     URI uri = new URI("http://foo/bar");
     Request request = new Request.Builder().url(uri.toURL()).build();
     CacheResponse cacheResponse = new CacheResponse() {
@@ -251,13 +149,13 @@
     };
 
     try {
-      JavaApiConverter.createOkResponse(request, cacheResponse);
+      JavaApiConverter.createOkResponseForCacheGet(request, cacheResponse);
       fail();
     } catch (IOException expected) {
     }
   }
 
-  @Test public void createOkResponse_fromSecureCacheResponse() throws Exception {
+  @Test public void createOkResponseForCacheGet_secure() throws Exception {
     final String statusLine = "HTTP/1.1 200 Fantastic";
     final Principal localPrincipal = LOCAL_CERT.getSubjectX500Principal();
     final List<Certificate> localCertificates = Arrays.<Certificate>asList(LOCAL_CERT);
@@ -298,8 +196,11 @@
       }
     };
 
-    Response response = JavaApiConverter.createOkResponse(request, cacheResponse);
-    assertSame(request, response.request());
+    Response response = JavaApiConverter.createOkResponseForCacheGet(request, cacheResponse);
+    Request cacheRequest = response.request();
+    assertEquals(request.url(), cacheRequest.url());
+    assertEquals(request.method(), cacheRequest.method());
+    assertEquals(0, request.headers().size());
 
     assertEquals(Protocol.HTTP_1_1, response.protocol());
     assertEquals(200, response.code());
@@ -364,7 +265,8 @@
 
   @Test public void createJavaUrlConnection_requestChangesForbidden() throws Exception {
     Response okResponse = createArbitraryOkResponse();
-    HttpURLConnection httpUrlConnection = JavaApiConverter.createJavaUrlConnection(okResponse);
+    HttpURLConnection httpUrlConnection =
+        JavaApiConverter.createJavaUrlConnectionForCachePut(okResponse);
     // Check an arbitrary (not complete) set of methods that can be used to modify the
     // request.
     try {
@@ -396,7 +298,8 @@
 
   @Test public void createJavaUrlConnection_connectionChangesForbidden() throws Exception {
     Response okResponse = createArbitraryOkResponse();
-    HttpURLConnection httpUrlConnection = JavaApiConverter.createJavaUrlConnection(okResponse);
+    HttpURLConnection httpUrlConnection =
+        JavaApiConverter.createJavaUrlConnectionForCachePut(okResponse);
     try {
       httpUrlConnection.connect();
       fail();
@@ -411,7 +314,8 @@
 
   @Test public void createJavaUrlConnection_responseChangesForbidden() throws Exception {
     Response okResponse = createArbitraryOkResponse();
-    HttpURLConnection httpUrlConnection = JavaApiConverter.createJavaUrlConnection(okResponse);
+    HttpURLConnection httpUrlConnection =
+        JavaApiConverter.createJavaUrlConnectionForCachePut(okResponse);
     // Check an arbitrary (not complete) set of methods that can be used to access the response
     // body.
     try {
@@ -455,7 +359,8 @@
         .body(responseBody)
         .build();
 
-    HttpURLConnection httpUrlConnection = JavaApiConverter.createJavaUrlConnection(okResponse);
+    HttpURLConnection httpUrlConnection =
+        JavaApiConverter.createJavaUrlConnectionForCachePut(okResponse);
     assertEquals(200, httpUrlConnection.getResponseCode());
     assertEquals("Fantastic", httpUrlConnection.getResponseMessage());
     assertEquals(responseBody.contentLength(), httpUrlConnection.getContentLength());
@@ -530,7 +435,8 @@
         .get()
         .build();
     Response okResponse = createArbitraryOkResponse(okRequest);
-    HttpURLConnection httpUrlConnection = JavaApiConverter.createJavaUrlConnection(okResponse);
+    HttpURLConnection httpUrlConnection =
+        JavaApiConverter.createJavaUrlConnectionForCachePut(okResponse);
 
     assertEquals("GET", httpUrlConnection.getRequestMethod());
     assertTrue(httpUrlConnection.getDoInput());
@@ -542,7 +448,8 @@
         .post(createRequestBody("PostBody"))
         .build();
     Response okResponse = createArbitraryOkResponse(okRequest);
-    HttpURLConnection httpUrlConnection = JavaApiConverter.createJavaUrlConnection(okResponse);
+    HttpURLConnection httpUrlConnection =
+        JavaApiConverter.createJavaUrlConnectionForCachePut(okResponse);
 
     assertEquals("POST", httpUrlConnection.getRequestMethod());
     assertTrue(httpUrlConnection.getDoInput());
@@ -560,7 +467,7 @@
         .handshake(handshake)
         .build();
     HttpsURLConnection httpsUrlConnection =
-        (HttpsURLConnection) JavaApiConverter.createJavaUrlConnection(okResponse);
+        (HttpsURLConnection) JavaApiConverter.createJavaUrlConnectionForCachePut(okResponse);
 
     assertEquals("SecureCipher", httpsUrlConnection.getCipherSuite());
     assertEquals(SERVER_CERT.getSubjectX500Principal(), httpsUrlConnection.getPeerPrincipal());
@@ -576,7 +483,7 @@
         .build();
     Response okResponse = createArbitraryOkResponse(okRequest);
     HttpsURLConnection httpsUrlConnection =
-        (HttpsURLConnection) JavaApiConverter.createJavaUrlConnection(okResponse);
+        (HttpsURLConnection) JavaApiConverter.createJavaUrlConnectionForCachePut(okResponse);
 
     try {
       httpsUrlConnection.getHostnameVerifier();
@@ -707,44 +614,11 @@
     }
   }
 
-  private URL configureServer(MockResponse mockResponse) throws Exception {
-    server.enqueue(mockResponse);
-    return server.getUrl("/");
-  }
-
-  private URL configureHttpsServer(MockResponse mockResponse) throws Exception {
-    server.get().useHttps(sslContext.getSocketFactory(), false /* tunnelProxy */);
-    server.enqueue(mockResponse);
-    return server.getUrl("/");
-  }
-
   private static <T> void assertNotNullAndEquals(T expected, T actual) {
     assertNotNull(actual);
     assertEquals(expected, actual);
   }
 
-  private interface HttpURLConnectionFactory {
-    public HttpURLConnection open(URL serverUrl) throws IOException;
-  }
-
-  private static class OkHttpURLConnectionFactory implements HttpURLConnectionFactory {
-    protected final OkHttpClient client;
-
-    private OkHttpURLConnectionFactory(OkHttpClient client) {
-      this.client = client;
-    }
-
-    @Override public HttpURLConnection open(URL serverUrl) {
-      return new OkUrlFactory(client).open(serverUrl);
-    }
-  }
-
-  private static class JavaHttpURLConnectionFactory implements HttpURLConnectionFactory {
-    @Override public HttpURLConnection open(URL serverUrl) throws IOException {
-      return (HttpURLConnection) serverUrl.openConnection();
-    }
-  }
-
   private static X509Certificate certificate(String certificate) {
     try {
       return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(
@@ -755,6 +629,7 @@
     }
   }
 
+  @SafeVarargs
   private static <T> Set<T> newSet(T... elements) {
     return newSet(Arrays.asList(elements));
   }
diff --git a/okhttp-android-support/src/test/java/com/squareup/okhttp/internal/huc/ResponseCacheTest.java b/okhttp-android-support/src/test/java/com/squareup/okhttp/internal/huc/ResponseCacheTest.java
index 18956a3..83d1f64 100644
--- a/okhttp-android-support/src/test/java/com/squareup/okhttp/internal/huc/ResponseCacheTest.java
+++ b/okhttp-android-support/src/test/java/com/squareup/okhttp/internal/huc/ResponseCacheTest.java
@@ -17,19 +17,19 @@
 package com.squareup.okhttp.internal.huc;
 
 import com.squareup.okhttp.AbstractResponseCache;
+import com.squareup.okhttp.AndroidInternal;
+import com.squareup.okhttp.AndroidShimResponseCache;
 import com.squareup.okhttp.Headers;
 import com.squareup.okhttp.OkHttpClient;
 import com.squareup.okhttp.OkUrlFactory;
 import com.squareup.okhttp.internal.Internal;
 import com.squareup.okhttp.internal.SslContextBuilder;
-import com.squareup.okhttp.internal.http.HttpDate;
 import com.squareup.okhttp.mockwebserver.MockResponse;
 import com.squareup.okhttp.mockwebserver.MockWebServer;
 import com.squareup.okhttp.mockwebserver.RecordedRequest;
 import com.squareup.okhttp.mockwebserver.rule.MockWebServerRule;
 import java.io.BufferedReader;
 import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
@@ -50,12 +50,16 @@
 import java.nio.charset.StandardCharsets;
 import java.security.Principal;
 import java.security.cert.Certificate;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -63,7 +67,6 @@
 import javax.net.ssl.HostnameVerifier;
 import javax.net.ssl.HttpsURLConnection;
 import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLPeerUnverifiedException;
 import javax.net.ssl.SSLSession;
 import okio.Buffer;
 import okio.BufferedSink;
@@ -73,6 +76,7 @@
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 
 import static com.squareup.okhttp.mockwebserver.SocketPolicy.DISCONNECT_AT_END;
 import static org.junit.Assert.assertEquals;
@@ -82,7 +86,10 @@
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
-/** Tests the interaction between OkHttp and {@link ResponseCache}. */
+/**
+ * Tests the interaction between OkHttp and {@link ResponseCache}.
+ * Based on com.squareup.okhttp.CacheTest with changes for ResponseCache and HttpURLConnection.
+ */
 public final class ResponseCacheTest {
   private static final HostnameVerifier NULL_HOSTNAME_VERIFIER = new HostnameVerifier() {
     @Override public boolean verify(String s, SSLSession sslSession) {
@@ -92,6 +99,7 @@
 
   private static final SSLContext sslContext = SslContextBuilder.localhost();
 
+  @Rule public TemporaryFolder cacheRule = new TemporaryFolder();
   @Rule public MockWebServerRule serverRule = new MockWebServerRule();
   @Rule public MockWebServerRule server2Rule = new MockWebServerRule();
 
@@ -99,6 +107,7 @@
   private MockWebServer server;
   private MockWebServer server2;
   private ResponseCache cache;
+  private CookieManager cookieManager;
 
   @Before public void setUp() throws Exception {
     server = serverRule.get();
@@ -106,18 +115,113 @@
     server2 = server2Rule.get();
 
     client = new OkHttpClient();
-    cache = new InMemoryResponseCache();
-    Internal.instance.setCache(client, new CacheAdapter(cache));
+
+    cache = AndroidShimResponseCache.create(cacheRule.getRoot(), 10 * 1024 * 1024);
+    AndroidInternal.setResponseCache(new OkUrlFactory(client), cache);
+
+    cookieManager = new CookieManager();
+    CookieManager.setDefault(cookieManager);
   }
 
   @After public void tearDown() throws Exception {
     CookieManager.setDefault(null);
+    ResponseCache.setDefault(null);
   }
 
   private HttpURLConnection openConnection(URL url) {
     return new OkUrlFactory(client).open(url);
   }
 
+  /**
+   * Test that response caching is consistent with the RI and the spec.
+   * http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4
+   */
+  @Test public void responseCachingByResponseCode() throws Exception {
+    // Test each documented HTTP/1.1 code, plus the first unused value in each range.
+    // http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
+
+    // We can't test 100 because it's not really a response.
+    // assertCached(false, 100);
+    assertCached(false, 101);
+    assertCached(false, 102);
+    assertCached(true,  200);
+    assertCached(false, 201);
+    assertCached(false, 202);
+    assertCached(true,  203);
+    assertCached(true,  204);
+    assertCached(false, 205);
+    assertCached(false, 206); //Electing to not cache partial responses
+    assertCached(false, 207);
+    assertCached(true,  300);
+    assertCached(true,  301);
+    assertCached(true,  302);
+    assertCached(false, 303);
+    assertCached(false, 304);
+    assertCached(false, 305);
+    assertCached(false, 306);
+    assertCached(true,  307);
+    assertCached(true,  308);
+    assertCached(false, 400);
+    assertCached(false, 401);
+    assertCached(false, 402);
+    assertCached(false, 403);
+    assertCached(true,  404);
+    assertCached(true,  405);
+    assertCached(false, 406);
+    assertCached(false, 408);
+    assertCached(false, 409);
+    // the HTTP spec permits caching 410s, but the RI doesn't.
+    assertCached(true,  410);
+    assertCached(false, 411);
+    assertCached(false, 412);
+    assertCached(false, 413);
+    assertCached(true,  414);
+    assertCached(false, 415);
+    assertCached(false, 416);
+    assertCached(false, 417);
+    assertCached(false, 418);
+
+    assertCached(false, 500);
+    assertCached(true,  501);
+    assertCached(false, 502);
+    assertCached(false, 503);
+    assertCached(false, 504);
+    assertCached(false, 505);
+    assertCached(false, 506);
+  }
+
+  private void assertCached(boolean shouldPut, int responseCode) throws Exception {
+    server = new MockWebServer();
+    MockResponse mockResponse = new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+        .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
+        .setResponseCode(responseCode)
+        .setBody("ABCDE")
+        .addHeader("WWW-Authenticate: challenge");
+    if (responseCode == HttpURLConnection.HTTP_PROXY_AUTH) {
+      mockResponse.addHeader("Proxy-Authenticate: Basic realm=\"protected area\"");
+    } else if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) {
+      mockResponse.addHeader("WWW-Authenticate: Basic realm=\"protected area\"");
+    }
+    server.enqueue(mockResponse);
+    server.start();
+
+    URL url = server.getUrl("/");
+    HttpURLConnection connection = openConnection(url);
+    assertEquals(responseCode, connection.getResponseCode());
+
+    // Exhaust the content stream.
+    readAscii(connection);
+
+    CacheResponse cached = cache.get(url.toURI(), "GET", null);
+    if (shouldPut) {
+      assertNotNull(Integer.toString(responseCode), cached);
+    } else {
+      assertNull(Integer.toString(responseCode), cached);
+    }
+    server.shutdown(); // tearDown() isn't sufficient; this test starts multiple servers
+  }
+
   @Test public void responseCachingAndInputStreamSkipWithFixedLength() throws IOException {
     testResponseCaching(TransferKind.FIXED_LENGTH);
   }
@@ -135,12 +239,12 @@
    * http://code.google.com/p/android/issues/detail?id=8175
    */
   private void testResponseCaching(TransferKind transferKind) throws IOException {
-    MockResponse response =
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
-            .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
-            .setStatus("HTTP/1.1 200 Fantastic");
-    transferKind.setBody(response, "I love puppies but hate spiders", 1);
-    server.enqueue(response);
+    MockResponse mockResponse = new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+        .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
+        .setStatus("HTTP/1.1 200 Fantastic");
+    transferKind.setBody(mockResponse, "I love puppies but hate spiders", 1);
+    server.enqueue(mockResponse);
 
     // Make sure that calling skip() doesn't omit bytes from the cache.
     HttpURLConnection urlConnection = openConnection(server.getUrl("/"));
@@ -162,34 +266,10 @@
     in.close();
   }
 
-  @Test public void responseCachingWithoutBody() throws IOException {
-    MockResponse response =
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
-            .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
-            .setStatus("HTTP/1.1 200 Fantastic");
-    server.enqueue(response);
-
-    // Make sure that calling skip() doesn't omit bytes from the cache.
-    HttpURLConnection urlConnection = openConnection(server.getUrl("/"));
-    assertEquals(200, urlConnection.getResponseCode());
-    assertEquals("Fantastic", urlConnection.getResponseMessage());
-    assertTrue(urlConnection.getDoInput());
-    InputStream is = urlConnection.getInputStream();
-    assertEquals(-1, is.read());
-    is.close();
-
-    urlConnection = openConnection(server.getUrl("/")); // cached!
-    assertTrue(urlConnection.getDoInput());
-    InputStream cachedIs = urlConnection.getInputStream();
-    assertEquals(-1, cachedIs.read());
-    cachedIs.close();
-    assertEquals(200, urlConnection.getResponseCode());
-    assertEquals("Fantastic", urlConnection.getResponseMessage());
-  }
-
   @Test public void secureResponseCaching() throws IOException {
     server.useHttps(sslContext.getSocketFactory(), false);
-    server.enqueue(new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
         .setBody("ABC"));
 
@@ -217,35 +297,18 @@
     assertEquals(localPrincipal, c2.getLocalPrincipal());
   }
 
-  @Test public void cacheReturnsInsecureResponseForSecureRequest() throws IOException {
-    server.useHttps(sslContext.getSocketFactory(), false);
-    server.enqueue(new MockResponse().setBody("ABC"));
-    server.enqueue(new MockResponse().setBody("DEF"));
-
-    Internal.instance.setCache(client,
-        new CacheAdapter(new InsecureResponseCache(new InMemoryResponseCache())));
-
-    HttpsURLConnection connection1 = (HttpsURLConnection) openConnection(server.getUrl("/"));
-    connection1.setSSLSocketFactory(sslContext.getSocketFactory());
-    connection1.setHostnameVerifier(NULL_HOSTNAME_VERIFIER);
-    assertEquals("ABC", readAscii(connection1));
-
-    // Not cached!
-    HttpsURLConnection connection2 = (HttpsURLConnection) openConnection(server.getUrl("/"));
-    connection2.setSSLSocketFactory(sslContext.getSocketFactory());
-    connection2.setHostnameVerifier(NULL_HOSTNAME_VERIFIER);
-    assertEquals("DEF", readAscii(connection2));
-  }
-
   @Test public void responseCachingAndRedirects() throws Exception {
-    server.enqueue(new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
         .setResponseCode(HttpURLConnection.HTTP_MOVED_PERM)
         .addHeader("Location: /foo"));
-    server.enqueue(new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
         .setBody("ABC"));
-    server.enqueue(new MockResponse().setBody("DEF"));
+    server.enqueue(new MockResponse()
+        .setBody("DEF"));
 
     HttpURLConnection connection = openConnection(server.getUrl("/"));
     assertEquals("ABC", readAscii(connection));
@@ -255,10 +318,14 @@
   }
 
   @Test public void redirectToCachedResult() throws Exception {
-    server.enqueue(new MockResponse().addHeader("Cache-Control: max-age=60").setBody("ABC"));
-    server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_MOVED_PERM)
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .setBody("ABC"));
+    server.enqueue(new MockResponse()
+        .setResponseCode(HttpURLConnection.HTTP_MOVED_PERM)
         .addHeader("Location: /foo"));
-    server.enqueue(new MockResponse().setBody("DEF"));
+    server.enqueue(new MockResponse()
+        .setBody("DEF"));
 
     assertEquals("ABC", readAscii(openConnection(server.getUrl("/foo"))));
     RecordedRequest request1 = server.takeRequest();
@@ -279,14 +346,17 @@
 
   @Test public void secureResponseCachingAndRedirects() throws IOException {
     server.useHttps(sslContext.getSocketFactory(), false);
-    server.enqueue(new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
         .setResponseCode(HttpURLConnection.HTTP_MOVED_PERM)
         .addHeader("Location: /foo"));
-    server.enqueue(new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
         .setBody("ABC"));
-    server.enqueue(new MockResponse().setBody("DEF"));
+    server.enqueue(new MockResponse()
+        .setBody("DEF"));
 
     client.setSslSocketFactory(sslContext.getSocketFactory());
     client.setHostnameVerifier(NULL_HOSTNAME_VERIFIER);
@@ -313,12 +383,15 @@
    */
   @Test public void secureResponseCachingAndProtocolRedirects() throws IOException {
     server2.useHttps(sslContext.getSocketFactory(), false);
-    server2.enqueue(new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+    server2.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
         .setBody("ABC"));
-    server2.enqueue(new MockResponse().setBody("DEF"));
+    server2.enqueue(new MockResponse()
+        .setBody("DEF"));
 
-    server.enqueue(new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
         .setResponseCode(HttpURLConnection.HTTP_MOVED_PERM)
         .addHeader("Location: " + server2.getUrl("/")));
@@ -334,24 +407,61 @@
     assertEquals("ABC", readAscii(connection2));
   }
 
-  @Test public void responseCacheRequestHeaders() throws IOException, URISyntaxException {
-    server.enqueue(new MockResponse().setBody("ABC"));
+  @Test public void foundCachedWithExpiresHeader() throws Exception {
+    temporaryRedirectCachedWithCachingHeader(302, "Expires", formatDate(1, TimeUnit.HOURS));
+  }
 
-    final AtomicReference<Map<String, List<String>>> requestHeadersRef =
-        new AtomicReference<Map<String, List<String>>>();
-    Internal.instance.setCache(client, new CacheAdapter(new AbstractResponseCache() {
-      @Override public CacheResponse get(URI uri, String requestMethod,
-          Map<String, List<String>> requestHeaders) throws IOException {
-        requestHeadersRef.set(requestHeaders);
-        return null;
-      }
-    }));
+  @Test public void foundCachedWithCacheControlHeader() throws Exception {
+    temporaryRedirectCachedWithCachingHeader(302, "Cache-Control", "max-age=60");
+  }
+
+  @Test public void temporaryRedirectCachedWithExpiresHeader() throws Exception {
+    temporaryRedirectCachedWithCachingHeader(307, "Expires", formatDate(1, TimeUnit.HOURS));
+  }
+
+  @Test public void temporaryRedirectCachedWithCacheControlHeader() throws Exception {
+    temporaryRedirectCachedWithCachingHeader(307, "Cache-Control", "max-age=60");
+  }
+
+  @Test public void foundNotCachedWithoutCacheHeader() throws Exception {
+    temporaryRedirectNotCachedWithoutCachingHeader(302);
+  }
+
+  @Test public void temporaryRedirectNotCachedWithoutCacheHeader() throws Exception {
+    temporaryRedirectNotCachedWithoutCachingHeader(307);
+  }
+
+  private void temporaryRedirectCachedWithCachingHeader(
+      int responseCode, String headerName, String headerValue) throws Exception {
+    server.enqueue(new MockResponse()
+        .setResponseCode(responseCode)
+        .addHeader(headerName, headerValue)
+        .addHeader("Location", "/a"));
+    server.enqueue(new MockResponse()
+        .addHeader(headerName, headerValue)
+        .setBody("a"));
+    server.enqueue(new MockResponse()
+        .setBody("b"));
+    server.enqueue(new MockResponse()
+        .setBody("c"));
 
     URL url = server.getUrl("/");
-    URLConnection urlConnection = openConnection(url);
-    urlConnection.addRequestProperty("A", "android");
-    readAscii(urlConnection);
-    assertEquals(Arrays.asList("android"), requestHeadersRef.get().get("A"));
+    assertEquals("a", readAscii(openConnection(url)));
+    assertEquals("a", readAscii(openConnection(url)));
+  }
+
+  private void temporaryRedirectNotCachedWithoutCachingHeader(int responseCode) throws Exception {
+    server.enqueue(new MockResponse()
+        .setResponseCode(responseCode)
+        .addHeader("Location", "/a"));
+    server.enqueue(new MockResponse()
+        .setBody("a"));
+    server.enqueue(new MockResponse()
+        .setBody("b"));
+
+    URL url = server.getUrl("/");
+    assertEquals("a", readAscii(openConnection(url)));
+    assertEquals("b", readAscii(openConnection(url)));
   }
 
   @Test public void serverDisconnectsPrematurelyWithContentLengthHeader() throws IOException {
@@ -372,7 +482,8 @@
     MockResponse response = new MockResponse();
     transferKind.setBody(response, "ABCDE\nFGHIJKLMNOPQRSTUVWXYZ", 16);
     server.enqueue(truncateViolently(response, 16));
-    server.enqueue(new MockResponse().setBody("Request #2"));
+    server.enqueue(new MockResponse()
+        .setBody("Request #2"));
 
     BufferedReader reader = new BufferedReader(
         new InputStreamReader(openConnection(server.getUrl("/")).getInputStream()));
@@ -403,10 +514,12 @@
 
   private void testClientPrematureDisconnect(TransferKind transferKind) throws IOException {
     // Setting a low transfer speed ensures that stream discarding will time out.
-    MockResponse response = new MockResponse().throttleBody(6, 1, TimeUnit.SECONDS);
+    MockResponse response = new MockResponse()
+        .throttleBody(6, 1, TimeUnit.SECONDS);
     transferKind.setBody(response, "ABCDE\nFGHIJKLMNOPQRSTUVWXYZ", 1024);
     server.enqueue(response);
-    server.enqueue(new MockResponse().setBody("Request #2"));
+    server.enqueue(new MockResponse()
+        .setBody("Request #2"));
 
     URLConnection connection = openConnection(server.getUrl("/"));
     InputStream in = connection.getInputStream();
@@ -427,10 +540,10 @@
     //             served:   5 seconds ago
     //   default lifetime: (105 - 5) / 10 = 10 seconds
     //            expires:  10 seconds from served date = 5 seconds from now
-    server.enqueue(
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-105, TimeUnit.SECONDS))
-            .addHeader("Date: " + formatDate(-5, TimeUnit.SECONDS))
-            .setBody("A"));
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-105, TimeUnit.SECONDS))
+        .addHeader("Date: " + formatDate(-5, TimeUnit.SECONDS))
+        .setBody("A"));
 
     URL url = server.getUrl("/");
     assertEquals("A", readAscii(openConnection(url)));
@@ -445,9 +558,9 @@
     //   default lifetime: (115 - 15) / 10 = 10 seconds
     //            expires:  10 seconds from served date = 5 seconds ago
     String lastModifiedDate = formatDate(-115, TimeUnit.SECONDS);
-    RecordedRequest conditionalRequest = assertConditionallyCached(
-        new MockResponse().addHeader("Last-Modified: " + lastModifiedDate)
-            .addHeader("Date: " + formatDate(-15, TimeUnit.SECONDS)));
+    RecordedRequest conditionalRequest = assertConditionallyCached(new MockResponse()
+        .addHeader("Last-Modified: " + lastModifiedDate)
+        .addHeader("Date: " + formatDate(-15, TimeUnit.SECONDS)));
     assertEquals(lastModifiedDate, conditionalRequest.getHeader("If-Modified-Since"));
   }
 
@@ -456,7 +569,8 @@
     //             served:   5 days ago
     //   default lifetime: (105 - 5) / 10 = 10 days
     //            expires:  10 days from served date = 5 days from now
-    server.enqueue(new MockResponse().addHeader("Last-Modified: " + formatDate(-105, TimeUnit.DAYS))
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-105, TimeUnit.DAYS))
         .addHeader("Date: " + formatDate(-5, TimeUnit.DAYS))
         .setBody("A"));
 
@@ -468,11 +582,12 @@
   }
 
   @Test public void noDefaultExpirationForUrlsWithQueryString() throws Exception {
-    server.enqueue(
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-105, TimeUnit.SECONDS))
-            .addHeader("Date: " + formatDate(-5, TimeUnit.SECONDS))
-            .setBody("A"));
-    server.enqueue(new MockResponse().setBody("B"));
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-105, TimeUnit.SECONDS))
+        .addHeader("Date: " + formatDate(-5, TimeUnit.SECONDS))
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
 
     URL url = server.getUrl("/?foo=bar");
     assertEquals("A", readAscii(openConnection(url)));
@@ -481,98 +596,122 @@
 
   @Test public void expirationDateInThePastWithLastModifiedHeader() throws Exception {
     String lastModifiedDate = formatDate(-2, TimeUnit.HOURS);
-    RecordedRequest conditionalRequest = assertConditionallyCached(
-        new MockResponse().addHeader("Last-Modified: " + lastModifiedDate)
-            .addHeader("Expires: " + formatDate(-1, TimeUnit.HOURS)));
+    RecordedRequest conditionalRequest = assertConditionallyCached(new MockResponse()
+        .addHeader("Last-Modified: " + lastModifiedDate)
+        .addHeader("Expires: " + formatDate(-1, TimeUnit.HOURS)));
     assertEquals(lastModifiedDate, conditionalRequest.getHeader("If-Modified-Since"));
   }
 
   @Test public void expirationDateInThePastWithNoLastModifiedHeader() throws Exception {
-    assertNotCached(new MockResponse().addHeader("Expires: " + formatDate(-1, TimeUnit.HOURS)));
+    assertNotCached(new MockResponse()
+        .addHeader("Expires: " + formatDate(-1, TimeUnit.HOURS)));
   }
 
   @Test public void expirationDateInTheFuture() throws Exception {
-    assertFullyCached(new MockResponse().addHeader("Expires: " + formatDate(1, TimeUnit.HOURS)));
+    assertFullyCached(new MockResponse()
+        .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS)));
   }
 
   @Test public void maxAgePreferredWithMaxAgeAndExpires() throws Exception {
-    assertFullyCached(new MockResponse().addHeader("Date: " + formatDate(0, TimeUnit.HOURS))
+    assertFullyCached(new MockResponse()
+        .addHeader("Date: " + formatDate(0, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Cache-Control: max-age=60"));
   }
 
   @Test public void maxAgeInThePastWithDateAndLastModifiedHeaders() throws Exception {
     String lastModifiedDate = formatDate(-2, TimeUnit.HOURS);
-    RecordedRequest conditionalRequest = assertConditionallyCached(
-        new MockResponse().addHeader("Date: " + formatDate(-120, TimeUnit.SECONDS))
-            .addHeader("Last-Modified: " + lastModifiedDate)
-            .addHeader("Cache-Control: max-age=60"));
+    RecordedRequest conditionalRequest = assertConditionallyCached(new MockResponse()
+        .addHeader("Date: " + formatDate(-120, TimeUnit.SECONDS))
+        .addHeader("Last-Modified: " + lastModifiedDate)
+        .addHeader("Cache-Control: max-age=60"));
     assertEquals(lastModifiedDate, conditionalRequest.getHeader("If-Modified-Since"));
   }
 
   @Test public void maxAgeInThePastWithDateHeaderButNoLastModifiedHeader() throws Exception {
     // Chrome interprets max-age relative to the local clock. Both our cache
     // and Firefox both use the earlier of the local and server's clock.
-    assertNotCached(new MockResponse().addHeader("Date: " + formatDate(-120, TimeUnit.SECONDS))
+    assertNotCached(new MockResponse()
+        .addHeader("Date: " + formatDate(-120, TimeUnit.SECONDS))
         .addHeader("Cache-Control: max-age=60"));
   }
 
   @Test public void maxAgeInTheFutureWithDateHeader() throws Exception {
-    assertFullyCached(new MockResponse().addHeader("Date: " + formatDate(0, TimeUnit.HOURS))
+    assertFullyCached(new MockResponse()
+        .addHeader("Date: " + formatDate(0, TimeUnit.HOURS))
         .addHeader("Cache-Control: max-age=60"));
   }
 
   @Test public void maxAgeInTheFutureWithNoDateHeader() throws Exception {
-    assertFullyCached(new MockResponse().addHeader("Cache-Control: max-age=60"));
+    assertFullyCached(new MockResponse()
+        .addHeader("Cache-Control: max-age=60"));
   }
 
   @Test public void maxAgeWithLastModifiedButNoServedDate() throws Exception {
-    assertFullyCached(
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-120, TimeUnit.SECONDS))
-            .addHeader("Cache-Control: max-age=60"));
+    assertFullyCached(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-120, TimeUnit.SECONDS))
+        .addHeader("Cache-Control: max-age=60"));
   }
 
   @Test public void maxAgeInTheFutureWithDateAndLastModifiedHeaders() throws Exception {
-    assertFullyCached(
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-120, TimeUnit.SECONDS))
-            .addHeader("Date: " + formatDate(0, TimeUnit.SECONDS))
-            .addHeader("Cache-Control: max-age=60"));
+    assertFullyCached(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-120, TimeUnit.SECONDS))
+        .addHeader("Date: " + formatDate(0, TimeUnit.SECONDS))
+        .addHeader("Cache-Control: max-age=60"));
   }
 
   @Test public void maxAgePreferredOverLowerSharedMaxAge() throws Exception {
-    assertFullyCached(new MockResponse().addHeader("Date: " + formatDate(-2, TimeUnit.MINUTES))
+    assertFullyCached(new MockResponse()
+        .addHeader("Date: " + formatDate(-2, TimeUnit.MINUTES))
         .addHeader("Cache-Control: s-maxage=60")
         .addHeader("Cache-Control: max-age=180"));
   }
 
   @Test public void maxAgePreferredOverHigherMaxAge() throws Exception {
-    assertNotCached(new MockResponse().addHeader("Date: " + formatDate(-2, TimeUnit.MINUTES))
+    assertNotCached(new MockResponse()
+        .addHeader("Date: " + formatDate(-2, TimeUnit.MINUTES))
         .addHeader("Cache-Control: s-maxage=180")
         .addHeader("Cache-Control: max-age=60"));
   }
 
-  /**
-   * Tests that the ResponseCache can cache something. The InMemoryResponseCache only caches GET
-   * requests.
-   */
-  @Test public void responseCacheCanCache() throws Exception {
+  @Test public void requestMethodOptionsIsNotCached() throws Exception {
+    testRequestMethod("OPTIONS", false);
+  }
+
+  @Test public void requestMethodGetIsCached() throws Exception {
     testRequestMethod("GET", true);
   }
 
-  /**
-   * Confirm the ResponseCache can elect to not cache something. The InMemoryResponseCache only
-   * caches GET requests.
-   */
-  @Test public void responseCacheCanIgnore() throws Exception {
+  @Test public void requestMethodHeadIsNotCached() throws Exception {
+    // We could support this but choose not to for implementation simplicity
     testRequestMethod("HEAD", false);
   }
 
+  @Test public void requestMethodPostIsNotCached() throws Exception {
+    // We could support this but choose not to for implementation simplicity
+    testRequestMethod("POST", false);
+  }
+
+  @Test public void requestMethodPutIsNotCached() throws Exception {
+    testRequestMethod("PUT", false);
+  }
+
+  @Test public void requestMethodDeleteIsNotCached() throws Exception {
+    testRequestMethod("DELETE", false);
+  }
+
+  @Test public void requestMethodTraceIsNotCached() throws Exception {
+    testRequestMethod("TRACE", false);
+  }
+
   private void testRequestMethod(String requestMethod, boolean expectCached) throws Exception {
     // 1. seed the cache (potentially)
     // 2. expect a cache hit or miss
-    server.enqueue(new MockResponse().addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
+    server.enqueue(new MockResponse()
+        .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
         .addHeader("X-Response-ID: 1"));
-    server.enqueue(new MockResponse().addHeader("X-Response-ID: 2"));
+    server.enqueue(new MockResponse()
+        .addHeader("X-Response-ID: 2"));
 
     URL url = server.getUrl("/");
 
@@ -591,6 +730,51 @@
     }
   }
 
+  private void addRequestBodyIfNecessary(String requestMethod, HttpURLConnection connection)
+      throws IOException {
+    if (requestMethod.equals("POST") || requestMethod.equals("PUT")) {
+      connection.setDoOutput(true);
+      OutputStream requestBody = connection.getOutputStream();
+      requestBody.write('x');
+      requestBody.close();
+    }
+  }
+
+  @Test public void postInvalidatesCache() throws Exception {
+    testMethodInvalidates("POST");
+  }
+
+  @Test public void putInvalidatesCache() throws Exception {
+    testMethodInvalidates("PUT");
+  }
+
+  @Test public void deleteMethodInvalidatesCache() throws Exception {
+    testMethodInvalidates("DELETE");
+  }
+
+  private void testMethodInvalidates(String requestMethod) throws Exception {
+    // 1. seed the cache
+    // 2. invalidate it
+    // 3. expect a cache miss
+    server.enqueue(new MockResponse()
+        .setBody("A")
+        .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS)));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+    server.enqueue(new MockResponse()
+        .setBody("C"));
+
+    URL url = server.getUrl("/");
+
+    assertEquals("A", readAscii(openConnection(url)));
+
+    HttpURLConnection invalidateConnection = openConnection(url);
+    invalidateConnection.setRequestMethod(requestMethod);
+    assertEquals("B", readAscii(invalidateConnection));
+
+    assertEquals("C", readAscii(openConnection(url)));
+  }
+
   /**
    * Equivalent to {@code CacheTest.postInvalidatesCacheWithUncacheableResponse()} but demonstrating
    * that {@link ResponseCache} provides no mechanism for cache invalidation as the result of
@@ -600,9 +784,12 @@
     // 1. seed the cache
     // 2. invalidate it with uncacheable response
     // 3. the cache to return the original value
-    server.enqueue(
-        new MockResponse().setBody("A").addHeader("Expires: " + formatDate(1, TimeUnit.HOURS)));
-    server.enqueue(new MockResponse().setBody("B").setResponseCode(500));
+    server.enqueue(new MockResponse()
+        .setBody("A")
+        .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS)));
+    server.enqueue(new MockResponse()
+        .setBody("B")
+        .setResponseCode(500));
 
     URL url = server.getUrl("/");
 
@@ -617,59 +804,65 @@
   }
 
   @Test public void etag() throws Exception {
-    RecordedRequest conditionalRequest =
-        assertConditionallyCached(new MockResponse().addHeader("ETag: v1"));
+    RecordedRequest conditionalRequest = assertConditionallyCached(new MockResponse()
+        .addHeader("ETag: v1"));
     assertEquals("v1", conditionalRequest.getHeader("If-None-Match"));
   }
 
+  /** If both If-Modified-Since and If-None-Match conditions apply, send only If-None-Match. */
   @Test public void etagAndExpirationDateInThePast() throws Exception {
     String lastModifiedDate = formatDate(-2, TimeUnit.HOURS);
-    RecordedRequest conditionalRequest = assertConditionallyCached(
-        new MockResponse().addHeader("ETag: v1")
-            .addHeader("Last-Modified: " + lastModifiedDate)
-            .addHeader("Expires: " + formatDate(-1, TimeUnit.HOURS)));
+    RecordedRequest conditionalRequest = assertConditionallyCached(new MockResponse()
+        .addHeader("ETag: v1")
+        .addHeader("Last-Modified: " + lastModifiedDate)
+        .addHeader("Expires: " + formatDate(-1, TimeUnit.HOURS)));
     assertEquals("v1", conditionalRequest.getHeader("If-None-Match"));
     assertNull(conditionalRequest.getHeader("If-Modified-Since"));
   }
 
   @Test public void etagAndExpirationDateInTheFuture() throws Exception {
-    assertFullyCached(new MockResponse().addHeader("ETag: v1")
+    assertFullyCached(new MockResponse()
+        .addHeader("ETag: v1")
         .addHeader("Last-Modified: " + formatDate(-2, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS)));
   }
 
   @Test public void cacheControlNoCache() throws Exception {
-    assertNotCached(new MockResponse().addHeader("Cache-Control: no-cache"));
+    assertNotCached(new MockResponse()
+        .addHeader("Cache-Control: no-cache"));
   }
 
   @Test public void cacheControlNoCacheAndExpirationDateInTheFuture() throws Exception {
     String lastModifiedDate = formatDate(-2, TimeUnit.HOURS);
-    RecordedRequest conditionalRequest = assertConditionallyCached(
-        new MockResponse().addHeader("Last-Modified: " + lastModifiedDate)
-            .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
-            .addHeader("Cache-Control: no-cache"));
+    RecordedRequest conditionalRequest = assertConditionallyCached(new MockResponse()
+        .addHeader("Last-Modified: " + lastModifiedDate)
+        .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
+        .addHeader("Cache-Control: no-cache"));
     assertEquals(lastModifiedDate, conditionalRequest.getHeader("If-Modified-Since"));
   }
 
   @Test public void pragmaNoCache() throws Exception {
-    assertNotCached(new MockResponse().addHeader("Pragma: no-cache"));
+    assertNotCached(new MockResponse()
+        .addHeader("Pragma: no-cache"));
   }
 
   @Test public void pragmaNoCacheAndExpirationDateInTheFuture() throws Exception {
     String lastModifiedDate = formatDate(-2, TimeUnit.HOURS);
-    RecordedRequest conditionalRequest = assertConditionallyCached(
-        new MockResponse().addHeader("Last-Modified: " + lastModifiedDate)
-            .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
-            .addHeader("Pragma: no-cache"));
+    RecordedRequest conditionalRequest = assertConditionallyCached(new MockResponse()
+        .addHeader("Last-Modified: " + lastModifiedDate)
+        .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
+        .addHeader("Pragma: no-cache"));
     assertEquals(lastModifiedDate, conditionalRequest.getHeader("If-Modified-Since"));
   }
 
   @Test public void cacheControlNoStore() throws Exception {
-    assertNotCached(new MockResponse().addHeader("Cache-Control: no-store"));
+    assertNotCached(new MockResponse()
+        .addHeader("Cache-Control: no-store"));
   }
 
   @Test public void cacheControlNoStoreAndExpirationDateInTheFuture() throws Exception {
-    assertNotCached(new MockResponse().addHeader("Last-Modified: " + formatDate(-2, TimeUnit.HOURS))
+    assertNotCached(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-2, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
         .addHeader("Cache-Control: no-store"));
   }
@@ -677,15 +870,17 @@
   @Test public void partialRangeResponsesDoNotCorruptCache() throws Exception {
     // 1. request a range
     // 2. request a full document, expecting a cache miss
-    server.enqueue(new MockResponse().setBody("AA")
+    server.enqueue(new MockResponse()
+        .setBody("AA")
         .setResponseCode(HttpURLConnection.HTTP_PARTIAL)
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
         .addHeader("Content-Range: bytes 1000-1001/2000"));
-    server.enqueue(new MockResponse().setBody("BB"));
+    server.enqueue(new MockResponse()
+        .setBody("BB"));
 
     URL url = server.getUrl("/");
 
-    URLConnection range = openConnection(url);
+    HttpURLConnection range = openConnection(url);
     range.addRequestProperty("Range", "bytes=1000-1001");
     assertEquals("AA", readAscii(range));
 
@@ -693,10 +888,12 @@
   }
 
   @Test public void serverReturnsDocumentOlderThanCache() throws Exception {
-    server.enqueue(new MockResponse().setBody("A")
+    server.enqueue(new MockResponse()
+        .setBody("A")
         .addHeader("Last-Modified: " + formatDate(-2, TimeUnit.HOURS))
         .addHeader("Expires: " + formatDate(-1, TimeUnit.HOURS)));
-    server.enqueue(new MockResponse().setBody("B")
+    server.enqueue(new MockResponse()
+        .setBody("B")
         .addHeader("Last-Modified: " + formatDate(-4, TimeUnit.HOURS)));
 
     URL url = server.getUrl("/");
@@ -705,23 +902,42 @@
     assertEquals("A", readAscii(openConnection(url)));
   }
 
+  @Test public void clientSideNoStore() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .setBody("B"));
+
+    HttpURLConnection connection1 = openConnection(server.getUrl("/"));
+    connection1.setRequestProperty("Cache-Control", "no-store");
+    assertEquals("A", readAscii(connection1));
+
+    HttpURLConnection connection2 = openConnection(server.getUrl("/"));
+    assertEquals("B", readAscii(connection2));
+  }
+
   @Test public void nonIdentityEncodingAndConditionalCache() throws Exception {
-    assertNonIdentityEncodingCached(
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-2, TimeUnit.HOURS))
-            .addHeader("Expires: " + formatDate(-1, TimeUnit.HOURS)));
+    assertNonIdentityEncodingCached(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-2, TimeUnit.HOURS))
+        .addHeader("Expires: " + formatDate(-1, TimeUnit.HOURS)));
   }
 
   @Test public void nonIdentityEncodingAndFullCache() throws Exception {
-    assertNonIdentityEncodingCached(
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-2, TimeUnit.HOURS))
-            .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS)));
+    assertNonIdentityEncodingCached(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-2, TimeUnit.HOURS))
+        .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS)));
   }
 
   private void assertNonIdentityEncodingCached(MockResponse response) throws Exception {
-    server.enqueue(
-        response.setBody(gzip("ABCABCABC")).addHeader("Content-Encoding: gzip"));
-    server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
-    server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
+    server.enqueue(response
+        .setBody(gzip("ABCABCABC"))
+        .addHeader("Content-Encoding: gzip"));
+    server.enqueue(new MockResponse()
+        .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
+    server.enqueue(new MockResponse()
+        .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
 
     // At least three request/response pairs are required because after the first request is cached
     // a different execution path might be taken. Thus modifications to the cache applied during
@@ -748,18 +964,34 @@
     assertEquals("DEFDEFDEF", readAscii(openConnection(server.getUrl("/"))));
   }
 
+  /** https://github.com/square/okhttp/issues/947 */
+  @Test public void gzipAndVaryOnAcceptEncoding() throws Exception {
+    server.enqueue(new MockResponse()
+        .setBody(gzip("ABCABCABC"))
+        .addHeader("Content-Encoding: gzip")
+        .addHeader("Vary: Accept-Encoding")
+        .addHeader("Cache-Control: max-age=60"));
+    server.enqueue(new MockResponse()
+        .setBody("FAIL"));
+
+    assertEquals("ABCABCABC", readAscii(openConnection(server.getUrl("/"))));
+    assertEquals("ABCABCABC", readAscii(openConnection(server.getUrl("/"))));
+  }
+
   @Test public void expiresDateBeforeModifiedDate() throws Exception {
-    assertConditionallyCached(
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
-            .addHeader("Expires: " + formatDate(-2, TimeUnit.HOURS)));
+    assertConditionallyCached(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+        .addHeader("Expires: " + formatDate(-2, TimeUnit.HOURS)));
   }
 
   @Test public void requestMaxAge() throws IOException {
-    server.enqueue(new MockResponse().setBody("A")
+    server.enqueue(new MockResponse()
+        .setBody("A")
         .addHeader("Last-Modified: " + formatDate(-2, TimeUnit.HOURS))
         .addHeader("Date: " + formatDate(-1, TimeUnit.MINUTES))
         .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS)));
-    server.enqueue(new MockResponse().setBody("B"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
 
     assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
 
@@ -769,10 +1001,12 @@
   }
 
   @Test public void requestMinFresh() throws IOException {
-    server.enqueue(new MockResponse().setBody("A")
+    server.enqueue(new MockResponse()
+        .setBody("A")
         .addHeader("Cache-Control: max-age=60")
         .addHeader("Date: " + formatDate(0, TimeUnit.MINUTES)));
-    server.enqueue(new MockResponse().setBody("B"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
 
     assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
 
@@ -782,10 +1016,12 @@
   }
 
   @Test public void requestMaxStale() throws IOException {
-    server.enqueue(new MockResponse().setBody("A")
+    server.enqueue(new MockResponse()
+        .setBody("A")
         .addHeader("Cache-Control: max-age=120")
         .addHeader("Date: " + formatDate(-4, TimeUnit.MINUTES)));
-    server.enqueue(new MockResponse().setBody("B"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
 
     assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
 
@@ -796,11 +1032,32 @@
         connection.getHeaderField("Warning"));
   }
 
+  @Test public void requestMaxStaleDirectiveWithNoValue() throws IOException {
+    // Add a stale response to the cache.
+    server.enqueue(new MockResponse()
+        .setBody("A")
+        .addHeader("Cache-Control: max-age=120")
+        .addHeader("Date: " + formatDate(-4, TimeUnit.MINUTES)));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
+
+    // With max-stale, we'll return that stale response.
+    URLConnection maxStaleConnection = openConnection(server.getUrl("/"));
+    maxStaleConnection.setRequestProperty("Cache-Control", "max-stale");
+    assertEquals("A", readAscii(maxStaleConnection));
+    assertEquals("110 HttpURLConnection \"Response is stale\"",
+        maxStaleConnection.getHeaderField("Warning"));
+  }
+
   @Test public void requestMaxStaleNotHonoredWithMustRevalidate() throws IOException {
-    server.enqueue(new MockResponse().setBody("A")
+    server.enqueue(new MockResponse()
+        .setBody("A")
         .addHeader("Cache-Control: max-age=120, must-revalidate")
         .addHeader("Date: " + formatDate(-4, TimeUnit.MINUTES)));
-    server.enqueue(new MockResponse().setBody("B"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
 
     assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
 
@@ -818,7 +1075,8 @@
   }
 
   @Test public void requestOnlyIfCachedWithFullResponseCached() throws IOException {
-    server.enqueue(new MockResponse().setBody("A")
+    server.enqueue(new MockResponse()
+        .setBody("A")
         .addHeader("Cache-Control: max-age=30")
         .addHeader("Date: " + formatDate(0, TimeUnit.MINUTES)));
 
@@ -829,7 +1087,8 @@
   }
 
   @Test public void requestOnlyIfCachedWithConditionalResponseCached() throws IOException {
-    server.enqueue(new MockResponse().setBody("A")
+    server.enqueue(new MockResponse()
+        .setBody("A")
         .addHeader("Cache-Control: max-age=30")
         .addHeader("Date: " + formatDate(-1, TimeUnit.MINUTES)));
 
@@ -840,7 +1099,8 @@
   }
 
   @Test public void requestOnlyIfCachedWithUnhelpfulResponseCached() throws IOException {
-    server.enqueue(new MockResponse().setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("A"));
 
     assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
     HttpURLConnection connection = openConnection(server.getUrl("/"));
@@ -849,11 +1109,11 @@
   }
 
   @Test public void requestCacheControlNoCache() throws Exception {
-    server.enqueue(
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-120, TimeUnit.SECONDS))
-            .addHeader("Date: " + formatDate(0, TimeUnit.SECONDS))
-            .addHeader("Cache-Control: max-age=60")
-            .setBody("A"));
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-120, TimeUnit.SECONDS))
+        .addHeader("Date: " + formatDate(0, TimeUnit.SECONDS))
+        .addHeader("Cache-Control: max-age=60")
+        .setBody("A"));
     server.enqueue(new MockResponse().setBody("B"));
 
     URL url = server.getUrl("/");
@@ -864,11 +1124,11 @@
   }
 
   @Test public void requestPragmaNoCache() throws Exception {
-    server.enqueue(
-        new MockResponse().addHeader("Last-Modified: " + formatDate(-120, TimeUnit.SECONDS))
-            .addHeader("Date: " + formatDate(0, TimeUnit.SECONDS))
-            .addHeader("Cache-Control: max-age=60")
-            .setBody("A"));
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-120, TimeUnit.SECONDS))
+        .addHeader("Date: " + formatDate(0, TimeUnit.SECONDS))
+        .addHeader("Cache-Control: max-age=60")
+        .setBody("A"));
     server.enqueue(new MockResponse().setBody("B"));
 
     URL url = server.getUrl("/");
@@ -879,8 +1139,9 @@
   }
 
   @Test public void clientSuppliedIfModifiedSinceWithCachedResult() throws Exception {
-    MockResponse response =
-        new MockResponse().addHeader("ETag: v3").addHeader("Cache-Control: max-age=0");
+    MockResponse response = new MockResponse()
+        .addHeader("ETag: v3")
+        .addHeader("Cache-Control: max-age=0");
     String ifModifiedSinceDate = formatDate(-24, TimeUnit.HOURS);
     RecordedRequest request =
         assertClientSuppliedCondition(response, "If-Modified-Since", ifModifiedSinceDate);
@@ -890,7 +1151,8 @@
 
   @Test public void clientSuppliedIfNoneMatchSinceWithCachedResult() throws Exception {
     String lastModifiedDate = formatDate(-3, TimeUnit.MINUTES);
-    MockResponse response = new MockResponse().addHeader("Last-Modified: " + lastModifiedDate)
+    MockResponse response = new MockResponse()
+        .addHeader("Last-Modified: " + lastModifiedDate)
         .addHeader("Date: " + formatDate(-2, TimeUnit.MINUTES))
         .addHeader("Cache-Control: max-age=0");
     RecordedRequest request = assertClientSuppliedCondition(response, "If-None-Match", "v1");
@@ -901,7 +1163,8 @@
   private RecordedRequest assertClientSuppliedCondition(MockResponse seed, String conditionName,
       String conditionValue) throws Exception {
     server.enqueue(seed.setBody("A"));
-    server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
+    server.enqueue(new MockResponse()
+        .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
 
     URL url = server.getUrl("/");
     assertEquals("A", readAscii(openConnection(url)));
@@ -915,20 +1178,42 @@
     return server.takeRequest();
   }
 
-  @Test public void setIfModifiedSince() throws Exception {
-    Date since = new Date();
-    server.enqueue(new MockResponse().setBody("A"));
+  /**
+   * For Last-Modified and Date headers, we should echo the date back in the
+   * exact format we were served.
+   */
+  @Test public void retainServedDateFormat() throws Exception {
+    // Serve a response with a non-standard date format that OkHttp supports.
+    Date lastModifiedDate = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(-1));
+    Date servedDate = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(-2));
+    DateFormat dateFormat = new SimpleDateFormat("EEE dd-MMM-yyyy HH:mm:ss z", Locale.US);
+    dateFormat.setTimeZone(TimeZone.getTimeZone("EDT"));
+    String lastModifiedString = dateFormat.format(lastModifiedDate);
+    String servedString = dateFormat.format(servedDate);
 
-    URL url = server.getUrl("/");
-    URLConnection connection = openConnection(url);
-    connection.setIfModifiedSince(since.getTime());
-    assertEquals("A", readAscii(connection));
-    RecordedRequest request = server.takeRequest();
-    assertEquals(HttpDate.format(since), request.getHeader("If-Modified-Since"));
+    // This response should be conditionally cached.
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + lastModifiedString)
+        .addHeader("Expires: " + servedString)
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
+
+    assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
+    assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
+
+    // The first request has no conditions.
+    RecordedRequest request1 = server.takeRequest();
+    assertNull(request1.getHeader("If-Modified-Since"));
+
+    // The 2nd request uses the server's date format.
+    RecordedRequest request2 = server.takeRequest();
+    assertEquals(lastModifiedString, request2.getHeader("If-Modified-Since"));
   }
 
   @Test public void clientSuppliedConditionWithoutCachedResult() throws Exception {
-    server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
+    server.enqueue(new MockResponse()
+        .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
 
     HttpURLConnection connection = openConnection(server.getUrl("/"));
     String clientIfModifiedSince = formatDate(-24, TimeUnit.HOURS);
@@ -938,8 +1223,11 @@
   }
 
   @Test public void authorizationRequestFullyCached() throws Exception {
-    server.enqueue(new MockResponse().addHeader("Cache-Control: max-age=60").setBody("A"));
-    server.enqueue(new MockResponse().setBody("B"));
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
 
     URL url = server.getUrl("/");
     URLConnection connection = openConnection(url);
@@ -949,59 +1237,26 @@
   }
 
   @Test public void contentLocationDoesNotPopulateCache() throws Exception {
-    server.enqueue(new MockResponse().addHeader("Cache-Control: max-age=60")
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
         .addHeader("Content-Location: /bar")
         .setBody("A"));
-    server.enqueue(new MockResponse().setBody("B"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
 
     assertEquals("A", readAscii(openConnection(server.getUrl("/foo"))));
     assertEquals("B", readAscii(openConnection(server.getUrl("/bar"))));
   }
 
-  @Test public void useCachesFalseDoesNotWriteToCache() throws Exception {
-    server.enqueue(
-        new MockResponse().addHeader("Cache-Control: max-age=60").setBody("A").setBody("A"));
-    server.enqueue(new MockResponse().setBody("B"));
-
-    URLConnection connection = openConnection(server.getUrl("/"));
-    connection.setUseCaches(false);
-    assertEquals("A", readAscii(connection));
-    assertEquals("B", readAscii(openConnection(server.getUrl("/"))));
-  }
-
-  @Test public void useCachesFalseDoesNotReadFromCache() throws Exception {
-    server.enqueue(
-        new MockResponse().addHeader("Cache-Control: max-age=60").setBody("A").setBody("A"));
-    server.enqueue(new MockResponse().setBody("B"));
-
-    assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
-    URLConnection connection = openConnection(server.getUrl("/"));
-    connection.setUseCaches(false);
-    assertEquals("B", readAscii(connection));
-  }
-
-  @Test public void defaultUseCachesSetsInitialValueOnly() throws Exception {
-    URL url = new URL("http://localhost/");
-    URLConnection c1 = openConnection(url);
-    URLConnection c2 = openConnection(url);
-    assertTrue(c1.getDefaultUseCaches());
-    c1.setDefaultUseCaches(false);
-    try {
-      assertTrue(c1.getUseCaches());
-      assertTrue(c2.getUseCaches());
-      URLConnection c3 = openConnection(url);
-      assertFalse(c3.getUseCaches());
-    } finally {
-      c1.setDefaultUseCaches(true);
-    }
-  }
-
   @Test public void connectionIsReturnedToPoolAfterConditionalSuccess() throws Exception {
-    server.enqueue(new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Cache-Control: max-age=0")
         .setBody("A"));
-    server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
-    server.enqueue(new MockResponse().setBody("B"));
+    server.enqueue(new MockResponse()
+        .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
 
     assertEquals("A", readAscii(openConnection(server.getUrl("/a"))));
     assertEquals("A", readAscii(openConnection(server.getUrl("/a"))));
@@ -1012,64 +1267,242 @@
     assertEquals(2, server.takeRequest().getSequenceNumber());
   }
 
-  /**
-   * Confirms the cache implementation may determine the criteria for caching. In real caches
-   * this would be the "Vary" headers.
-   */
-  @Test public void cacheCanUseCriteriaBesidesVariantObeyed() throws Exception {
-    server.enqueue(
-        new MockResponse().addHeader("Cache-Control: max-age=60")
-            .addHeader(InMemoryResponseCache.CACHE_VARIANT_HEADER, "A").setBody("A"));
-    server.enqueue(
-        new MockResponse().addHeader("Cache-Control: max-age=60")
-            .addHeader(InMemoryResponseCache.CACHE_VARIANT_HEADER, "B").setBody("B"));
+  @Test public void varyMatchesChangedRequestHeaderField() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Accept-Language")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
 
     URL url = server.getUrl("/");
-    URLConnection connection1 = openConnection(url);
-    connection1.addRequestProperty(InMemoryResponseCache.CACHE_VARIANT_HEADER, "A");
+    HttpURLConnection frenchConnection = openConnection(url);
+    frenchConnection.setRequestProperty("Accept-Language", "fr-CA");
+    assertEquals("A", readAscii(frenchConnection));
+
+    HttpURLConnection englishConnection = openConnection(url);
+    englishConnection.setRequestProperty("Accept-Language", "en-US");
+    assertEquals("B", readAscii(englishConnection));
+  }
+
+  @Test public void varyMatchesUnchangedRequestHeaderField() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Accept-Language")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    URL url = server.getUrl("/");
+    HttpURLConnection frenchConnection1 = openConnection(url);
+    frenchConnection1.setRequestProperty("Accept-Language", "fr-CA");
+    assertEquals("A", readAscii(frenchConnection1));
+
+    HttpURLConnection frenchConnection2 = openConnection(url);
+    frenchConnection2.setRequestProperty("Accept-Language", "fr-CA");
+    assertEquals("A", readAscii(frenchConnection2));
+  }
+
+  @Test public void varyMatchesAbsentRequestHeaderField() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Foo")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
+    assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
+  }
+
+  @Test public void varyMatchesAddedRequestHeaderField() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Foo")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
+    HttpURLConnection connection2 = openConnection(server.getUrl("/"));
+    connection2.setRequestProperty("Foo", "bar");
+    assertEquals("B", readAscii(connection2));
+  }
+
+  @Test public void varyMatchesRemovedRequestHeaderField() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Foo")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    HttpURLConnection connection1 = openConnection(server.getUrl("/"));
+    connection1.setRequestProperty("Foo", "bar");
     assertEquals("A", readAscii(connection1));
-    URLConnection connection2 = openConnection(url);
-    connection2.addRequestProperty(InMemoryResponseCache.CACHE_VARIANT_HEADER, "A");
+    assertEquals("B", readAscii(openConnection(server.getUrl("/"))));
+  }
+
+  @Test public void varyFieldsAreCaseInsensitive() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: ACCEPT-LANGUAGE")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    URL url = server.getUrl("/");
+    HttpURLConnection frenchConnection1 = openConnection(url);
+    frenchConnection1.setRequestProperty("Accept-Language", "fr-CA");
+    assertEquals("A", readAscii(frenchConnection1));
+    HttpURLConnection frenchConnection2 = openConnection(url);
+    frenchConnection2.setRequestProperty("accept-language", "fr-CA");
+    assertEquals("A", readAscii(frenchConnection2));
+  }
+
+  @Test public void varyMultipleFieldsWithMatch() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Accept-Language, Accept-Charset")
+        .addHeader("Vary: Accept-Encoding")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    URL url = server.getUrl("/");
+    HttpURLConnection frenchConnection1 = openConnection(url);
+    frenchConnection1.setRequestProperty("Accept-Language", "fr-CA");
+    frenchConnection1.setRequestProperty("Accept-Charset", "UTF-8");
+    frenchConnection1.setRequestProperty("Accept-Encoding", "identity");
+    assertEquals("A", readAscii(frenchConnection1));
+    HttpURLConnection frenchConnection2 = openConnection(url);
+    frenchConnection2.setRequestProperty("Accept-Language", "fr-CA");
+    frenchConnection2.setRequestProperty("Accept-Charset", "UTF-8");
+    frenchConnection2.setRequestProperty("Accept-Encoding", "identity");
+    assertEquals("A", readAscii(frenchConnection2));
+  }
+
+  @Test public void varyMultipleFieldsWithNoMatch() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Accept-Language, Accept-Charset")
+        .addHeader("Vary: Accept-Encoding")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    URL url = server.getUrl("/");
+    HttpURLConnection frenchConnection = openConnection(url);
+    frenchConnection.setRequestProperty("Accept-Language", "fr-CA");
+    frenchConnection.setRequestProperty("Accept-Charset", "UTF-8");
+    frenchConnection.setRequestProperty("Accept-Encoding", "identity");
+    assertEquals("A", readAscii(frenchConnection));
+    HttpURLConnection englishConnection = openConnection(url);
+    englishConnection.setRequestProperty("Accept-Language", "en-CA");
+    englishConnection.setRequestProperty("Accept-Charset", "UTF-8");
+    englishConnection.setRequestProperty("Accept-Encoding", "identity");
+    assertEquals("B", readAscii(englishConnection));
+  }
+
+  @Test public void varyMultipleFieldValuesWithMatch() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Accept-Language")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    URL url = server.getUrl("/");
+    HttpURLConnection multiConnection1 = openConnection(url);
+    multiConnection1.setRequestProperty("Accept-Language", "fr-CA, fr-FR");
+    multiConnection1.addRequestProperty("Accept-Language", "en-US");
+    assertEquals("A", readAscii(multiConnection1));
+
+    HttpURLConnection multiConnection2 = openConnection(url);
+    multiConnection2.setRequestProperty("Accept-Language", "fr-CA, fr-FR");
+    multiConnection2.addRequestProperty("Accept-Language", "en-US");
+    assertEquals("A", readAscii(multiConnection2));
+  }
+
+  @Test public void varyMultipleFieldValuesWithNoMatch() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Accept-Language")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    URL url = server.getUrl("/");
+    HttpURLConnection multiConnection = openConnection(url);
+    multiConnection.setRequestProperty("Accept-Language", "fr-CA, fr-FR");
+    multiConnection.addRequestProperty("Accept-Language", "en-US");
+    assertEquals("A", readAscii(multiConnection));
+
+    HttpURLConnection notFrenchConnection = openConnection(url);
+    notFrenchConnection.setRequestProperty("Accept-Language", "fr-CA");
+    notFrenchConnection.addRequestProperty("Accept-Language", "en-US");
+    assertEquals("B", readAscii(notFrenchConnection));
+  }
+
+  @Test public void varyAsterisk() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: *")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
+    assertEquals("B", readAscii(openConnection(server.getUrl("/"))));
+  }
+
+  @Test public void varyAndHttps() throws Exception {
+    server.useHttps(sslContext.getSocketFactory(), false);
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Accept-Language")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    client.setSslSocketFactory(sslContext.getSocketFactory());
+    client.setHostnameVerifier(NULL_HOSTNAME_VERIFIER);
+
+    URL url = server.getUrl("/");
+    HttpURLConnection connection1 = openConnection(url);
+    connection1.setRequestProperty("Accept-Language", "en-US");
+    assertEquals("A", readAscii(connection1));
+
+    HttpURLConnection connection2 = openConnection(url);
+    connection2.setRequestProperty("Accept-Language", "en-US");
     assertEquals("A", readAscii(connection2));
-    assertEquals(1, server.getRequestCount());
-
-    URLConnection connection3 = openConnection(url);
-    connection3.addRequestProperty(InMemoryResponseCache.CACHE_VARIANT_HEADER, "B");
-    assertEquals("B", readAscii(connection3));
-    assertEquals(2, server.getRequestCount());
-
-    URLConnection connection4 = openConnection(url);
-    connection4.addRequestProperty(InMemoryResponseCache.CACHE_VARIANT_HEADER, "A");
-    assertEquals("A", readAscii(connection4));
-    assertEquals(2, server.getRequestCount());
   }
 
   @Test public void cachePlusCookies() throws Exception {
-    server.enqueue(new MockResponse().addHeader(
-        "Set-Cookie: a=FIRST; domain=" + server.getCookieDomain() + ";")
+    server.enqueue(new MockResponse()
+        .addHeader("Set-Cookie: a=FIRST; domain=" + server.getCookieDomain() + ";")
         .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Cache-Control: max-age=0")
         .setBody("A"));
-    server.enqueue(new MockResponse().addHeader(
-        "Set-Cookie: a=SECOND; domain=" + server.getCookieDomain() + ";")
+    server.enqueue(new MockResponse()
+        .addHeader("Set-Cookie: a=SECOND; domain=" + server.getCookieDomain() + ";")
         .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
 
-    CookieManager cookieManager = new CookieManager();
-    CookieManager.setDefault(cookieManager);
-
     URL url = server.getUrl("/");
     assertEquals("A", readAscii(openConnection(url)));
-    assertCookies(cookieManager, url, "a=FIRST");
+    assertCookies(url, "a=FIRST");
     assertEquals("A", readAscii(openConnection(url)));
-    assertCookies(cookieManager, url, "a=SECOND");
+    assertCookies(url, "a=SECOND");
   }
 
   @Test public void getHeadersReturnsNetworkEndToEndHeaders() throws Exception {
-    server.enqueue(new MockResponse().addHeader("Allow: GET, HEAD")
+    server.enqueue(new MockResponse()
+        .addHeader("Allow: GET, HEAD")
         .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Cache-Control: max-age=0")
         .setBody("A"));
-    server.enqueue(new MockResponse().addHeader("Allow: GET, HEAD, PUT")
+    server.enqueue(new MockResponse()
+        .addHeader("Allow: GET, HEAD, PUT")
         .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
 
     URLConnection connection1 = openConnection(server.getUrl("/"));
@@ -1082,11 +1515,13 @@
   }
 
   @Test public void getHeadersReturnsCachedHopByHopHeaders() throws Exception {
-    server.enqueue(new MockResponse().addHeader("Transfer-Encoding: identity")
+    server.enqueue(new MockResponse()
+        .addHeader("Transfer-Encoding: identity")
         .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Cache-Control: max-age=0")
         .setBody("A"));
-    server.enqueue(new MockResponse().addHeader("Transfer-Encoding: none")
+    server.enqueue(new MockResponse()
+        .addHeader("Transfer-Encoding: none")
         .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
 
     URLConnection connection1 = openConnection(server.getUrl("/"));
@@ -1099,11 +1534,13 @@
   }
 
   @Test public void getHeadersDeletesCached100LevelWarnings() throws Exception {
-    server.enqueue(new MockResponse().addHeader("Warning: 199 test danger")
+    server.enqueue(new MockResponse()
+        .addHeader("Warning: 199 test danger")
         .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Cache-Control: max-age=0")
         .setBody("A"));
-    server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
+    server.enqueue(new MockResponse()
+        .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
 
     URLConnection connection1 = openConnection(server.getUrl("/"));
     assertEquals("A", readAscii(connection1));
@@ -1115,11 +1552,13 @@
   }
 
   @Test public void getHeadersRetainsCached200LevelWarnings() throws Exception {
-    server.enqueue(new MockResponse().addHeader("Warning: 299 test danger")
+    server.enqueue(new MockResponse()
+        .addHeader("Warning: 299 test danger")
         .addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
         .addHeader("Cache-Control: max-age=0")
         .setBody("A"));
-    server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
+    server.enqueue(new MockResponse()
+        .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
 
     URLConnection connection1 = openConnection(server.getUrl("/"));
     assertEquals("A", readAscii(connection1));
@@ -1130,17 +1569,17 @@
     assertEquals("299 test danger", connection2.getHeaderField("Warning"));
   }
 
-  public void assertCookies(CookieManager cookieManager, URL url, String... expectedCookies)
-      throws Exception {
-    List<String> actualCookies = new ArrayList<String>();
+  public void assertCookies(URL url, String... expectedCookies) throws Exception {
+    List<String> actualCookies = new ArrayList<>();
     for (HttpCookie cookie : cookieManager.getCookieStore().get(url.toURI())) {
       actualCookies.add(cookie.toString());
     }
     assertEquals(Arrays.asList(expectedCookies), actualCookies);
   }
 
-  @Test public void cachePlusRange() throws Exception {
-    assertNotCached(new MockResponse().setResponseCode(HttpURLConnection.HTTP_PARTIAL)
+  @Test public void doNotCachePartialResponse() throws Exception  {
+    assertNotCached(new MockResponse()
+        .setResponseCode(HttpURLConnection.HTTP_PARTIAL)
         .addHeader("Date: " + formatDate(0, TimeUnit.HOURS))
         .addHeader("Content-Range: bytes 100-100/200")
         .addHeader("Cache-Control: max-age=60"));
@@ -1152,18 +1591,23 @@
    */
   @Test public void conditionalHitDoesNotUpdateCache() throws Exception {
     // A response that is cacheable, but with a short life.
-    server.enqueue(new MockResponse().addHeader("Last-Modified: " + formatDate(0, TimeUnit.SECONDS))
+    server.enqueue(new MockResponse()
+        .addHeader("Last-Modified: " + formatDate(0, TimeUnit.SECONDS))
         .addHeader("Cache-Control: max-age=0")
         .setBody("A"));
     // A response that refers to the previous response, but is cacheable with a long life.
     // Contains a header we can recognize as having come from the server.
-    server.enqueue(new MockResponse().addHeader("Cache-Control: max-age=30")
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=30")
         .addHeader("Allow: GET, HEAD")
         .setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
     // A response that is cacheable with a long life.
-    server.enqueue(new MockResponse().setBody("B").addHeader("Cache-Control: max-age=30"));
+    server.enqueue(new MockResponse()
+        .setBody("B")
+        .addHeader("Cache-Control: max-age=30"));
     // A response that should never be requested.
-    server.enqueue(new MockResponse().setBody("C"));
+    server.enqueue(new MockResponse()
+        .setBody("C"));
 
     // cache miss; seed the cache with an entry that will require a network hit to be sure it is
     // still valid
@@ -1189,7 +1633,8 @@
   }
 
   @Test public void responseSourceHeaderCached() throws IOException {
-    server.enqueue(new MockResponse().setBody("A")
+    server.enqueue(new MockResponse()
+        .setBody("A")
         .addHeader("Cache-Control: max-age=30")
         .addHeader("Date: " + formatDate(0, TimeUnit.MINUTES)));
 
@@ -1200,10 +1645,12 @@
   }
 
   @Test public void responseSourceHeaderConditionalCacheFetched() throws IOException {
-    server.enqueue(new MockResponse().setBody("A")
+    server.enqueue(new MockResponse()
+        .setBody("A")
         .addHeader("Cache-Control: max-age=30")
         .addHeader("Date: " + formatDate(-31, TimeUnit.MINUTES)));
-    server.enqueue(new MockResponse().setBody("B")
+    server.enqueue(new MockResponse()
+        .setBody("B")
         .addHeader("Cache-Control: max-age=30")
         .addHeader("Date: " + formatDate(0, TimeUnit.MINUTES)));
 
@@ -1213,10 +1660,12 @@
   }
 
   @Test public void responseSourceHeaderConditionalCacheNotFetched() throws IOException {
-    server.enqueue(new MockResponse().setBody("A")
+    server.enqueue(new MockResponse()
+        .setBody("A")
         .addHeader("Cache-Control: max-age=0")
         .addHeader("Date: " + formatDate(0, TimeUnit.MINUTES)));
-    server.enqueue(new MockResponse().setResponseCode(304));
+    server.enqueue(new MockResponse()
+        .setResponseCode(304));
 
     assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
     HttpURLConnection connection = openConnection(server.getUrl("/"));
@@ -1224,7 +1673,8 @@
   }
 
   @Test public void responseSourceHeaderFetched() throws IOException {
-    server.enqueue(new MockResponse().setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("A"));
 
     URLConnection connection = openConnection(server.getUrl("/"));
     assertEquals("A", readAscii(connection));
@@ -1243,6 +1693,172 @@
   }
 
   /**
+   * @param delta the offset from the current date to use. Negative
+   * values yield dates in the past; positive values yield dates in the
+   * future.
+   */
+  private String formatDate(long delta, TimeUnit timeUnit) {
+    return formatDate(new Date(System.currentTimeMillis() + timeUnit.toMillis(delta)));
+  }
+
+  private String formatDate(Date date) {
+    DateFormat rfc1123 = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
+    rfc1123.setTimeZone(TimeZone.getTimeZone("GMT"));
+    return rfc1123.format(date);
+  }
+
+  private void assertNotCached(MockResponse response) throws Exception {
+    server.enqueue(response.setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    URL url = server.getUrl("/");
+    assertEquals("A", readAscii(openConnection(url)));
+    assertEquals("B", readAscii(openConnection(url)));
+  }
+
+  /** @return the request with the conditional get headers. */
+  private RecordedRequest assertConditionallyCached(MockResponse response) throws Exception {
+    // scenario 1: condition succeeds
+    server.enqueue(response.setBody("A").setStatus("HTTP/1.1 200 A-OK"));
+    server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
+
+    // scenario 2: condition fails
+    server.enqueue(response.setBody("B").setStatus("HTTP/1.1 200 B-OK"));
+    server.enqueue(new MockResponse().setStatus("HTTP/1.1 200 C-OK").setBody("C"));
+
+    URL valid = server.getUrl("/valid");
+    HttpURLConnection connection1 = openConnection(valid);
+    assertEquals("A", readAscii(connection1));
+    assertEquals(HttpURLConnection.HTTP_OK, connection1.getResponseCode());
+    assertEquals("A-OK", connection1.getResponseMessage());
+    HttpURLConnection connection2 = openConnection(valid);
+    assertEquals("A", readAscii(connection2));
+    assertEquals(HttpURLConnection.HTTP_OK, connection2.getResponseCode());
+    assertEquals("A-OK", connection2.getResponseMessage());
+
+    URL invalid = server.getUrl("/invalid");
+    HttpURLConnection connection3 = openConnection(invalid);
+    assertEquals("B", readAscii(connection3));
+    assertEquals(HttpURLConnection.HTTP_OK, connection3.getResponseCode());
+    assertEquals("B-OK", connection3.getResponseMessage());
+    HttpURLConnection connection4 = openConnection(invalid);
+    assertEquals("C", readAscii(connection4));
+    assertEquals(HttpURLConnection.HTTP_OK, connection4.getResponseCode());
+    assertEquals("C-OK", connection4.getResponseMessage());
+
+    server.takeRequest(); // regular get
+    return server.takeRequest(); // conditional get
+  }
+
+  private void assertFullyCached(MockResponse response) throws Exception {
+    server.enqueue(response.setBody("A"));
+    server.enqueue(response.setBody("B"));
+
+    URL url = server.getUrl("/");
+    assertEquals("A", readAscii(openConnection(url)));
+    assertEquals("A", readAscii(openConnection(url)));
+  }
+
+  /**
+   * Shortens the body of {@code response} but not the corresponding headers.
+   * Only useful to test how clients respond to the premature conclusion of
+   * the HTTP body.
+   */
+  private MockResponse truncateViolently(MockResponse response, int numBytesToKeep) {
+    response.setSocketPolicy(DISCONNECT_AT_END);
+    Headers headers = response.getHeaders();
+    Buffer truncatedBody = new Buffer();
+    truncatedBody.write(response.getBody(), numBytesToKeep);
+    response.setBody(truncatedBody);
+    response.setHeaders(headers);
+    return response;
+  }
+
+  enum TransferKind {
+    CHUNKED() {
+      @Override void setBody(MockResponse response, Buffer content, int chunkSize)
+          throws IOException {
+        response.setChunkedBody(content, chunkSize);
+      }
+    },
+    FIXED_LENGTH() {
+      @Override void setBody(MockResponse response, Buffer content, int chunkSize) {
+        response.setBody(content);
+      }
+    },
+    END_OF_STREAM() {
+      @Override void setBody(MockResponse response, Buffer content, int chunkSize) {
+        response.setBody(content);
+        response.setSocketPolicy(DISCONNECT_AT_END);
+        response.removeHeader("Content-Length");
+      }
+    };
+
+    abstract void setBody(MockResponse response, Buffer content, int chunkSize) throws IOException;
+
+    void setBody(MockResponse response, String content, int chunkSize) throws IOException {
+      setBody(response, new Buffer().writeUtf8(content), chunkSize);
+    }
+  }
+
+  /** Returns a gzipped copy of {@code bytes}. */
+  public Buffer gzip(String data) throws IOException {
+    Buffer result = new Buffer();
+    BufferedSink sink = Okio.buffer(new GzipSink(result));
+    sink.writeUtf8(data);
+    sink.close();
+    return result;
+  }
+
+  /**
+   * Reads {@code count} characters from the stream. If the stream is
+   * exhausted before {@code count} characters can be read, the remaining
+   * characters are returned and the stream is closed.
+   */
+  private String readAscii(URLConnection connection, int count) throws IOException {
+    HttpURLConnection httpConnection = (HttpURLConnection) connection;
+    InputStream in = httpConnection.getResponseCode() < HttpURLConnection.HTTP_BAD_REQUEST
+        ? connection.getInputStream() : httpConnection.getErrorStream();
+    StringBuilder result = new StringBuilder();
+    for (int i = 0; i < count; i++) {
+      int value = in.read();
+      if (value == -1) {
+        in.close();
+        break;
+      }
+      result.append((char) value);
+    }
+    return result.toString();
+  }
+
+  private String readAscii(URLConnection connection) throws IOException {
+    return readAscii(connection, Integer.MAX_VALUE);
+  }
+
+  private void reliableSkip(InputStream in, int length) throws IOException {
+    while (length > 0) {
+      length -= in.skip(length);
+    }
+  }
+
+  private void assertGatewayTimeout(HttpURLConnection connection) throws IOException {
+    try {
+      connection.getInputStream();
+      fail();
+    } catch (FileNotFoundException expected) {
+    }
+    assertEquals(504, connection.getResponseCode());
+    assertEquals(-1, connection.getErrorStream().read());
+  }
+
+  private static <T> List<T> toListOrNull(T[] arrayOrNull) {
+    return arrayOrNull != null ? Arrays.asList(arrayOrNull) : null;
+  }
+
+  // Android-added tests.
+
+  /**
    * Test that we can interrogate the response when the cache is being
    * populated. http://code.google.com/p/android/issues/detail?id=7787
    */
@@ -1347,173 +1963,6 @@
     }
   }
 
-  /**
-   * @param delta the offset from the current date to use. Negative
-   * values yield dates in the past; positive values yield dates in the
-   * future.
-   */
-  private String formatDate(long delta, TimeUnit timeUnit) {
-    return HttpDate.format(new Date(System.currentTimeMillis() + timeUnit.toMillis(delta)));
-  }
-
-  private void addRequestBodyIfNecessary(String requestMethod, HttpURLConnection invalidate)
-      throws IOException {
-    if (requestMethod.equals("POST") || requestMethod.equals("PUT")) {
-      invalidate.setDoOutput(true);
-      OutputStream requestBody = invalidate.getOutputStream();
-      requestBody.write('x');
-      requestBody.close();
-    }
-  }
-
-  private void assertNotCached(MockResponse response) throws Exception {
-    server.enqueue(response.setBody("A"));
-    server.enqueue(new MockResponse().setBody("B"));
-
-    URL url = server.getUrl("/");
-    assertEquals("A", readAscii(openConnection(url)));
-    assertEquals("B", readAscii(openConnection(url)));
-  }
-
-  /** @return the request with the conditional get headers. */
-  private RecordedRequest assertConditionallyCached(MockResponse response) throws Exception {
-    // scenario 1: condition succeeds
-    server.enqueue(response.setBody("A").setStatus("HTTP/1.1 200 A-OK"));
-    server.enqueue(new MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED));
-
-    // scenario 2: condition fails
-    server.enqueue(response.setBody("B").setStatus("HTTP/1.1 200 B-OK"));
-    server.enqueue(new MockResponse().setStatus("HTTP/1.1 200 C-OK").setBody("C"));
-
-    URL valid = server.getUrl("/valid");
-    HttpURLConnection connection1 = openConnection(valid);
-    assertEquals("A", readAscii(connection1));
-    assertEquals(HttpURLConnection.HTTP_OK, connection1.getResponseCode());
-    assertEquals("A-OK", connection1.getResponseMessage());
-    HttpURLConnection connection2 = openConnection(valid);
-    assertEquals("A", readAscii(connection2));
-    assertEquals(HttpURLConnection.HTTP_OK, connection2.getResponseCode());
-    assertEquals("A-OK", connection2.getResponseMessage());
-
-    URL invalid = server.getUrl("/invalid");
-    HttpURLConnection connection3 = openConnection(invalid);
-    assertEquals("B", readAscii(connection3));
-    assertEquals(HttpURLConnection.HTTP_OK, connection3.getResponseCode());
-    assertEquals("B-OK", connection3.getResponseMessage());
-    HttpURLConnection connection4 = openConnection(invalid);
-    assertEquals("C", readAscii(connection4));
-    assertEquals(HttpURLConnection.HTTP_OK, connection4.getResponseCode());
-    assertEquals("C-OK", connection4.getResponseMessage());
-
-    server.takeRequest(); // regular get
-    return server.takeRequest(); // conditional get
-  }
-
-  private void assertFullyCached(MockResponse response) throws Exception {
-    server.enqueue(response.setBody("A"));
-    server.enqueue(response.setBody("B"));
-
-    URL url = server.getUrl("/");
-    assertEquals("A", readAscii(openConnection(url)));
-    assertEquals("A", readAscii(openConnection(url)));
-  }
-
-  /**
-   * Shortens the body of {@code response} but not the corresponding headers.
-   * Only useful to test how clients respond to the premature conclusion of
-   * the HTTP body.
-   */
-  private MockResponse truncateViolently(MockResponse response, int numBytesToKeep) {
-    response.setSocketPolicy(DISCONNECT_AT_END);
-    Headers headers = response.getHeaders();
-    Buffer truncatedBody = new Buffer();
-    truncatedBody.write(response.getBody(), numBytesToKeep);
-    response.setBody(truncatedBody);
-    response.setHeaders(headers);
-    return response;
-  }
-
-  /**
-   * Reads {@code count} characters from the stream. If the stream is
-   * exhausted before {@code count} characters can be read, the remaining
-   * characters are returned and the stream is closed.
-   */
-  private String readAscii(URLConnection connection, int count) throws IOException {
-    HttpURLConnection httpConnection = (HttpURLConnection) connection;
-    InputStream in = httpConnection.getResponseCode() < HttpURLConnection.HTTP_BAD_REQUEST
-        ? connection.getInputStream() : httpConnection.getErrorStream();
-    StringBuilder result = new StringBuilder();
-    for (int i = 0; i < count; i++) {
-      int value = in.read();
-      if (value == -1) {
-        in.close();
-        break;
-      }
-      result.append((char) value);
-    }
-    return result.toString();
-  }
-
-  private String readAscii(URLConnection connection) throws IOException {
-    return readAscii(connection, Integer.MAX_VALUE);
-  }
-
-  private void reliableSkip(InputStream in, int length) throws IOException {
-    while (length > 0) {
-      length -= in.skip(length);
-    }
-  }
-
-  private void assertGatewayTimeout(HttpURLConnection connection) throws IOException {
-    try {
-      connection.getInputStream();
-      fail();
-    } catch (FileNotFoundException expected) {
-    }
-    assertEquals(504, connection.getResponseCode());
-    assertEquals(-1, connection.getErrorStream().read());
-  }
-
-  enum TransferKind {
-    CHUNKED() {
-      @Override void setBody(MockResponse response, Buffer content, int chunkSize)
-          throws IOException {
-        response.setChunkedBody(content, chunkSize);
-      }
-    },
-    FIXED_LENGTH() {
-      @Override void setBody(MockResponse response, Buffer content, int chunkSize) {
-        response.setBody(content);
-      }
-    },
-    END_OF_STREAM() {
-      @Override void setBody(MockResponse response, Buffer content, int chunkSize) {
-        response.setBody(content);
-        response.setSocketPolicy(DISCONNECT_AT_END);
-        response.removeHeader("Content-Length");
-      }
-    };
-
-    abstract void setBody(MockResponse response, Buffer content, int chunkSize) throws IOException;
-
-    void setBody(MockResponse response, String content, int chunkSize) throws IOException {
-      setBody(response, new Buffer().writeUtf8(content), chunkSize);
-    }
-  }
-
-  private <T> List<T> toListOrNull(T[] arrayOrNull) {
-    return arrayOrNull != null ? Arrays.asList(arrayOrNull) : null;
-  }
-
-  /** Returns a gzipped copy of {@code bytes}. */
-  public Buffer gzip(String data) throws IOException {
-    Buffer result = new Buffer();
-    BufferedSink sink = Okio.buffer(new GzipSink(result));
-    sink.writeUtf8(data);
-    sink.close();
-    return result;
-  }
-
   private static class InsecureResponseCache extends ResponseCache {
 
     private final ResponseCache delegate;
@@ -1543,205 +1992,204 @@
     }
   }
 
-  /**
-   * A trivial and non-thread-safe implementation of ResponseCache that uses an in-memory map to
-   * cache GETs.
-   */
-  private static class InMemoryResponseCache extends ResponseCache {
+  @Test public void cacheReturnsInsecureResponseForSecureRequest() throws IOException {
+    server.useHttps(sslContext.getSocketFactory(), false);
+    server.enqueue(new MockResponse().setBody("ABC"));
+    server.enqueue(new MockResponse().setBody("DEF"));
 
-    /** A request / response header that acts a bit like Vary but without the complexity. */
-    public static final String CACHE_VARIANT_HEADER = "CacheVariant";
+    AndroidInternal.setResponseCache(new OkUrlFactory(client), new InsecureResponseCache(cache));
 
-    private static class Key {
-      private final URI uri;
-      private final String cacheVariant;
+    HttpsURLConnection connection1 = (HttpsURLConnection) openConnection(server.getUrl("/"));
+    connection1.setSSLSocketFactory(sslContext.getSocketFactory());
+    connection1.setHostnameVerifier(NULL_HOSTNAME_VERIFIER);
+    assertEquals("ABC", readAscii(connection1));
 
-      private Key(URI uri, String cacheVariant) {
-        this.uri = uri;
-        this.cacheVariant = cacheVariant;
-      }
+    // Not cached!
+    HttpsURLConnection connection2 = (HttpsURLConnection) openConnection(server.getUrl("/"));
+    connection2.setSSLSocketFactory(sslContext.getSocketFactory());
+    connection2.setHostnameVerifier(NULL_HOSTNAME_VERIFIER);
+    assertEquals("DEF", readAscii(connection2));
+  }
 
-      @Override
-      public boolean equals(Object o) {
-        if (this == o) {
-          return true;
-        }
-        if (o == null || getClass() != o.getClass()) {
-          return false;
-        }
+  @Test public void responseCacheRequestHeaders() throws IOException, URISyntaxException {
+    server.enqueue(new MockResponse()
+        .setBody("ABC"));
 
-        Key key = (Key) o;
-
-        if (cacheVariant != null ? !cacheVariant.equals(key.cacheVariant)
-            : key.cacheVariant != null) {
-          return false;
-        }
-        if (!uri.equals(key.uri)) {
-          return false;
-        }
-
-        return true;
-      }
-
-      @Override
-      public int hashCode() {
-        int result = uri.hashCode();
-        result = 31 * result + (cacheVariant != null ? cacheVariant.hashCode() : 0);
-        return result;
-      }
-    }
-
-    private class Entry {
-
-      private final URI uri;
-      private final String cacheVariant;
-      private final String method;
-      private final Map<String, List<String>> responseHeaders;
-      private final String cipherSuite;
-      private final Certificate[] serverCertificates;
-      private final Certificate[] localCertificates;
-      private byte[] body;
-
-      public Entry(URI uri, URLConnection urlConnection) {
-        this.uri = uri;
-        HttpURLConnection httpUrlConnection = (HttpURLConnection) urlConnection;
-        method = httpUrlConnection.getRequestMethod();
-        cacheVariant = urlConnection.getHeaderField(CACHE_VARIANT_HEADER);
-        responseHeaders = urlConnection.getHeaderFields();
-        if (urlConnection instanceof HttpsURLConnection) {
-          HttpsURLConnection httpsURLConnection = (HttpsURLConnection) urlConnection;
-          cipherSuite = httpsURLConnection.getCipherSuite();
-          Certificate[] serverCertificates;
-          try {
-            serverCertificates = httpsURLConnection.getServerCertificates();
-          } catch (SSLPeerUnverifiedException e) {
-            serverCertificates = null;
-          }
-          this.serverCertificates = serverCertificates;
-          localCertificates = httpsURLConnection.getLocalCertificates();
-        } else {
-          cipherSuite = null;
-          serverCertificates = null;
-          localCertificates = null;
-        }
-      }
-
-      public CacheResponse asCacheResponse() {
-        if (!method.equals(this.method)) {
-          return null;
-        }
-
-        // Handle SSL
-        if (cipherSuite != null) {
-          return new SecureCacheResponse() {
-            @Override
-            public Map<String, List<String>> getHeaders() throws IOException {
-              return responseHeaders;
-            }
-
-            @Override
-            public InputStream getBody() throws IOException {
-              return new ByteArrayInputStream(body);
-            }
-
-            @Override
-            public String getCipherSuite() {
-              return cipherSuite;
-            }
-
-            @Override
-            public List<Certificate> getLocalCertificateChain() {
-              return localCertificates == null ? null : Arrays.asList(localCertificates);
-            }
-
-            @Override
-            public List<Certificate> getServerCertificateChain() throws SSLPeerUnverifiedException {
-              if (serverCertificates == null) {
-                throw new SSLPeerUnverifiedException("Test implementation");
-              }
-              return Arrays.asList(serverCertificates);
-            }
-
-            @Override
-            public Principal getPeerPrincipal() throws SSLPeerUnverifiedException {
-              throw new UnsupportedOperationException();
-            }
-
-            @Override
-            public Principal getLocalPrincipal() {
-              throw new UnsupportedOperationException();
-            }
-          };
-        } else {
-          return new CacheResponse() {
-            @Override
-            public Map<String, List<String>> getHeaders() throws IOException {
-              return responseHeaders;
-            }
-
-            @Override
-            public InputStream getBody() throws IOException {
-              return new ByteArrayInputStream(body);
-            }
-          };
-        }
-      }
-
-      public CacheRequest asCacheRequest() {
-        return new CacheRequest() {
-          @Override
-          public OutputStream getBody() throws IOException {
-            return new ByteArrayOutputStream() {
-              @Override
-              public void close() throws IOException {
-                super.close();
-                body = toByteArray();
-                cache.put(Entry.this.key(), Entry.this);
-              }
-            };
-          }
-
-          @Override
-          public void abort() {
-            // No-op: close() puts the item in the cache, abort need not do anything.
-          }
-        };
-      }
-
-      private Key key() {
-        return new Key(uri, cacheVariant);
-      }
-    }
-
-    private Map<Key, Entry> cache = new HashMap<Key, Entry>();
-
-    @Override
-    public CacheResponse get(URI uri, String method, Map<String, List<String>> requestHeaders)
-        throws IOException {
-
-      if (!"GET".equals(method)) {
+    final AtomicReference<Map<String, List<String>>> requestHeadersRef = new AtomicReference<>();
+    Internal.instance.setCache(client, new CacheAdapter(new AbstractResponseCache() {
+      @Override public CacheResponse get(URI uri, String requestMethod,
+          Map<String, List<String>> requestHeaders) throws IOException {
+        requestHeadersRef.set(requestHeaders);
         return null;
       }
+    }));
 
-      String cacheVariant =
-          requestHeaders.containsKey(CACHE_VARIANT_HEADER)
-              ? requestHeaders.get(CACHE_VARIANT_HEADER).get(0) : null;
-      Key key = new Key(uri, cacheVariant);
-      Entry entry = cache.get(key);
-      if (entry == null) {
-        return null;
-      }
-      return entry.asCacheResponse();
+    URL url = server.getUrl("/");
+    URLConnection urlConnection = openConnection(url);
+    urlConnection.addRequestProperty("A", "android");
+    readAscii(urlConnection);
+    assertEquals(Arrays.asList("android"), requestHeadersRef.get().get("A"));
+  }
+
+  @Test public void responseCachingWithoutBody() throws IOException {
+    MockResponse response =
+        new MockResponse().addHeader("Last-Modified: " + formatDate(-1, TimeUnit.HOURS))
+            .addHeader("Expires: " + formatDate(1, TimeUnit.HOURS))
+            .setStatus("HTTP/1.1 200 Fantastic");
+    server.enqueue(response);
+
+    HttpURLConnection urlConnection = openConnection(server.getUrl("/"));
+    assertEquals(200, urlConnection.getResponseCode());
+    assertEquals("Fantastic", urlConnection.getResponseMessage());
+    assertTrue(urlConnection.getDoInput());
+    InputStream is = urlConnection.getInputStream();
+    assertEquals(-1, is.read());
+    is.close();
+
+    urlConnection = openConnection(server.getUrl("/")); // cached!
+    assertTrue(urlConnection.getDoInput());
+    InputStream cachedIs = urlConnection.getInputStream();
+    assertEquals(-1, cachedIs.read());
+    cachedIs.close();
+    assertEquals(200, urlConnection.getResponseCode());
+    assertEquals("Fantastic", urlConnection.getResponseMessage());
+  }
+
+  @Test public void useCachesFalseDoesNotWriteToCache() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    URLConnection connection = openConnection(server.getUrl("/"));
+    connection.setUseCaches(false);
+    assertEquals("A", readAscii(connection));
+    assertEquals("B", readAscii(openConnection(server.getUrl("/"))));
+  }
+
+  @Test public void useCachesFalseDoesNotReadFromCache() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    assertEquals("A", readAscii(openConnection(server.getUrl("/"))));
+    URLConnection connection = openConnection(server.getUrl("/"));
+    connection.setUseCaches(false);
+    assertEquals("B", readAscii(connection));
+  }
+
+  @Test public void defaultUseCachesSetsInitialValueOnly() throws Exception {
+    URL url = new URL("http://localhost/");
+    URLConnection c1 = openConnection(url);
+    URLConnection c2 = openConnection(url);
+    assertTrue(c1.getDefaultUseCaches());
+    c1.setDefaultUseCaches(false);
+    try {
+      assertTrue(c1.getUseCaches());
+      assertTrue(c2.getUseCaches());
+      URLConnection c3 = openConnection(url);
+      assertFalse(c3.getUseCaches());
+    } finally {
+      c1.setDefaultUseCaches(true);
     }
+  }
 
-    @Override
-    public CacheRequest put(URI uri, URLConnection urlConnection) throws IOException {
-      if (!"GET".equals(((HttpURLConnection) urlConnection).getRequestMethod())) {
-        return null;
-      }
+  // Other stacks (e.g. older versions of OkHttp bundled inside Android apps) can interact with the
+  // default ResponseCache. We try to keep this case working as much as possible because apps break
+  // if we don't.
+  @Test public void otherStacks_cacheHitWithoutVary() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("FAIL"));
 
-      Entry entry = new Entry(uri, urlConnection);
-      return entry.asCacheRequest();
-    }
+    // Set the cache as the shared cache.
+    ResponseCache.setDefault(cache);
+
+    // Use the platform's HTTP stack.
+    URLConnection connection = server.getUrl("/").openConnection();
+    assertFalse(connection instanceof HttpURLConnectionImpl);
+    assertEquals("A", readAscii(connection));
+
+    URLConnection connection2 = server.getUrl("/").openConnection();
+    assertFalse(connection2 instanceof HttpURLConnectionImpl);
+    assertEquals("A", readAscii(connection2));
+  }
+
+  // Other stacks (e.g. older versions of OkHttp bundled inside Android apps) can interact with the
+  // default ResponseCache. We can't keep the Vary case working because we can't get to the Vary
+  // request headers after connect(). Accept-Encoding has special behavior so we test it explicitly.
+  @Test public void otherStacks_cacheMissWithVaryAcceptEncoding() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Accept-Encoding")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    // Set the cache as the shared cache.
+    ResponseCache.setDefault(cache);
+
+    // Use the platform's HTTP stack.
+    URLConnection connection = server.getUrl("/").openConnection();
+    assertFalse(connection instanceof HttpURLConnectionImpl);
+    assertEquals("A", readAscii(connection));
+
+    URLConnection connection2 = server.getUrl("/").openConnection();
+    assertFalse(connection2 instanceof HttpURLConnectionImpl);
+    assertEquals("B", readAscii(connection2));
+  }
+
+  // Other stacks (e.g. older versions of OkHttp bundled inside Android apps) can interact with the
+  // default ResponseCache. We can't keep the Vary case working because we can't get to the Vary
+  // request headers after connect().
+  @Test public void otherStacks_cacheMissWithVary() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: Accept-Language")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    // Set the cache as the shared cache.
+    ResponseCache.setDefault(cache);
+
+    // Use the platform's HTTP stack.
+    URLConnection connection = server.getUrl("/").openConnection();
+    assertFalse(connection instanceof HttpURLConnectionImpl);
+    connection.setRequestProperty("Accept-Language", "en-US");
+    assertEquals("A", readAscii(connection));
+
+    URLConnection connection2 = server.getUrl("/").openConnection();
+    assertFalse(connection2 instanceof HttpURLConnectionImpl);
+    assertEquals("B", readAscii(connection2));
+  }
+
+  // Other stacks (e.g. older versions of OkHttp bundled inside Android apps) can interact with the
+  // default ResponseCache. We can't keep the Vary case working, because we can't get to the Vary
+  // request headers after connect().
+  @Test public void otherStacks_cacheMissWithVaryAsterisk() throws Exception {
+    server.enqueue(new MockResponse()
+        .addHeader("Cache-Control: max-age=60")
+        .addHeader("Vary: *")
+        .setBody("A"));
+    server.enqueue(new MockResponse()
+        .setBody("B"));
+
+    // Set the cache as the shared cache.
+    ResponseCache.setDefault(cache);
+
+    // Use the platform's HTTP stack.
+    URLConnection connection = server.getUrl("/").openConnection();
+    assertFalse(connection instanceof HttpURLConnectionImpl);
+    assertEquals("A", readAscii(connection));
+
+    URLConnection connection2 = server.getUrl("/").openConnection();
+    assertFalse(connection2 instanceof HttpURLConnectionImpl);
+    assertEquals("B", readAscii(connection2));
   }
 }
diff --git a/okhttp-apache/pom.xml b/okhttp-apache/pom.xml
index 2eafbad..6bc872b 100644
--- a/okhttp-apache/pom.xml
+++ b/okhttp-apache/pom.xml
@@ -19,6 +19,12 @@
       <version>${project.version}</version>
     </dependency>
     <dependency>
+      <groupId>com.squareup.okhttp</groupId>
+      <artifactId>okhttp-testing-support</artifactId>
+      <version>${project.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
       <groupId>org.apache.httpcomponents</groupId>
       <artifactId>httpclient</artifactId>
       <scope>provided</scope>
diff --git a/okhttp-apache/src/main/java/com/squareup/okhttp/apache/OkApacheClient.java b/okhttp-apache/src/main/java/com/squareup/okhttp/apache/OkApacheClient.java
index 602a2c8..d307a33 100644
--- a/okhttp-apache/src/main/java/com/squareup/okhttp/apache/OkApacheClient.java
+++ b/okhttp-apache/src/main/java/com/squareup/okhttp/apache/OkApacheClient.java
@@ -49,7 +49,7 @@
     String contentType = null;
     for (Header header : request.getAllHeaders()) {
       String name = header.getName();
-      if ("Content-Type".equals(name)) {
+      if ("Content-Type".equalsIgnoreCase(name)) {
         contentType = header.getValue();
       } else {
         builder.header(name, header.getValue());
diff --git a/okhttp-apache/src/test/java/com/squareup/okhttp/apache/OkApacheClientTest.java b/okhttp-apache/src/test/java/com/squareup/okhttp/apache/OkApacheClientTest.java
index ca47c01..fd66fda 100644
--- a/okhttp-apache/src/test/java/com/squareup/okhttp/apache/OkApacheClientTest.java
+++ b/okhttp-apache/src/test/java/com/squareup/okhttp/apache/OkApacheClientTest.java
@@ -6,6 +6,7 @@
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.net.URISyntaxException;
 import java.util.zip.GZIPInputStream;
 import okio.Buffer;
 import okio.GzipSink;
@@ -156,6 +157,15 @@
     assertNull(response3.getEntity().getContentType());
   }
 
+  @Test public void contentTypeIsCaseInsensitive() throws URISyntaxException, IOException {
+    server.enqueue(new MockResponse().setBody("{\"Message\": { \"text\": \"Hello, World!\" } }")
+        .setHeader("cONTENT-tYPE", "application/json"));
+
+    HttpGet request = new HttpGet(server.getUrl("/").toURI());
+    HttpResponse response = client.execute(request);
+    assertEquals("application/json", response.getEntity().getContentType().getValue());
+  }
+
   @Test public void contentEncoding() throws Exception {
     String text = "{\"Message\": { \"text\": \"Hello, World!\" } }";
     server.enqueue(new MockResponse().setBody(gzip(text))
diff --git a/okhttp-hpacktests/pom.xml b/okhttp-hpacktests/pom.xml
index 6ae44d5..4d299fe 100644
--- a/okhttp-hpacktests/pom.xml
+++ b/okhttp-hpacktests/pom.xml
@@ -6,7 +6,7 @@
   <parent>
     <groupId>com.squareup.okhttp</groupId>
     <artifactId>parent</artifactId>
-    <version>2.3.0-SNAPSHOT</version>
+    <version>2.4.0-SNAPSHOT</version>
   </parent>
 
   <artifactId>okhttp-hpacktests</artifactId>
@@ -23,6 +23,12 @@
       <version>${project.version}</version>
     </dependency>
     <dependency>
+      <groupId>com.squareup.okhttp</groupId>
+      <artifactId>okhttp-testing-support</artifactId>
+      <version>${project.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
       <groupId>junit</groupId>
       <artifactId>junit</artifactId>
       <scope>test</scope>
diff --git a/okhttp-testing-support/pom.xml b/okhttp-testing-support/pom.xml
new file mode 100644
index 0000000..ad016c8
--- /dev/null
+++ b/okhttp-testing-support/pom.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>com.squareup.okhttp</groupId>
+    <artifactId>parent</artifactId>
+    <version>2.4.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>okhttp-testing-support</artifactId>
+  <name>OkHttp test support classes</name>
+
+  <dependencies>
+    <dependency>
+      <groupId>junit</groupId>
+      <artifactId>junit</artifactId>
+      <optional>true</optional>
+    </dependency>
+  </dependencies>
+</project>
diff --git a/okhttp-testing-support/src/main/java/com/squareup/okhttp/testing/InstallUncaughtExceptionHandlerListener.java b/okhttp-testing-support/src/main/java/com/squareup/okhttp/testing/InstallUncaughtExceptionHandlerListener.java
new file mode 100644
index 0000000..4dd4c92
--- /dev/null
+++ b/okhttp-testing-support/src/main/java/com/squareup/okhttp/testing/InstallUncaughtExceptionHandlerListener.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp.testing;
+
+import org.junit.runner.Description;
+import org.junit.runner.Result;
+import org.junit.runner.notification.RunListener;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+/**
+ * A {@link org.junit.runner.notification.RunListener} used to install an aggressive default
+ * {@link java.lang.Thread.UncaughtExceptionHandler} similar to the one found on Android.
+ * No exceptions should escape from OkHttp that might cause apps to be killed or tests to fail on
+ * Android.
+ */
+public class InstallUncaughtExceptionHandlerListener extends RunListener {
+
+  private Thread.UncaughtExceptionHandler oldDefaultUncaughtExceptionHandler;
+  private Description lastTestStarted;
+
+  @Override public void testRunStarted(Description description) throws Exception {
+    System.err.println("Installing aggressive uncaught exception handler");
+    oldDefaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
+    Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
+      @Override public void uncaughtException(Thread thread, Throwable throwable) {
+        StringWriter errorText = new StringWriter(256);
+        errorText.append("Uncaught exception in OkHttp thread \"");
+        errorText.append(thread.getName());
+        errorText.append("\"\n");
+        throwable.printStackTrace(new PrintWriter(errorText));
+        errorText.append("\n");
+        if (lastTestStarted != null) {
+          errorText.append("Last test to start was: ");
+          errorText.append(lastTestStarted.getDisplayName());
+          errorText.append("\n");
+        }
+        System.err.print(errorText.toString());
+        System.exit(-1);
+      }
+    });
+  }
+
+  @Override public void testStarted(Description description) throws Exception {
+    lastTestStarted = description;
+  }
+
+  @Override public void testRunFinished(Result result) throws Exception {
+    Thread.setDefaultUncaughtExceptionHandler(oldDefaultUncaughtExceptionHandler);
+    System.err.println("Uninstalled aggressive uncaught exception handler");
+  }
+}
diff --git a/okhttp-tests/pom.xml b/okhttp-tests/pom.xml
index e3a9966..bcf1268 100644
--- a/okhttp-tests/pom.xml
+++ b/okhttp-tests/pom.xml
@@ -24,6 +24,12 @@
     </dependency>
     <dependency>
       <groupId>com.squareup.okhttp</groupId>
+      <artifactId>okhttp-testing-support</artifactId>
+      <version>${project.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.squareup.okhttp</groupId>
       <artifactId>okhttp-urlconnection</artifactId>
       <version>${project.version}</version>
     </dependency>
@@ -39,6 +45,11 @@
       <version>${project.version}</version>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>com.google.code.gson</groupId>
+      <artifactId>gson</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/CallTest.java b/okhttp-tests/src/test/java/com/squareup/okhttp/CallTest.java
index 19003e2..93f2b5b 100644
--- a/okhttp-tests/src/test/java/com/squareup/okhttp/CallTest.java
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/CallTest.java
@@ -38,7 +38,6 @@
 import java.security.cert.Certificate;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.Callable;
@@ -74,6 +73,7 @@
 import static java.net.CookiePolicy.ACCEPT_ORIGINAL_SERVER;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNotSame;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
@@ -259,7 +259,7 @@
 
     Request request = new Request.Builder()
         .url(server.getUrl("/"))
-        .method("POST", null)
+        .method("POST", RequestBody.create(null, new byte[0]))
         .build();
 
     executeSynchronously(request)
@@ -681,6 +681,7 @@
     }
   }
 
+  // https://github.com/square/okhttp/issues/442
   @Test public void timeoutsNotRetried() throws Exception {
     server.enqueue(new MockResponse()
         .setSocketPolicy(SocketPolicy.NO_RESPONSE));
@@ -851,7 +852,7 @@
       client.newCall(request).execute();
       fail();
     } catch (UnknownServiceException expected) {
-      assertTrue(expected.getMessage().contains("no connection specs"));
+      assertTrue(expected.getMessage().contains("CLEARTEXT communication not supported"));
     }
   }
 
@@ -1553,6 +1554,25 @@
     canceledAfterResponseIsDeliveredBreaksStreamButSignalsOnce();
   }
 
+  @Test public void cancelWithInterceptor() throws Exception {
+    client.interceptors().add(new Interceptor() {
+      @Override public Response intercept(Chain chain) throws IOException {
+        chain.proceed(chain.request());
+        throw new AssertionError(); // We expect an exception.
+      }
+    });
+
+    Call call = client.newCall(new Request.Builder().url(server.getUrl("/a")).build());
+    call.cancel();
+
+    try {
+      call.execute();
+      fail();
+    } catch (IOException expected) {
+    }
+    assertEquals(0, server.getRequestCount());
+  }
+
   @Test public void gzip() throws Exception {
     Buffer gzippedBody = gzip("abcabcabc");
     String bodySize = Long.toString(gzippedBody.size());
@@ -1699,21 +1719,6 @@
     return result;
   }
 
-  private void assertContains(Collection<String> collection, String element) {
-    for (String c : collection) {
-      if (c != null && c.equalsIgnoreCase(element)) return;
-    }
-    fail("No " + element + " in " + collection);
-  }
-
-  private void assertContainsNoneMatching(List<String> headers, String pattern) {
-    for (String header : headers) {
-      if (header.matches(pattern)) {
-        fail("Header " + header + " matches " + pattern);
-      }
-    }
-  }
-
   private static class RecordingSSLSocketFactory extends DelegatingSSLSocketFactory {
 
     private List<SSLSocket> socketsCreated = new ArrayList<SSLSocket>();
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/ConnectionPoolTest.java b/okhttp-tests/src/test/java/com/squareup/okhttp/ConnectionPoolTest.java
index ebeb698..4e8ec7a 100644
--- a/okhttp-tests/src/test/java/com/squareup/okhttp/ConnectionPoolTest.java
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/ConnectionPoolTest.java
@@ -15,6 +15,7 @@
  */
 package com.squareup.okhttp;
 
+import com.squareup.okhttp.internal.Internal;
 import com.squareup.okhttp.internal.RecordingHostnameVerifier;
 import com.squareup.okhttp.internal.SslContextBuilder;
 import com.squareup.okhttp.internal.Util;
@@ -43,6 +44,13 @@
 import static org.junit.Assert.fail;
 
 public final class ConnectionPoolTest {
+  static {
+    Internal.initializeInstanceForTests();
+  }
+
+  private static final List<ConnectionSpec> CONNECTION_SPECS = Util.immutableList(
+      ConnectionSpec.MODERN_TLS, ConnectionSpec.CLEARTEXT);
+
   private static final int KEEP_ALIVE_DURATION_MS = 5000;
   private static final SSLContext sslContext = SslContextBuilder.localhost();
 
@@ -77,13 +85,10 @@
     httpServer = new MockWebServer();
     spdyServer.useHttps(sslContext.getSocketFactory(), false);
 
-    List<ConnectionSpec> connectionSpecs = Util.immutableList(
-        ConnectionSpec.MODERN_TLS, ConnectionSpec.CLEARTEXT);
-
     httpServer.start();
     httpAddress = new Address(httpServer.getHostName(), httpServer.getPort(), socketFactory, null,
         null, null, AuthenticatorAdapter.INSTANCE, null,
-        Util.immutableList(Protocol.SPDY_3, Protocol.HTTP_1_1), connectionSpecs, proxySelector);
+        Util.immutableList(Protocol.SPDY_3, Protocol.HTTP_1_1), CONNECTION_SPECS, proxySelector);
     httpSocketAddress = new InetSocketAddress(InetAddress.getByName(httpServer.getHostName()),
         httpServer.getPort());
 
@@ -91,30 +96,28 @@
     spdyAddress = new Address(spdyServer.getHostName(), spdyServer.getPort(), socketFactory,
         sslContext.getSocketFactory(), new RecordingHostnameVerifier(), CertificatePinner.DEFAULT,
         AuthenticatorAdapter.INSTANCE, null, Util.immutableList(Protocol.SPDY_3, Protocol.HTTP_1_1),
-        connectionSpecs, proxySelector);
+        CONNECTION_SPECS, proxySelector);
     spdySocketAddress = new InetSocketAddress(InetAddress.getByName(spdyServer.getHostName()),
         spdyServer.getPort());
 
-    Route httpRoute = new Route(httpAddress, Proxy.NO_PROXY, httpSocketAddress,
-        ConnectionSpec.CLEARTEXT);
-    Route spdyRoute = new Route(spdyAddress, Proxy.NO_PROXY, spdySocketAddress,
-        ConnectionSpec.MODERN_TLS);
+    Route httpRoute = new Route(httpAddress, Proxy.NO_PROXY, httpSocketAddress);
+    Route spdyRoute = new Route(spdyAddress, Proxy.NO_PROXY, spdySocketAddress);
     pool = new ConnectionPool(poolSize, KEEP_ALIVE_DURATION_MS);
     // Disable the automatic execution of the cleanup.
     cleanupExecutor = new FakeExecutor();
     pool.replaceCleanupExecutorForTests(cleanupExecutor);
     httpA = new Connection(pool, httpRoute);
-    httpA.connect(200, 200, 200, null);
+    httpA.connect(200, 200, 200, null, CONNECTION_SPECS, false /* connectionRetryEnabled */);
     httpB = new Connection(pool, httpRoute);
-    httpB.connect(200, 200, 200, null);
+    httpB.connect(200, 200, 200, null, CONNECTION_SPECS, false /* connectionRetryEnabled */);
     httpC = new Connection(pool, httpRoute);
-    httpC.connect(200, 200, 200, null);
+    httpC.connect(200, 200, 200, null, CONNECTION_SPECS, false /* connectionRetryEnabled */);
     httpD = new Connection(pool, httpRoute);
-    httpD.connect(200, 200, 200, null);
+    httpD.connect(200, 200, 200, null, CONNECTION_SPECS, false /* connectionRetryEnabled */);
     httpE = new Connection(pool, httpRoute);
-    httpE.connect(200, 200, 200, null);
+    httpE.connect(200, 200, 200, null, CONNECTION_SPECS, false /* connectionRetryEnabled */);
     spdyA = new Connection(pool, spdyRoute);
-    spdyA.connect(20000, 20000, 2000, null);
+    spdyA.connect(20000, 20000, 2000, null, CONNECTION_SPECS, false /* connectionRetryEnabled */);
 
     owner = new Object();
     httpA.setOwner(owner);
@@ -146,9 +149,8 @@
     Connection connection = pool.get(httpAddress);
     assertNull(connection);
 
-    connection = new Connection(pool, new Route(httpAddress, Proxy.NO_PROXY, httpSocketAddress,
-        ConnectionSpec.CLEARTEXT));
-    connection.connect(200, 200, 200, null);
+    connection = new Connection(pool, new Route(httpAddress, Proxy.NO_PROXY, httpSocketAddress));
+    connection.connect(200, 200, 200, null, CONNECTION_SPECS, false /* connectionRetryEnabled */);
     connection.setOwner(owner);
     assertEquals(0, pool.getConnectionCount());
 
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/ConnectionSpecTest.java b/okhttp-tests/src/test/java/com/squareup/okhttp/ConnectionSpecTest.java
index 2267c2a..7833cca 100644
--- a/okhttp-tests/src/test/java/com/squareup/okhttp/ConnectionSpecTest.java
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/ConnectionSpecTest.java
@@ -15,13 +15,8 @@
  */
 package com.squareup.okhttp;
 
-import com.squareup.okhttp.internal.http.AuthenticatorAdapter;
-
 import org.junit.Test;
 
-import java.net.InetSocketAddress;
-import java.net.Proxy;
-import java.net.ProxySelector;
 import java.util.Arrays;
 import java.util.LinkedHashSet;
 import java.util.Set;
@@ -35,14 +30,6 @@
 
 public final class ConnectionSpecTest {
 
-  private static final Proxy PROXY = Proxy.NO_PROXY;
-  private static final InetSocketAddress INET_SOCKET_ADDRESS =
-      InetSocketAddress.createUnresolved("host", 443);
-  private static final Address HTTPS_ADDRESS = new Address(
-      INET_SOCKET_ADDRESS.getHostString(), INET_SOCKET_ADDRESS.getPort(), null, null, null, null,
-      AuthenticatorAdapter.INSTANCE, PROXY, Arrays.asList(Protocol.HTTP_1_1),
-      Arrays.asList(ConnectionSpec.MODERN_TLS), ProxySelector.getDefault());
-
   @Test
   public void cleartextBuilder() throws Exception {
     ConnectionSpec cleartextSpec = new ConnectionSpec.Builder(false).build();
@@ -89,9 +76,8 @@
         TlsVersion.TLS_1_1.javaName,
     });
 
-    Route route = new Route(HTTPS_ADDRESS, PROXY, INET_SOCKET_ADDRESS, tlsSpec,
-        false /* shouldSendTlsFallbackIndicator */);
-    tlsSpec.apply(socket, route);
+    assertTrue(tlsSpec.isCompatible(socket));
+    tlsSpec.apply(socket, false /* isFallback */);
 
     assertEquals(createSet(TlsVersion.TLS_1_2.javaName), createSet(socket.getEnabledProtocols()));
 
@@ -119,9 +105,8 @@
         TlsVersion.TLS_1_1.javaName,
     });
 
-    Route route = new Route(HTTPS_ADDRESS, PROXY, INET_SOCKET_ADDRESS, tlsSpec,
-        true /* shouldSendTlsFallbackIndicator */);
-    tlsSpec.apply(socket, route);
+    assertTrue(tlsSpec.isCompatible(socket));
+    tlsSpec.apply(socket, true /* isFallback */);
 
     assertEquals(createSet(TlsVersion.TLS_1_2.javaName), createSet(socket.getEnabledProtocols()));
 
@@ -153,9 +138,8 @@
         TlsVersion.TLS_1_1.javaName,
     });
 
-    Route route = new Route(HTTPS_ADDRESS, PROXY, INET_SOCKET_ADDRESS, tlsSpec,
-        true /* shouldSendTlsFallbackIndicator */);
-    tlsSpec.apply(socket, route);
+    assertTrue(tlsSpec.isCompatible(socket));
+    tlsSpec.apply(socket, true /* isFallback */);
 
     assertEquals(createSet(TlsVersion.TLS_1_2.javaName), createSet(socket.getEnabledProtocols()));
 
@@ -176,6 +160,52 @@
         .build();
   }
 
+  public void tls_missingRequiredCipher() throws Exception {
+    ConnectionSpec tlsSpec = new ConnectionSpec.Builder(true)
+        .cipherSuites(CipherSuite.TLS_RSA_WITH_RC4_128_MD5)
+        .tlsVersions(TlsVersion.TLS_1_2)
+        .supportsTlsExtensions(false)
+        .build();
+
+    SSLSocket socket = (SSLSocket) SSLSocketFactory.getDefault().createSocket();
+    socket.setEnabledProtocols(new String[] {
+        TlsVersion.TLS_1_2.javaName,
+        TlsVersion.TLS_1_1.javaName,
+    });
+
+    socket.setEnabledCipherSuites(new String[] {
+        CipherSuite.TLS_RSA_WITH_RC4_128_SHA.javaName,
+        CipherSuite.TLS_RSA_WITH_RC4_128_MD5.javaName,
+    });
+    assertTrue(tlsSpec.isCompatible(socket));
+
+    socket.setEnabledCipherSuites(new String[] {
+        CipherSuite.TLS_RSA_WITH_RC4_128_SHA.javaName,
+    });
+    assertFalse(tlsSpec.isCompatible(socket));
+  }
+
+  @Test
+  public void tls_missingTlsVersion() throws Exception {
+    ConnectionSpec tlsSpec = new ConnectionSpec.Builder(true)
+        .cipherSuites(CipherSuite.TLS_RSA_WITH_RC4_128_MD5)
+        .tlsVersions(TlsVersion.TLS_1_2)
+        .supportsTlsExtensions(false)
+        .build();
+
+    SSLSocket socket = (SSLSocket) SSLSocketFactory.getDefault().createSocket();
+    socket.setEnabledCipherSuites(new String[] {
+        CipherSuite.TLS_RSA_WITH_RC4_128_MD5.javaName,
+    });
+
+    socket.setEnabledProtocols(
+        new String[] { TlsVersion.TLS_1_2.javaName, TlsVersion.TLS_1_1.javaName });
+    assertTrue(tlsSpec.isCompatible(socket));
+
+    socket.setEnabledProtocols(new String[] { TlsVersion.TLS_1_1.javaName });
+    assertFalse(tlsSpec.isCompatible(socket));
+  }
+
   private static Set<String> createSet(String... values) {
     return new LinkedHashSet<String>(Arrays.asList(values));
   }
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/HttpUrlTest.java b/okhttp-tests/src/test/java/com/squareup/okhttp/HttpUrlTest.java
new file mode 100644
index 0000000..4dd7f83
--- /dev/null
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/HttpUrlTest.java
@@ -0,0 +1,365 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp;
+
+import com.squareup.okhttp.UrlComponentEncodingTester.Component;
+import com.squareup.okhttp.UrlComponentEncodingTester.Encoding;
+import java.util.Arrays;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public final class HttpUrlTest {
+  @Test public void parseTrimsAsciiWhitespace() throws Exception {
+    HttpUrl expected = HttpUrl.parse("http://host/");
+    assertEquals(expected, HttpUrl.parse("http://host/\f\n\t \r")); // Leading.
+    assertEquals(expected, HttpUrl.parse("\r\n\f \thttp://host/")); // Trailing.
+    assertEquals(expected, HttpUrl.parse(" http://host/ ")); // Both.
+    assertEquals(expected, HttpUrl.parse("    http://host/    ")); // Both.
+    assertEquals(expected, HttpUrl.parse("http://host/").resolve("   "));
+    assertEquals(expected, HttpUrl.parse("http://host/").resolve("  .  "));
+  }
+
+  @Test public void parseDoesNotTrimOtherWhitespaceCharacters() throws Exception {
+    // Whitespace characters list from Google's Guava team: http://goo.gl/IcR9RD
+    assertEquals("/%0B", HttpUrl.parse("http://h/\u000b").path()); // line tabulation
+    assertEquals("/%1C", HttpUrl.parse("http://h/\u001c").path()); // information separator 4
+    assertEquals("/%1D", HttpUrl.parse("http://h/\u001d").path()); // information separator 3
+    assertEquals("/%1E", HttpUrl.parse("http://h/\u001e").path()); // information separator 2
+    assertEquals("/%1F", HttpUrl.parse("http://h/\u001f").path()); // information separator 1
+    assertEquals("/%C2%85", HttpUrl.parse("http://h/\u0085").path()); // next line
+    assertEquals("/%C2%A0", HttpUrl.parse("http://h/\u00a0").path()); // non-breaking space
+    assertEquals("/%E1%9A%80", HttpUrl.parse("http://h/\u1680").path()); // ogham space mark
+    assertEquals("/%E1%A0%8E", HttpUrl.parse("http://h/\u180e").path()); // mongolian vowel separator
+    assertEquals("/%E2%80%80", HttpUrl.parse("http://h/\u2000").path()); // en quad
+    assertEquals("/%E2%80%81", HttpUrl.parse("http://h/\u2001").path()); // em quad
+    assertEquals("/%E2%80%82", HttpUrl.parse("http://h/\u2002").path()); // en space
+    assertEquals("/%E2%80%83", HttpUrl.parse("http://h/\u2003").path()); // em space
+    assertEquals("/%E2%80%84", HttpUrl.parse("http://h/\u2004").path()); // three-per-em space
+    assertEquals("/%E2%80%85", HttpUrl.parse("http://h/\u2005").path()); // four-per-em space
+    assertEquals("/%E2%80%86", HttpUrl.parse("http://h/\u2006").path()); // six-per-em space
+    assertEquals("/%E2%80%87", HttpUrl.parse("http://h/\u2007").path()); // figure space
+    assertEquals("/%E2%80%88", HttpUrl.parse("http://h/\u2008").path()); // punctuation space
+    assertEquals("/%E2%80%89", HttpUrl.parse("http://h/\u2009").path()); // thin space
+    assertEquals("/%E2%80%8A", HttpUrl.parse("http://h/\u200a").path()); // hair space
+    assertEquals("/%E2%80%8B", HttpUrl.parse("http://h/\u200b").path()); // zero-width space
+    assertEquals("/%E2%80%8C", HttpUrl.parse("http://h/\u200c").path()); // zero-width non-joiner
+    assertEquals("/%E2%80%8D", HttpUrl.parse("http://h/\u200d").path()); // zero-width joiner
+    assertEquals("/%E2%80%8E", HttpUrl.parse("http://h/\u200e").path()); // left-to-right mark
+    assertEquals("/%E2%80%8F", HttpUrl.parse("http://h/\u200f").path()); // right-to-left mark
+    assertEquals("/%E2%80%A8", HttpUrl.parse("http://h/\u2028").path()); // line separator
+    assertEquals("/%E2%80%A9", HttpUrl.parse("http://h/\u2029").path()); // paragraph separator
+    assertEquals("/%E2%80%AF", HttpUrl.parse("http://h/\u202f").path()); // narrow non-breaking space
+    assertEquals("/%E2%81%9F", HttpUrl.parse("http://h/\u205f").path()); // medium mathematical space
+    assertEquals("/%E3%80%80", HttpUrl.parse("http://h/\u3000").path()); // ideographic space
+  }
+
+  @Test public void scheme() throws Exception {
+    assertEquals(HttpUrl.parse("http://host/"), HttpUrl.parse("http://host/"));
+    assertEquals(HttpUrl.parse("http://host/"), HttpUrl.parse("Http://host/"));
+    assertEquals(HttpUrl.parse("http://host/"), HttpUrl.parse("http://host/"));
+    assertEquals(HttpUrl.parse("http://host/"), HttpUrl.parse("HTTP://host/"));
+    assertEquals(HttpUrl.parse("https://host/"), HttpUrl.parse("https://host/"));
+    assertEquals(HttpUrl.parse("https://host/"), HttpUrl.parse("HTTPS://host/"));
+    assertEquals(null, HttpUrl.parse("httpp://host/"));
+    assertEquals(null, HttpUrl.parse("0ttp://host/"));
+    assertEquals(null, HttpUrl.parse("ht+tp://host/"));
+    assertEquals(null, HttpUrl.parse("ht.tp://host/"));
+    assertEquals(null, HttpUrl.parse("ht-tp://host/"));
+    assertEquals(null, HttpUrl.parse("ht1tp://host/"));
+    assertEquals(null, HttpUrl.parse("httpss://host/"));
+  }
+
+  @Test public void parseNoScheme() throws Exception {
+    assertEquals(null, HttpUrl.parse("//host"));
+    assertEquals(null, HttpUrl.parse("/path"));
+    assertEquals(null, HttpUrl.parse("path"));
+    assertEquals(null, HttpUrl.parse("?query"));
+    assertEquals(null, HttpUrl.parse("#fragment"));
+  }
+
+  @Test public void resolveNoScheme() throws Exception {
+    HttpUrl base = HttpUrl.parse("http://host/a/b");
+    assertEquals(HttpUrl.parse("http://host2/"), base.resolve("//host2"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("/path"));
+    assertEquals(HttpUrl.parse("http://host/a/path"), base.resolve("path"));
+    assertEquals(HttpUrl.parse("http://host/a/b?query"), base.resolve("?query"));
+    assertEquals(HttpUrl.parse("http://host/a/b#fragment"), base.resolve("#fragment"));
+    assertEquals(HttpUrl.parse("http://host/a/b"), base.resolve(""));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("\\path"));
+  }
+
+  @Test public void resolveUnsupportedScheme() throws Exception {
+    HttpUrl base = HttpUrl.parse("http://a/");
+    assertEquals(null, base.resolve("ftp://b"));
+    assertEquals(null, base.resolve("ht+tp://b"));
+    assertEquals(null, base.resolve("ht-tp://b"));
+    assertEquals(null, base.resolve("ht.tp://b"));
+  }
+
+  @Test public void resolveSchemeLikePath() throws Exception {
+    HttpUrl base = HttpUrl.parse("http://a/");
+    assertEquals(HttpUrl.parse("http://a/http//b/"), base.resolve("http//b/"));
+    assertEquals(HttpUrl.parse("http://a/ht+tp//b/"), base.resolve("ht+tp//b/"));
+    assertEquals(HttpUrl.parse("http://a/ht-tp//b/"), base.resolve("ht-tp//b/"));
+    assertEquals(HttpUrl.parse("http://a/ht.tp//b/"), base.resolve("ht.tp//b/"));
+  }
+
+  @Test public void parseAuthoritySlashCountDoesntMatter() throws Exception {
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http://host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:\\/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:/\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:\\\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:///host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:\\//host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:/\\/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http://\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:\\\\/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:/\\\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:\\\\\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http:////host/path"));
+  }
+
+  @Test public void resolveAuthoritySlashCountDoesntMatterWithDifferentScheme() throws Exception {
+    HttpUrl base = HttpUrl.parse("https://a/b/c");
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http://host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:/\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:///host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\//host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:/\\/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http://\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\\\/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:/\\\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\\\\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:////host/path"));
+  }
+
+  @Test public void resolveAuthoritySlashCountMattersWithSameScheme() throws Exception {
+    HttpUrl base = HttpUrl.parse("http://a/b/c");
+    assertEquals(HttpUrl.parse("http://a/b/host/path"), base.resolve("http:host/path"));
+    assertEquals(HttpUrl.parse("http://a/host/path"), base.resolve("http:/host/path"));
+    assertEquals(HttpUrl.parse("http://a/host/path"), base.resolve("http:\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http://host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:/\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:///host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\//host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:/\\/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http://\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\\\/host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:/\\\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:\\\\\\host/path"));
+    assertEquals(HttpUrl.parse("http://host/path"), base.resolve("http:////host/path"));
+  }
+
+  @Test public void username() throws Exception {
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http://@host/path"));
+    assertEquals(HttpUrl.parse("http://user@host/path"), HttpUrl.parse("http://user@host/path"));
+  }
+
+  @Test public void authorityWithMultipleAtSigns() throws Exception {
+    assertEquals(HttpUrl.parse("http://foo%40bar@baz/path"),
+        HttpUrl.parse("http://foo@bar@baz/path"));
+    assertEquals(HttpUrl.parse("http://foo:pass1%40bar%3Apass2@baz/path"),
+        HttpUrl.parse("http://foo:pass1@bar:pass2@baz/path"));
+  }
+
+  @Test public void usernameAndPassword() throws Exception {
+    assertEquals(HttpUrl.parse("http://username:password@host/path"),
+        HttpUrl.parse("http://username:password@host/path"));
+    assertEquals(HttpUrl.parse("http://username@host/path"),
+        HttpUrl.parse("http://username:@host/path"));
+  }
+
+  @Test public void passwordWithEmptyUsername() throws Exception {
+    // Chrome doesn't mind, but Firefox rejects URLs with empty usernames and non-empty passwords.
+    assertEquals(HttpUrl.parse("http://host/path"), HttpUrl.parse("http://:@host/path"));
+    assertEquals("password%40", HttpUrl.parse("http://:password@@host/path").password());
+  }
+
+  @Test public void unprintableCharactersArePercentEncoded() throws Exception {
+    assertEquals("/%00", HttpUrl.parse("http://host/\u0000").path());
+    assertEquals("/%08", HttpUrl.parse("http://host/\u0008").path());
+    assertEquals("/%EF%BF%BD", HttpUrl.parse("http://host/\ufffd").path());
+  }
+
+  @Test public void usernameCharacters() throws Exception {
+    new UrlComponentEncodingTester()
+        .override(Encoding.PERCENT, '[', ']', '{', '}', '|', '^', '\'', ';', '=', '@')
+        .override(Encoding.SKIP, ':', '/', '\\', '?', '#')
+        .test(Component.USER);
+  }
+
+  @Test public void passwordCharacters() throws Exception {
+    new UrlComponentEncodingTester()
+        .override(Encoding.PERCENT, '[', ']', '{', '}', '|', '^', '\'', ':', ';', '=', '@')
+        .override(Encoding.SKIP, '/', '\\', '?', '#')
+        .test(Component.PASSWORD);
+  }
+
+  @Test public void hostContainsIllegalCharacter() throws Exception {
+    assertEquals(null, HttpUrl.parse("http://\n/"));
+    assertEquals(null, HttpUrl.parse("http:// /"));
+    assertEquals(null, HttpUrl.parse("http://%20/"));
+  }
+
+  @Test public void hostIpv6() throws Exception {
+    // Square braces are absent from host()...
+    assertEquals("::1", HttpUrl.parse("http://[::1]/").host());
+
+    // ... but they're included in toString().
+    assertEquals("http://[::1]/", HttpUrl.parse("http://[::1]/").toString());
+
+    // IPv6 colons don't interfere with port numbers or passwords.
+    assertEquals(8080, HttpUrl.parse("http://[::1]:8080/").port());
+    assertEquals("password", HttpUrl.parse("http://user:password@[::1]/").password());
+    assertEquals("::1", HttpUrl.parse("http://user:password@[::1]:8080/").host());
+
+    // Permit the contents of IPv6 addresses to be percent-encoded...
+    assertEquals("::1", HttpUrl.parse("http://[%3A%3A%31]/").host());
+
+    // Including the Square braces themselves! (This is what Chrome does.)
+    assertEquals("::1", HttpUrl.parse("http://%5B%3A%3A1%5D/").host());
+  }
+
+  @Test public void port() throws Exception {
+    assertEquals(HttpUrl.parse("http://host/"), HttpUrl.parse("http://host:80/"));
+    assertEquals(HttpUrl.parse("http://host:99/"), HttpUrl.parse("http://host:99/"));
+    assertEquals(65535, HttpUrl.parse("http://host:65535/").port());
+    assertEquals(null, HttpUrl.parse("http://host:0/"));
+    assertEquals(null, HttpUrl.parse("http://host:65536/"));
+    assertEquals(null, HttpUrl.parse("http://host:-1/"));
+    assertEquals(null, HttpUrl.parse("http://host:a/"));
+    assertEquals(null, HttpUrl.parse("http://host:%39%39/"));
+  }
+
+  @Test public void pathCharacters() throws Exception {
+    new UrlComponentEncodingTester()
+        .override(Encoding.PERCENT, '^', '{', '}', '|')
+        .override(Encoding.SKIP, '\\', '?', '#')
+        .test(Component.PATH);
+  }
+
+  @Test public void queryCharacters() throws Exception {
+    new UrlComponentEncodingTester()
+        .override(Encoding.IDENTITY, '?', '`')
+        .override(Encoding.PERCENT, '\'')
+        .override(Encoding.SKIP, '#')
+        .test(Component.QUERY);
+  }
+
+  @Test public void fragmentCharacters() throws Exception {
+    new UrlComponentEncodingTester()
+        .override(Encoding.IDENTITY, ' ', '"', '#', '<', '>', '?', '`')
+        .test(Component.FRAGMENT);
+    // TODO(jwilson): don't percent-encode non-ASCII characters. (But do encode control characters!)
+  }
+
+  @Test public void relativePath() throws Exception {
+    HttpUrl base = HttpUrl.parse("http://host/a/b/c");
+    assertEquals(HttpUrl.parse("http://host/a/b/d/e/f"), base.resolve("d/e/f"));
+    assertEquals(HttpUrl.parse("http://host/d/e/f"), base.resolve("../../d/e/f"));
+    assertEquals(HttpUrl.parse("http://host/a/"), base.resolve(".."));
+    assertEquals(HttpUrl.parse("http://host/"), base.resolve("../.."));
+    assertEquals(HttpUrl.parse("http://host/"), base.resolve("../../.."));
+    assertEquals(HttpUrl.parse("http://host/a/b/"), base.resolve("."));
+    assertEquals(HttpUrl.parse("http://host/a/"), base.resolve("././.."));
+    assertEquals(HttpUrl.parse("http://host/a/b/c/"), base.resolve("c/d/../e/../"));
+    assertEquals(HttpUrl.parse("http://host/a/b/..e/"), base.resolve("..e/"));
+    assertEquals(HttpUrl.parse("http://host/a/b/e/f../"), base.resolve("e/f../"));
+    assertEquals(HttpUrl.parse("http://host/a/"), base.resolve("%2E."));
+    assertEquals(HttpUrl.parse("http://host/a/"), base.resolve(".%2E"));
+    assertEquals(HttpUrl.parse("http://host/a/"), base.resolve("%2E%2E"));
+    assertEquals(HttpUrl.parse("http://host/a/"), base.resolve("%2e."));
+    assertEquals(HttpUrl.parse("http://host/a/"), base.resolve(".%2e"));
+    assertEquals(HttpUrl.parse("http://host/a/"), base.resolve("%2e%2e"));
+    assertEquals(HttpUrl.parse("http://host/a/b/"), base.resolve("%2E"));
+    assertEquals(HttpUrl.parse("http://host/a/b/"), base.resolve("%2e"));
+  }
+
+  @Test public void pathWithBackslash() throws Exception {
+    HttpUrl base = HttpUrl.parse("http://host/a/b/c");
+    assertEquals(HttpUrl.parse("http://host/a/b/d/e/f"), base.resolve("d\\e\\f"));
+    assertEquals(HttpUrl.parse("http://host/d/e/f"), base.resolve("../..\\d\\e\\f"));
+    assertEquals(HttpUrl.parse("http://host/"), base.resolve("..\\.."));
+  }
+
+  @Test public void relativePathWithSameScheme() throws Exception {
+    HttpUrl base = HttpUrl.parse("http://host/a/b/c");
+    assertEquals(HttpUrl.parse("http://host/a/b/d/e/f"), base.resolve("http:d/e/f"));
+    assertEquals(HttpUrl.parse("http://host/d/e/f"), base.resolve("http:../../d/e/f"));
+  }
+
+  @Test public void decodeUsername() {
+    assertEquals("user", HttpUrl.parse("http://user@host/").decodeUsername());
+    assertEquals("\uD83C\uDF69", HttpUrl.parse("http://%F0%9F%8D%A9@host/").decodeUsername());
+  }
+
+  @Test public void decodePassword() {
+    assertEquals("password", HttpUrl.parse("http://user:password@host/").decodePassword());
+    assertEquals(null, HttpUrl.parse("http://user:@host/").decodePassword());
+    assertEquals("\uD83C\uDF69", HttpUrl.parse("http://user:%F0%9F%8D%A9@host/").decodePassword());
+  }
+
+  @Test public void decodeSlashCharacterInDecodedPathSegment() {
+    assertEquals(Arrays.asList("a/b/c"),
+        HttpUrl.parse("http://host/a%2Fb%2Fc").decodePathSegments());
+  }
+
+  @Test public void decodeEmptyPathSegments() {
+    assertEquals(Arrays.asList(""),
+        HttpUrl.parse("http://host/").decodePathSegments());
+  }
+
+  @Test public void percentDecode() throws Exception {
+    assertEquals(Arrays.asList("\u0000"),
+        HttpUrl.parse("http://host/%00").decodePathSegments());
+    assertEquals(Arrays.asList("a", "\u2603", "c"),
+        HttpUrl.parse("http://host/a/%E2%98%83/c").decodePathSegments());
+    assertEquals(Arrays.asList("a", "\uD83C\uDF69", "c"),
+        HttpUrl.parse("http://host/a/%F0%9F%8D%A9/c").decodePathSegments());
+    assertEquals(Arrays.asList("a", "b", "c"),
+        HttpUrl.parse("http://host/a/%62/c").decodePathSegments());
+    assertEquals(Arrays.asList("a", "z", "c"),
+        HttpUrl.parse("http://host/a/%7A/c").decodePathSegments());
+    assertEquals(Arrays.asList("a", "z", "c"),
+        HttpUrl.parse("http://host/a/%7a/c").decodePathSegments());
+  }
+
+  @Test public void malformedPercentEncoding() {
+    assertEquals(Arrays.asList("a%f", "b"),
+        HttpUrl.parse("http://host/a%f/b").decodePathSegments());
+    assertEquals(Arrays.asList("%", "b"),
+        HttpUrl.parse("http://host/%/b").decodePathSegments());
+    assertEquals(Arrays.asList("%"),
+        HttpUrl.parse("http://host/%").decodePathSegments());
+  }
+
+  @Test public void malformedUtf8Encoding() {
+    // Replace a partial UTF-8 sequence with the Unicode replacement character.
+    assertEquals(Arrays.asList("a", "\ufffdx", "c"),
+        HttpUrl.parse("http://host/a/%E2%98x/c").decodePathSegments());
+  }
+}
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/UrlComponentEncodingTester.java b/okhttp-tests/src/test/java/com/squareup/okhttp/UrlComponentEncodingTester.java
new file mode 100644
index 0000000..d602bcc
--- /dev/null
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/UrlComponentEncodingTester.java
@@ -0,0 +1,317 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import okio.Buffer;
+import okio.ByteString;
+
+import static org.junit.Assert.assertEquals;
+
+/** Tests how each code point is encoded and decoded in the context of each URL component. */
+class UrlComponentEncodingTester {
+  /**
+   * The default encode set for the ASCII range. The specific rules vary per-component: for example,
+   * '?' may be identity-encoded in a fragment, but must be percent-encoded in a path.
+   *
+   * See https://url.spec.whatwg.org/#percent-encoded-bytes
+   */
+  private static final Map<Integer, Encoding> defaultEncodings;
+  static {
+    Map<Integer, Encoding> map = new LinkedHashMap<>();
+    map.put(       0x0, Encoding.PERCENT); // Null character
+    map.put(       0x1, Encoding.PERCENT); // Start of Header
+    map.put(       0x2, Encoding.PERCENT); // Start of Text
+    map.put(       0x3, Encoding.PERCENT); // End of Text
+    map.put(       0x4, Encoding.PERCENT); // End of Transmission
+    map.put(       0x5, Encoding.PERCENT); // Enquiry
+    map.put(       0x6, Encoding.PERCENT); // Acknowledgment
+    map.put(       0x7, Encoding.PERCENT); // Bell
+    map.put((int) '\b', Encoding.PERCENT); // Backspace
+    map.put((int) '\t', Encoding.SKIP);    // Horizontal Tab
+    map.put((int) '\n', Encoding.SKIP);    // Line feed
+    map.put(       0xb, Encoding.PERCENT); // Vertical Tab
+    map.put((int) '\f', Encoding.SKIP);    // Form feed
+    map.put((int) '\r', Encoding.SKIP);    // Carriage return
+    map.put(       0xe, Encoding.PERCENT); // Shift Out
+    map.put(       0xf, Encoding.PERCENT); // Shift In
+    map.put(      0x10, Encoding.PERCENT); // Data Link Escape
+    map.put(      0x11, Encoding.PERCENT); // Device Control 1 (oft. XON)
+    map.put(      0x12, Encoding.PERCENT); // Device Control 2
+    map.put(      0x13, Encoding.PERCENT); // Device Control 3 (oft. XOFF)
+    map.put(      0x14, Encoding.PERCENT); // Device Control 4
+    map.put(      0x15, Encoding.PERCENT); // Negative Acknowledgment
+    map.put(      0x16, Encoding.PERCENT); // Synchronous idle
+    map.put(      0x17, Encoding.PERCENT); // End of Transmission Block
+    map.put(      0x18, Encoding.PERCENT); // Cancel
+    map.put(      0x19, Encoding.PERCENT); // End of Medium
+    map.put(      0x1a, Encoding.PERCENT); // Substitute
+    map.put(      0x1b, Encoding.PERCENT); // Escape
+    map.put(      0x1c, Encoding.PERCENT); // File Separator
+    map.put(      0x1d, Encoding.PERCENT); // Group Separator
+    map.put(      0x1e, Encoding.PERCENT); // Record Separator
+    map.put(      0x1f, Encoding.PERCENT); // Unit Separator
+    map.put((int)  ' ', Encoding.PERCENT);
+    map.put((int)  '!', Encoding.IDENTITY);
+    map.put((int)  '"', Encoding.PERCENT);
+    map.put((int)  '#', Encoding.PERCENT);
+    map.put((int)  '$', Encoding.IDENTITY);
+    map.put((int)  '%', Encoding.IDENTITY);
+    map.put((int)  '&', Encoding.IDENTITY);
+    map.put((int) '\'', Encoding.IDENTITY);
+    map.put((int)  '(', Encoding.IDENTITY);
+    map.put((int)  ')', Encoding.IDENTITY);
+    map.put((int)  '*', Encoding.IDENTITY);
+    map.put((int)  '+', Encoding.IDENTITY);
+    map.put((int)  ',', Encoding.IDENTITY);
+    map.put((int)  '-', Encoding.IDENTITY);
+    map.put((int)  '.', Encoding.IDENTITY);
+    map.put((int)  '/', Encoding.IDENTITY);
+    map.put((int)  '0', Encoding.IDENTITY);
+    map.put((int)  '1', Encoding.IDENTITY);
+    map.put((int)  '2', Encoding.IDENTITY);
+    map.put((int)  '3', Encoding.IDENTITY);
+    map.put((int)  '4', Encoding.IDENTITY);
+    map.put((int)  '5', Encoding.IDENTITY);
+    map.put((int)  '6', Encoding.IDENTITY);
+    map.put((int)  '7', Encoding.IDENTITY);
+    map.put((int)  '8', Encoding.IDENTITY);
+    map.put((int)  '9', Encoding.IDENTITY);
+    map.put((int)  ':', Encoding.IDENTITY);
+    map.put((int)  ';', Encoding.IDENTITY);
+    map.put((int)  '<', Encoding.PERCENT);
+    map.put((int)  '=', Encoding.IDENTITY);
+    map.put((int)  '>', Encoding.PERCENT);
+    map.put((int)  '?', Encoding.PERCENT);
+    map.put((int)  '@', Encoding.IDENTITY);
+    map.put((int)  'A', Encoding.IDENTITY);
+    map.put((int)  'B', Encoding.IDENTITY);
+    map.put((int)  'C', Encoding.IDENTITY);
+    map.put((int)  'D', Encoding.IDENTITY);
+    map.put((int)  'E', Encoding.IDENTITY);
+    map.put((int)  'F', Encoding.IDENTITY);
+    map.put((int)  'G', Encoding.IDENTITY);
+    map.put((int)  'H', Encoding.IDENTITY);
+    map.put((int)  'I', Encoding.IDENTITY);
+    map.put((int)  'J', Encoding.IDENTITY);
+    map.put((int)  'K', Encoding.IDENTITY);
+    map.put((int)  'L', Encoding.IDENTITY);
+    map.put((int)  'M', Encoding.IDENTITY);
+    map.put((int)  'N', Encoding.IDENTITY);
+    map.put((int)  'O', Encoding.IDENTITY);
+    map.put((int)  'P', Encoding.IDENTITY);
+    map.put((int)  'Q', Encoding.IDENTITY);
+    map.put((int)  'R', Encoding.IDENTITY);
+    map.put((int)  'S', Encoding.IDENTITY);
+    map.put((int)  'T', Encoding.IDENTITY);
+    map.put((int)  'U', Encoding.IDENTITY);
+    map.put((int)  'V', Encoding.IDENTITY);
+    map.put((int)  'W', Encoding.IDENTITY);
+    map.put((int)  'X', Encoding.IDENTITY);
+    map.put((int)  'Y', Encoding.IDENTITY);
+    map.put((int)  'Z', Encoding.IDENTITY);
+    map.put((int)  '[', Encoding.IDENTITY);
+    map.put((int) '\\', Encoding.IDENTITY);
+    map.put((int)  ']', Encoding.IDENTITY);
+    map.put((int)  '^', Encoding.IDENTITY);
+    map.put((int)  '_', Encoding.IDENTITY);
+    map.put((int)  '`', Encoding.PERCENT);
+    map.put((int)  'a', Encoding.IDENTITY);
+    map.put((int)  'b', Encoding.IDENTITY);
+    map.put((int)  'c', Encoding.IDENTITY);
+    map.put((int)  'd', Encoding.IDENTITY);
+    map.put((int)  'e', Encoding.IDENTITY);
+    map.put((int)  'f', Encoding.IDENTITY);
+    map.put((int)  'g', Encoding.IDENTITY);
+    map.put((int)  'h', Encoding.IDENTITY);
+    map.put((int)  'i', Encoding.IDENTITY);
+    map.put((int)  'j', Encoding.IDENTITY);
+    map.put((int)  'k', Encoding.IDENTITY);
+    map.put((int)  'l', Encoding.IDENTITY);
+    map.put((int)  'm', Encoding.IDENTITY);
+    map.put((int)  'n', Encoding.IDENTITY);
+    map.put((int)  'o', Encoding.IDENTITY);
+    map.put((int)  'p', Encoding.IDENTITY);
+    map.put((int)  'q', Encoding.IDENTITY);
+    map.put((int)  'r', Encoding.IDENTITY);
+    map.put((int)  's', Encoding.IDENTITY);
+    map.put((int)  't', Encoding.IDENTITY);
+    map.put((int)  'u', Encoding.IDENTITY);
+    map.put((int)  'v', Encoding.IDENTITY);
+    map.put((int)  'w', Encoding.IDENTITY);
+    map.put((int)  'x', Encoding.IDENTITY);
+    map.put((int)  'y', Encoding.IDENTITY);
+    map.put((int)  'z', Encoding.IDENTITY);
+    map.put((int)  '{', Encoding.IDENTITY);
+    map.put((int)  '|', Encoding.IDENTITY);
+    map.put((int)  '}', Encoding.IDENTITY);
+    map.put((int)  '~', Encoding.IDENTITY);
+    map.put(      0x7f, Encoding.PERCENT); // Delete
+    defaultEncodings = Collections.unmodifiableMap(map);
+  }
+
+  private final Map<Integer, Encoding> encodings;
+
+  public UrlComponentEncodingTester() {
+    this.encodings = new LinkedHashMap<>(defaultEncodings);
+  }
+
+  public UrlComponentEncodingTester override(Encoding encoding, int... codePoints) {
+    for (int codePoint : codePoints) {
+      encodings.put(codePoint, encoding);
+    }
+    return this;
+  }
+
+  public UrlComponentEncodingTester test(Component component) {
+    for (Map.Entry<Integer, Encoding> entry : encodings.entrySet()) {
+      if (entry.getValue() == Encoding.SKIP) continue;
+
+      testParseOriginal(entry.getKey(), entry.getValue(), component);
+      testParseAlreadyEncoded(entry.getKey(), entry.getValue(), component);
+      testSerialize(entry.getKey(), entry.getValue(), component);
+    }
+    return this;
+  }
+
+  private void testParseAlreadyEncoded(int codePoint, Encoding encoding, Component component) {
+    String encoded = encoding.encode(codePoint);
+    String urlString = component.urlString(encoded);
+    HttpUrl url = HttpUrl.parse(urlString);
+    if (!component.decodedValue(url).equals(encoded)) {
+      assertEquals(String.format("Encoding %s %#x using %s", component, codePoint, encoding),
+          encoded, component.decodedValue(url));
+    }
+  }
+
+  private void testParseOriginal(int codePoint, Encoding encoding, Component component) {
+    String encoded = encoding.encode(codePoint);
+    if (encoding != Encoding.PERCENT) return;
+    String identity = Encoding.IDENTITY.encode(codePoint);
+    String urlString = component.urlString(identity);
+    HttpUrl url = HttpUrl.parse(urlString);
+
+    String s = component.decodedValue(url);
+    if (!s.equals(encoded)) {
+      assertEquals(String.format("Encoding %s %#02x using %s", component, codePoint, encoding),
+          encoded, component.decodedValue(url));
+    }
+  }
+
+  private void testSerialize(int codePoint, Encoding encoding, Component component) {
+    // TODO.
+  }
+
+  public enum Encoding {
+    IDENTITY {
+      public String encode(int codePoint) {
+        return new String(new int[] { codePoint }, 0, 1);
+      }
+    },
+
+    PERCENT {
+      public String encode(int codePoint) {
+        ByteString utf8 = ByteString.encodeUtf8(IDENTITY.encode(codePoint));
+        Buffer percentEncoded = new Buffer();
+        for (int i = 0; i < utf8.size(); i++) {
+          percentEncoded.writeUtf8(String.format("%%%02X", utf8.getByte(i) & 0xff));
+        }
+        return percentEncoded.readUtf8();
+      }
+    },
+
+    SKIP {
+      public String encode(int codePoint) {
+        throw new UnsupportedOperationException();
+      }
+    };
+
+    public abstract String encode(int codePoint);
+
+  }
+
+  public enum Component {
+    USER {
+      @Override public String urlString(String value) {
+        return "http://" + value + "@example.com/";
+      }
+      @Override public String decodedValue(HttpUrl url) {
+        return url.username();
+      }
+    },
+    PASSWORD {
+      @Override public String urlString(String value) {
+        return "http://:" + value + "@example.com/";
+      }
+      @Override public String decodedValue(HttpUrl url) {
+        return url.password();
+      }
+    },
+    HOST {
+      @Override public String urlString(String value) {
+        throw new UnsupportedOperationException("TODO");
+      }
+
+      @Override public String decodedValue(HttpUrl url) {
+        throw new UnsupportedOperationException("TODO");
+      }
+    },
+    PORT {
+      @Override public String urlString(String value) {
+        throw new UnsupportedOperationException("TODO");
+      }
+
+      @Override public String decodedValue(HttpUrl url) {
+        throw new UnsupportedOperationException("TODO");
+      }
+    },
+    PATH {
+      @Override public String urlString(String value) {
+        return "http://example.com/a" + value + "z/";
+      }
+      @Override public String decodedValue(HttpUrl url) {
+        String path = url.path();
+        return path.substring(2, path.length() - 2);
+      }
+    },
+    QUERY {
+      @Override public String urlString(String value) {
+        return "http://example.com/?a" + value + "z";
+      }
+
+      @Override public String decodedValue(HttpUrl url) {
+        String query = url.query();
+        return query.substring(1, query.length() - 1);
+      }
+    },
+    FRAGMENT {
+      @Override public String urlString(String value) {
+        return "http://example.com/#a" + value + "z";
+      }
+
+      @Override public String decodedValue(HttpUrl url) {
+        String fragment = url.fragment();
+        return fragment.substring(1, fragment.length() - 1);
+      }
+    };
+
+    public abstract String urlString(String value);
+
+    public abstract String decodedValue(HttpUrl url);
+  }
+}
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformTestRun.java b/okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformTestRun.java
new file mode 100644
index 0000000..da71661
--- /dev/null
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformTestRun.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp;
+
+import com.google.gson.Gson;
+import com.squareup.okhttp.internal.Util;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.List;
+
+/**
+ * The result of a test run from the <a href="https://github.com/w3c/web-platform-tests">W3C web
+ * platform tests</a>. This class serves as a Gson model for browser test results.
+ *
+ * <p><strong>Note:</strong> When extracting the .json file from the browser after a test run, be
+ * careful to avoid text encoding problems. In one environment, Safari was corrupting UTF-8 data
+ * for download (but the clipboard was fine), and Firefox was corrupting UTF-8 data copied to the
+ * clipboard (but the download was fine).
+ */
+public final class WebPlatformTestRun {
+  List<TestResult> results;
+
+  public SubtestResult get(String testName, String subtestName) {
+    for (TestResult result : results) {
+      if (testName.equals(result.test)) {
+        for (SubtestResult subtestResult : result.subtests) {
+          if (subtestName.equals(subtestResult.name)) {
+            return subtestResult;
+          }
+        }
+      }
+    }
+    return null;
+  }
+
+  public static WebPlatformTestRun load(InputStream in) throws IOException {
+    try {
+      return new Gson().getAdapter(WebPlatformTestRun.class)
+          .fromJson(new InputStreamReader(in, Util.UTF_8));
+    } finally {
+      Util.closeQuietly(in);
+    }
+  }
+
+  public static class TestResult {
+    String test;
+    List<SubtestResult> subtests;
+  }
+
+  public static class SubtestResult {
+    String name;
+    Status status;
+    String message;
+
+    public boolean isPass() {
+      return status == Status.PASS;
+    }
+  }
+
+  public enum Status {
+    PASS, FAIL
+  }
+}
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformUrlTest.java b/okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformUrlTest.java
new file mode 100644
index 0000000..72c1d3c
--- /dev/null
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformUrlTest.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp;
+
+import com.squareup.okhttp.WebPlatformTestRun.SubtestResult;
+import com.squareup.okhttp.internal.Util;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import okio.BufferedSource;
+import okio.Okio;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+/** Runs the web platform URL tests against Java URL models. */
+@RunWith(Parameterized.class)
+public final class WebPlatformUrlTest {
+  @Parameterized.Parameters(name = "{0}")
+  public static List<Object[]> parameters() {
+    try {
+      List<WebPlatformUrlTestData> tests = loadTests();
+
+      // The web platform tests are run in both HTML and XHTML variants. Major browsers pass more
+      // tests in HTML mode, so that's what we'll attempt to match.
+      String testName = "/url/a-element.html";
+      WebPlatformTestRun firefoxTestRun
+          = loadTestRun("/web-platform-test-results-url-firefox-37.0.json");
+      WebPlatformTestRun chromeTestRun
+          = loadTestRun("/web-platform-test-results-url-chrome-42.0.json");
+      WebPlatformTestRun safariTestRun
+          = loadTestRun("/web-platform-test-results-url-safari-7.1.json");
+
+      List<Object[]> result = new ArrayList<>();
+      for (WebPlatformUrlTestData urlTestData : tests) {
+        String subtestName = urlTestData.toString();
+        SubtestResult firefoxResult = firefoxTestRun.get(testName, subtestName);
+        SubtestResult chromeResult = chromeTestRun.get(testName, subtestName);
+        SubtestResult safariResult = safariTestRun.get(testName, subtestName);
+        result.add(new Object[] { urlTestData, firefoxResult, chromeResult, safariResult });
+      }
+      return result;
+    } catch (IOException e) {
+      throw new AssertionError();
+    }
+  }
+
+  @Parameter(0)
+  public WebPlatformUrlTestData testData;
+
+  @Parameter(1)
+  public SubtestResult firefoxResult;
+
+  @Parameter(2)
+  public SubtestResult chromeResultResult;
+
+  @Parameter(3)
+  public SubtestResult safariResult;
+
+  private static final List<String> JAVA_NET_URL_SCHEMES
+      = Util.immutableList("file", "ftp", "http", "https", "mailto");
+  private static final List<String> HTTP_URL_SCHEMES
+      = Util.immutableList("http", "https");
+
+  /** Test how {@link URL} does against the web platform test suite. */
+  @Ignore // java.net.URL is broken. Not much we can do about that.
+  @Test public void javaNetUrl() throws Exception {
+    if (!testData.scheme.isEmpty() && !JAVA_NET_URL_SCHEMES.contains(testData.scheme)) {
+      System.out.println("Ignoring unsupported scheme " + testData.scheme);
+      return;
+    }
+
+    try {
+      testJavaNetUrl();
+    } catch (AssertionError e) {
+      if (tolerateFailure()) {
+        System.out.println("Tolerable failure: " + e.getMessage());
+        return;
+      }
+      throw e;
+    }
+  }
+
+  private void testJavaNetUrl() {
+    URL url = null;
+    String failureMessage = "";
+    try {
+      if (testData.base.equals("about:blank")) {
+        url = new URL(testData.input);
+      } else {
+        URL baseUrl = new URL(testData.base);
+        url = new URL(baseUrl, testData.input);
+      }
+    } catch (MalformedURLException e) {
+      failureMessage = e.getMessage();
+    }
+
+    if (testData.expectParseFailure()) {
+      assertNull("Expected URL to fail parsing", url);
+    } else {
+      assertNotNull("Expected URL to parse successfully, but was " + failureMessage, url);
+      String effectivePort = url.getPort() != -1 ? Integer.toString(url.getPort()) : "";
+      String effectiveQuery = url.getQuery() != null ? "?" + url.getQuery() : "";
+      String effectiveFragment = url.getRef() != null ? "#" + url.getRef() : "";
+      assertEquals("scheme", testData.scheme, url.getProtocol());
+      assertEquals("host", testData.host, url.getHost());
+      assertEquals("port", testData.port, effectivePort);
+      assertEquals("path", testData.path, url.getPath());
+      assertEquals("query", testData.query, effectiveQuery);
+      assertEquals("fragment", testData.fragment, effectiveFragment);
+    }
+  }
+
+  /** Test how {@link HttpUrl} does against the web platform test suite. */
+  @Ignore // TODO(jwilson): implement character encoding.
+  @Test public void httpUrl() throws Exception {
+    if (!testData.scheme.isEmpty() && !HTTP_URL_SCHEMES.contains(testData.scheme)) {
+      System.out.println("Ignoring unsupported scheme " + testData.scheme);
+      return;
+    }
+    if (!testData.base.startsWith("https:") && !testData.base.startsWith("http:")) {
+      System.out.println("Ignoring unsupported base " + testData.base);
+      return;
+    }
+
+    try {
+      testHttpUrl();
+    } catch (AssertionError e) {
+      if (tolerateFailure()) {
+        System.out.println("Tolerable failure: " + e.getMessage());
+        return;
+      }
+      throw e;
+    }
+  }
+
+  private void testHttpUrl() {
+    HttpUrl url;
+    if (testData.base.equals("about:blank")) {
+      url = HttpUrl.parse(testData.input);
+    } else {
+      HttpUrl baseUrl = HttpUrl.parse(testData.base);
+      url = baseUrl.resolve(testData.input);
+    }
+
+    if (testData.expectParseFailure()) {
+      assertNull("Expected URL to fail parsing", url);
+    } else {
+      assertNotNull("Expected URL to parse successfully, but was null", url);
+      String effectivePort = url.port() != HttpUrl.defaultPort(url.scheme())
+          ? Integer.toString(url.port())
+          : "";
+      String effectiveQuery = url.query() != null ? "?" + url.query() : "";
+      String effectiveFragment = url.fragment() != null ? "#" + url.fragment() : "";
+      assertEquals("scheme", testData.scheme, url.scheme());
+      assertEquals("host", testData.host, url.host());
+      assertEquals("port", testData.port, effectivePort);
+      assertEquals("path", testData.path, url.path());
+      assertEquals("query", testData.query, effectiveQuery);
+      assertEquals("fragment", testData.fragment, effectiveFragment);
+    }
+  }
+
+  /**
+   * Returns true if several major browsers also fail this test, in which case the test itself is
+   * questionable.
+   */
+  private boolean tolerateFailure() {
+    return !firefoxResult.isPass()
+        && !chromeResultResult.isPass()
+        && !safariResult.isPass();
+  }
+
+  private static List<WebPlatformUrlTestData> loadTests() throws IOException {
+    BufferedSource source = Okio.buffer(Okio.source(
+        WebPlatformUrlTest.class.getResourceAsStream("/web-platform-test-urltestdata.txt")));
+    return WebPlatformUrlTestData.load(source);
+  }
+
+  private static WebPlatformTestRun loadTestRun(String name) throws IOException {
+    return WebPlatformTestRun.load(WebPlatformUrlTest.class.getResourceAsStream(name));
+  }
+}
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformUrlTestData.java b/okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformUrlTestData.java
new file mode 100644
index 0000000..08bc9e3
--- /dev/null
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/WebPlatformUrlTestData.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import okio.Buffer;
+import okio.BufferedSource;
+
+/**
+ * A test from the <a href="https://github.com/w3c/web-platform-tests/tree/master/url">Web Platform
+ * URL test suite</a>. Each test is a line of the file {@code urltestdata.txt}; the format is
+ * informally specified by its JavaScript parser {@code urltestparser.js}; with which this class
+ * attempts to be compatible.
+ *
+ * <p>Each line of the urltestdata.text file specifies a test. Lines look like this: <pre>   {@code
+ *   http://example\t.\norg http://example.org/foo/bar s:http h:example.org p:/
+ * }
+ */
+public final class WebPlatformUrlTestData {
+  String input;
+  String base;
+  String scheme = "";
+  String username = "";
+  String password = null;
+  String host = "";
+  String port = "";
+  String path = "";
+  String query = "";
+  String fragment = "";
+
+  public boolean expectParseFailure() {
+    return scheme.isEmpty();
+  }
+
+  private void set(String name, String value) {
+    switch (name) {
+      case "s":
+        scheme = value;
+        break;
+      case "u":
+        username = value;
+        break;
+      case "pass":
+        password = value;
+        break;
+      case "h":
+        host = value;
+        break;
+      case "port":
+        port = value;
+        break;
+      case "p":
+        path = value;
+        break;
+      case "q":
+        query = value;
+        break;
+      case "f":
+        fragment = value;
+        break;
+      default:
+        throw new IllegalArgumentException("unexpected attribute: " + value);
+    }
+  }
+
+  @Override public String toString() {
+    return String.format("Parsing: <%s> against <%s>", input, base);
+  }
+
+  public static List<WebPlatformUrlTestData> load(BufferedSource source) throws IOException {
+    List<WebPlatformUrlTestData> list = new ArrayList<>();
+    for (String line; (line = source.readUtf8Line()) != null; ) {
+      if (line.isEmpty() || line.startsWith("#")) continue;
+
+      int i = 0;
+      String[] parts = line.split(" ");
+      WebPlatformUrlTestData element = new WebPlatformUrlTestData();
+      element.input = unescape(parts[i++]);
+
+      String base = i < parts.length ? parts[i++] : null;
+      element.base = (base == null || base.isEmpty())
+          ? list.get(list.size() - 1).base
+          : unescape(base);
+
+      for (; i < parts.length; i++) {
+        String piece = parts[i];
+        if (piece.startsWith("#")) continue;
+        String[] nameAndValue = piece.split(":", 2);
+        element.set(nameAndValue[0], unescape(nameAndValue[1]));
+      }
+
+      list.add(element);
+    }
+    return list;
+  }
+
+  private static String unescape(String s) throws EOFException {
+    Buffer in = new Buffer().writeUtf8(s);
+    StringBuilder result = new StringBuilder();
+    while (!in.exhausted()) {
+      int c = in.readUtf8CodePoint();
+      if (c != '\\') {
+        result.append((char) c);
+        continue;
+      }
+
+      switch (in.readUtf8CodePoint()) {
+        case '\\':
+          result.append('\\');
+          break;
+        case '#':
+          result.append('#');
+          break;
+        case 'n':
+          result.append('\n');
+          break;
+        case 'r':
+          result.append('\r');
+          break;
+        case 's':
+          result.append(' ');
+          break;
+        case 't':
+          result.append('\t');
+          break;
+        case 'f':
+          result.append('\f');
+          break;
+        case 'u':
+          result.append((char) Integer.parseInt(in.readUtf8(4), 16));
+          break;
+        default:
+          throw new IllegalArgumentException("unexpected escape character in " + s);
+      }
+    }
+
+    return result.toString();
+  }
+}
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/internal/ConnectionSpecSelectorTest.java b/okhttp-tests/src/test/java/com/squareup/okhttp/internal/ConnectionSpecSelectorTest.java
new file mode 100644
index 0000000..6af9c02
--- /dev/null
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/internal/ConnectionSpecSelectorTest.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp.internal;
+
+import com.squareup.okhttp.ConnectionSpec;
+import com.squareup.okhttp.TlsVersion;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.security.cert.CertificateException;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLHandshakeException;
+import javax.net.ssl.SSLSocket;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class ConnectionSpecSelectorTest {
+
+  static {
+    Internal.initializeInstanceForTests();
+  }
+
+  private static final SSLContext sslContext = SslContextBuilder.localhost();
+
+  public static final SSLHandshakeException RETRYABLE_EXCEPTION = new SSLHandshakeException(
+      "Simulated handshake exception");
+
+  @Test
+  public void nonRetryableIOException() throws Exception {
+    ConnectionSpecSelector connectionSpecSelector =
+        createConnectionSpecSelector(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS);
+    SSLSocket socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_1, TlsVersion.TLS_1_0);
+    connectionSpecSelector.configureSecureSocket(socket);
+
+    boolean retry = connectionSpecSelector.connectionFailed(
+        new IOException("Non-handshake exception"));
+    assertFalse(retry);
+    socket.close();
+  }
+
+  @Test
+  public void nonRetryableSSLHandshakeException() throws Exception {
+    ConnectionSpecSelector connectionSpecSelector =
+        createConnectionSpecSelector(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS);
+    SSLSocket socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_1, TlsVersion.TLS_1_0);
+    connectionSpecSelector.configureSecureSocket(socket);
+
+    SSLHandshakeException trustIssueException =
+        new SSLHandshakeException("Certificate handshake exception");
+    trustIssueException.initCause(new CertificateException());
+    boolean retry = connectionSpecSelector.connectionFailed(trustIssueException);
+    assertFalse(retry);
+    socket.close();
+  }
+
+  @Test
+  public void retryableSSLHandshakeException() throws Exception {
+    ConnectionSpecSelector connectionSpecSelector =
+        createConnectionSpecSelector(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS);
+    SSLSocket socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_1, TlsVersion.TLS_1_0);
+    connectionSpecSelector.configureSecureSocket(socket);
+
+    boolean retry = connectionSpecSelector.connectionFailed(RETRYABLE_EXCEPTION);
+    assertTrue(retry);
+    socket.close();
+  }
+
+  @Test
+  public void someFallbacksSupported() throws Exception {
+    ConnectionSpec sslV3 =
+        new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
+            .tlsVersions(TlsVersion.SSL_3_0)
+            .build();
+
+    ConnectionSpecSelector connectionSpecSelector = createConnectionSpecSelector(
+        ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS, sslV3);
+
+    TlsVersion[] enabledSocketTlsVersions = { TlsVersion.TLS_1_1, TlsVersion.TLS_1_0 };
+    SSLSocket socket = createSocketWithEnabledProtocols(enabledSocketTlsVersions);
+
+    // MODERN_TLS is used here.
+    connectionSpecSelector.configureSecureSocket(socket);
+    assertEnabledProtocols(socket, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0);
+
+    boolean retry = connectionSpecSelector.connectionFailed(RETRYABLE_EXCEPTION);
+    assertTrue(retry);
+    socket.close();
+
+    // COMPATIBLE_TLS is used here.
+    socket = createSocketWithEnabledProtocols(enabledSocketTlsVersions);
+    connectionSpecSelector.configureSecureSocket(socket);
+    assertEnabledProtocols(socket, TlsVersion.TLS_1_0);
+
+    retry = connectionSpecSelector.connectionFailed(RETRYABLE_EXCEPTION);
+    assertFalse(retry);
+    socket.close();
+
+    // sslV3 is not used because SSLv3 is not enabled on the socket.
+  }
+
+  private static ConnectionSpecSelector createConnectionSpecSelector(
+      ConnectionSpec... connectionSpecs) {
+    return new ConnectionSpecSelector(Arrays.asList(connectionSpecs));
+  }
+
+  private SSLSocket createSocketWithEnabledProtocols(TlsVersion... tlsVersions) throws IOException {
+    SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket();
+    socket.setEnabledProtocols(javaNames(tlsVersions));
+    return socket;
+  }
+
+  private static void assertEnabledProtocols(SSLSocket socket, TlsVersion... required) {
+    Set<String> actual = new LinkedHashSet<>(Arrays.asList(socket.getEnabledProtocols()));
+    Set<String> expected = new LinkedHashSet<>(Arrays.asList(javaNames(required)));
+    assertEquals(expected, actual);
+  }
+
+  private static String[] javaNames(TlsVersion... tlsVersions) {
+    String[] protocols = new String[tlsVersions.length];
+    for (int i = 0; i < tlsVersions.length; i++) {
+      protocols[i] = tlsVersions[i].javaName();
+    }
+    return protocols;
+  }
+}
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/RouteExceptionTest.java b/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/RouteExceptionTest.java
new file mode 100644
index 0000000..efd0d7a
--- /dev/null
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/RouteExceptionTest.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp.internal.http;
+
+import org.junit.Test;
+
+import java.io.IOException;
+
+import static org.junit.Assert.assertSame;
+
+/**
+ * Tests for {@link RouteException}.
+ */
+public class RouteExceptionTest {
+
+  @Test public void getConnectionIOException_single() {
+    IOException firstException = new IOException();
+    RouteException re = new RouteException(firstException);
+    assertSame(firstException, re.getLastConnectException());
+  }
+
+  @Test public void getConnectionIOException_multiple() {
+    IOException firstException = new IOException();
+    IOException secondException = new IOException();
+    IOException thirdException = new IOException();
+    RouteException re = new RouteException(firstException);
+    re.addConnectException(secondException);
+    re.addConnectException(thirdException);
+
+    IOException connectionIOException = re.getLastConnectException();
+    assertSame(thirdException, connectionIOException);
+    Throwable[] thirdSuppressedExceptions = thirdException.getSuppressed();
+    assertSame(secondException, thirdSuppressedExceptions[0]);
+
+    Throwable[] secondSuppressedException = secondException.getSuppressed();
+    assertSame(firstException, secondSuppressedException[0]);
+  }
+}
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/RouteSelectorTest.java b/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/RouteSelectorTest.java
index 8efd308..bb8d082 100644
--- a/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/RouteSelectorTest.java
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/RouteSelectorTest.java
@@ -28,7 +28,6 @@
 import com.squareup.okhttp.internal.RouteDatabase;
 import com.squareup.okhttp.internal.SslContextBuilder;
 import com.squareup.okhttp.internal.Util;
-import java.io.IOException;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.Proxy;
@@ -41,7 +40,6 @@
 import javax.net.ssl.HostnameVerifier;
 import javax.net.ssl.HttpsURLConnection;
 import javax.net.ssl.SSLContext;
-import javax.net.ssl.SSLHandshakeException;
 import javax.net.ssl.SSLSocketFactory;
 import org.junit.Before;
 import org.junit.Test;
@@ -114,8 +112,7 @@
 
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(255, 1);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0],
-        uriPort, ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort);
     dns.assertRequests(uriHost);
 
     assertFalse(routeSelector.hasNext());
@@ -135,8 +132,7 @@
     Route route = routeSelector.next();
     routeDatabase.failed(route);
     routeSelector = RouteSelector.get(address, httpRequest, client);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0],
-        uriPort, ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort);
     assertFalse(routeSelector.hasNext());
     try {
       routeSelector.next();
@@ -153,10 +149,8 @@
 
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(255, 2);
-    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0],
-        proxyAPort, ConnectionSpec.CLEARTEXT);
-    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[1],
-        proxyAPort, ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0], proxyAPort);
+    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[1], proxyAPort);
 
     assertFalse(routeSelector.hasNext());
     dns.assertRequests(proxyAHost);
@@ -171,10 +165,8 @@
 
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(255, 2);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0],
-        uriPort, ConnectionSpec.CLEARTEXT);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[1],
-        uriPort, ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[1], uriPort);
 
     assertFalse(routeSelector.hasNext());
     dns.assertRequests(uriHost);
@@ -190,8 +182,7 @@
 
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(255, 1);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0],
-        uriPort, ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort);
     dns.assertRequests(uriHost);
 
     assertFalse(routeSelector.hasNext());
@@ -203,10 +194,8 @@
 
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(255, 2);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0],
-        uriPort, ConnectionSpec.CLEARTEXT);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[1],
-        uriPort, ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[1], uriPort);
 
     assertFalse(routeSelector.hasNext());
     dns.assertRequests(uriHost);
@@ -224,25 +213,20 @@
     // First try the IP addresses of the first proxy, in sequence.
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(255, 2);
-    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0], proxyAPort,
-        ConnectionSpec.CLEARTEXT);
-    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[1], proxyAPort,
-        ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0], proxyAPort);
+    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[1], proxyAPort);
     dns.assertRequests(proxyAHost);
 
     // Next try the IP address of the second proxy.
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(254, 1);
-    assertRoute(routeSelector.next(), address, proxyB, dns.inetAddresses[0],
-        proxyBPort,
-        ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, proxyB, dns.inetAddresses[0], proxyBPort);
     dns.assertRequests(proxyBHost);
 
     // Finally try the only IP address of the origin server.
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(253, 1);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort,
-        ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort);
     dns.assertRequests(uriHost);
 
     assertFalse(routeSelector.hasNext());
@@ -258,8 +242,7 @@
     // Only the origin server will be attempted.
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(255, 1);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort,
-        ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort);
     dns.assertRequests(uriHost);
 
     assertFalse(routeSelector.hasNext());
@@ -276,8 +259,7 @@
 
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(255, 1);
-    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0],
-        proxyAPort, ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0], proxyAPort);
     dns.assertRequests(proxyAHost);
 
     assertTrue(routeSelector.hasNext());
@@ -291,44 +273,17 @@
 
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(255, 1);
-    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0],
-        proxyAPort, ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0], proxyAPort);
     dns.assertRequests(proxyAHost);
 
     assertTrue(routeSelector.hasNext());
     dns.inetAddresses = makeFakeAddresses(254, 1);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0],
-        uriPort, ConnectionSpec.CLEARTEXT);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort);
     dns.assertRequests(uriHost);
 
     assertFalse(routeSelector.hasNext());
   }
 
-  // https://github.com/square/okhttp/issues/442
-  @Test public void nonSslErrorAddsAllTlsModesToFailedRoute() throws Exception {
-    Address address = httpsAddress();
-    client.setProxy(Proxy.NO_PROXY);
-    RouteSelector routeSelector = RouteSelector.get(address, httpsRequest, client);
-
-    dns.inetAddresses = makeFakeAddresses(255, 1);
-    Route route = routeSelector.next();
-    routeSelector.connectFailed(route, new IOException("Non SSL exception"));
-    assertEquals(2, routeDatabase.failedRoutesCount());
-    assertFalse(routeSelector.hasNext());
-  }
-
-  @Test public void sslErrorAddsOnlyFailedConfigurationToFailedRoute() throws Exception {
-    Address address = httpsAddress();
-    client.setProxy(Proxy.NO_PROXY);
-    RouteSelector routeSelector = RouteSelector.get(address, httpsRequest, client);
-
-    dns.inetAddresses = makeFakeAddresses(255, 1);
-    Route route = routeSelector.next();
-    routeSelector.connectFailed(route, new SSLHandshakeException("SSL exception"));
-    assertTrue(routeDatabase.failedRoutesCount() == 1);
-    assertTrue(routeSelector.hasNext());
-  }
-
   @Test public void multipleProxiesMultipleInetAddressesMultipleConfigurations() throws Exception {
     Address address = httpsAddress();
     proxySelector.proxies.add(proxyA);
@@ -337,39 +292,21 @@
 
     // Proxy A
     dns.inetAddresses = makeFakeAddresses(255, 2);
-    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0],
-        proxyAPort, ConnectionSpec.MODERN_TLS);
+    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0], proxyAPort);
     dns.assertRequests(proxyAHost);
-    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[0],
-        proxyAPort, ConnectionSpec.COMPATIBLE_TLS);
-    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[1],
-        proxyAPort, ConnectionSpec.MODERN_TLS);
-    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[1],
-        proxyAPort, ConnectionSpec.COMPATIBLE_TLS);
+    assertRoute(routeSelector.next(), address, proxyA, dns.inetAddresses[1], proxyAPort);
 
     // Proxy B
     dns.inetAddresses = makeFakeAddresses(254, 2);
-    assertRoute(routeSelector.next(), address, proxyB, dns.inetAddresses[0],
-        proxyBPort, ConnectionSpec.MODERN_TLS);
+    assertRoute(routeSelector.next(), address, proxyB, dns.inetAddresses[0], proxyBPort);
     dns.assertRequests(proxyBHost);
-    assertRoute(routeSelector.next(), address, proxyB, dns.inetAddresses[0],
-        proxyBPort, ConnectionSpec.COMPATIBLE_TLS);
-    assertRoute(routeSelector.next(), address, proxyB, dns.inetAddresses[1],
-        proxyBPort, ConnectionSpec.MODERN_TLS);
-    assertRoute(routeSelector.next(), address, proxyB, dns.inetAddresses[1],
-        proxyBPort, ConnectionSpec.COMPATIBLE_TLS);
+    assertRoute(routeSelector.next(), address, proxyB, dns.inetAddresses[1], proxyBPort);
 
     // Origin
     dns.inetAddresses = makeFakeAddresses(253, 2);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0],
-        uriPort, ConnectionSpec.MODERN_TLS);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0], uriPort);
     dns.assertRequests(uriHost);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[0],
-        uriPort, ConnectionSpec.COMPATIBLE_TLS);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[1],
-        uriPort, ConnectionSpec.MODERN_TLS);
-    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[1],
-        uriPort, ConnectionSpec.COMPATIBLE_TLS);
+    assertRoute(routeSelector.next(), address, NO_PROXY, dns.inetAddresses[1], uriPort);
 
     assertFalse(routeSelector.hasNext());
   }
@@ -379,7 +316,8 @@
     client.setProxy(Proxy.NO_PROXY);
     RouteSelector routeSelector = RouteSelector.get(address, httpsRequest, client);
 
-    dns.inetAddresses = makeFakeAddresses(255, 1);
+    final int numberOfAddresses = 2;
+    dns.inetAddresses = makeFakeAddresses(255, numberOfAddresses);
 
     // Extract the regular sequence of routes from selector.
     List<Route> regularRoutes = new ArrayList<>();
@@ -388,7 +326,7 @@
     }
 
     // Check that we do indeed have more than one route.
-    assertTrue(regularRoutes.size() > 1);
+    assertEquals(numberOfAddresses, regularRoutes.size());
     // Add first regular route as failed.
     routeDatabase.failed(regularRoutes.get(0));
     // Reset selector
@@ -422,13 +360,12 @@
     assertEquals("127.0.0.1", RouteSelector.getHostString(socketAddress));
   }
 
-  private void assertRoute(Route route, Address address, Proxy proxy,
-      InetAddress socketAddress, int socketPort, ConnectionSpec connectionSpec) {
+  private void assertRoute(Route route, Address address, Proxy proxy, InetAddress socketAddress,
+      int socketPort) {
     assertEquals(address, route.getAddress());
     assertEquals(proxy, route.getProxy());
     assertEquals(socketAddress, route.getSocketAddress().getAddress());
     assertEquals(socketPort, route.getSocketAddress().getPort());
-    assertEquals(connectionSpec, route.getConnectionSpec());
   }
 
   /** Returns an address that's without an SSL socket factory or hostname verifier. */
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/URLConnectionTest.java b/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/URLConnectionTest.java
index c9f9d42..7caf404 100644
--- a/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/URLConnectionTest.java
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/internal/http/URLConnectionTest.java
@@ -605,7 +605,7 @@
     }
   }
 
-  @Test public void connectViaHttpsWithSSLFallback() throws IOException, InterruptedException {
+  @Test public void connectViaHttpsWithSSLFallback() throws Exception {
     server.get().useHttps(sslContext.getSocketFactory(), false);
     server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.FAIL_HANDSHAKE));
     server.enqueue(new MockResponse().setBody("this response comes via SSL"));
@@ -618,6 +618,25 @@
 
     RecordedRequest request = server.takeRequest();
     assertEquals("GET /foo HTTP/1.1", request.getRequestLine());
+    assertEquals(TlsVersion.TLS_1_0, request.getTlsVersion());
+  }
+
+  @Test public void connectViaHttpsWithSSLFallbackFailuresRecorded() throws Exception {
+    server.get().useHttps(sslContext.getSocketFactory(), false);
+    server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.FAIL_HANDSHAKE));
+    server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.FAIL_HANDSHAKE));
+
+    suppressTlsFallbackScsv(client.client());
+    Internal.instance.setNetwork(client.client(), new SingleInetAddressNetwork());
+
+    client.client().setHostnameVerifier(new RecordingHostnameVerifier());
+    connection = client.open(server.getUrl("/foo"));
+
+    try {
+      connection.getResponseCode();
+    } catch (IOException e) {
+      assertEquals(1, e.getSuppressed().length);
+    }
   }
 
   /**
diff --git a/okhttp-tests/src/test/java/com/squareup/okhttp/internal/spdy/Spdy3ConnectionTest.java b/okhttp-tests/src/test/java/com/squareup/okhttp/internal/spdy/Spdy3ConnectionTest.java
index eb53e35..40bf8c0 100644
--- a/okhttp-tests/src/test/java/com/squareup/okhttp/internal/spdy/Spdy3ConnectionTest.java
+++ b/okhttp-tests/src/test/java/com/squareup/okhttp/internal/spdy/Spdy3ConnectionTest.java
@@ -963,6 +963,47 @@
     assertEquals(TYPE_RST_STREAM, peer.takeFrame().type);
   }
 
+  @Test public void writeTimesOutAwaitingConnectionWindow() throws Exception {
+    // Set the peer's receive window to 5 bytes. Give the stream 5 bytes back, so only the
+    // connection-level window is applicable.
+    Settings peerSettings = new Settings().set(Settings.INITIAL_WINDOW_SIZE, PERSIST_VALUE, 5);
+
+    // write the mocking script
+    peer.sendFrame().settings(peerSettings);
+    peer.acceptFrame(); // SYN_STREAM
+    peer.sendFrame().synReply(false, 1, headerEntries("a", "android"));
+    peer.sendFrame().windowUpdate(1, 5);
+    peer.acceptFrame(); // PING
+    peer.sendFrame().ping(true, 1, 0);
+    peer.acceptFrame(); // DATA
+    peer.acceptFrame(); // RST_STREAM
+    peer.play();
+
+    // play it back
+    SpdyConnection connection = connection(peer, SPDY3);
+    SpdyStream stream = connection.newStream(headerEntries("b", "banana"), true, true);
+    connection.ping().roundTripTime(); // Make sure the window update has been received.
+    Sink sink = stream.getSink();
+    stream.writeTimeout().timeout(500, TimeUnit.MILLISECONDS);
+    sink.write(new Buffer().writeUtf8("abcdef"), 6);
+    long startNanos = System.nanoTime();
+    try {
+      sink.flush(); // This will time out waiting on the write window.
+      fail();
+    } catch (InterruptedIOException expected) {
+    }
+    long elapsedNanos = System.nanoTime() - startNanos;
+    awaitWatchdogIdle();
+    assertEquals(500d, TimeUnit.NANOSECONDS.toMillis(elapsedNanos), 200d /* 200ms delta */);
+    assertEquals(0, connection.openStreamCount());
+
+    // verify the peer received what was expected
+    assertEquals(TYPE_HEADERS, peer.takeFrame().type);
+    assertEquals(TYPE_PING, peer.takeFrame().type);
+    assertEquals(TYPE_DATA, peer.takeFrame().type);
+    assertEquals(TYPE_RST_STREAM, peer.takeFrame().type);
+  }
+
   @Test public void outgoingWritesAreBatched() throws Exception {
     // write the mocking script
     peer.acceptFrame(); // SYN_STREAM
diff --git a/okhttp-tests/src/test/resources/web-platform-test-results-url-chrome-42.0.json b/okhttp-tests/src/test/resources/web-platform-test-results-url-chrome-42.0.json
new file mode 100644
index 0000000..60adf69
--- /dev/null
+++ b/okhttp-tests/src/test/resources/web-platform-test-results-url-chrome-42.0.json
@@ -0,0 +1,1341 @@
+{
+  "results": [
+    {
+      "test": "/url/a-element.html",
+      "subtests": [
+        {
+          "name": "Loading data…",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example\t.\norg> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://user:pass@foo:21/bar;par?b#c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:foo.com> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <\t   :foo.com   \n> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: < foo.com  > against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <a:\t foo.com> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:21/ b ? d # e > against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:0/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:00000000000000/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:00000000000000000000080/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:b/c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"0\""
+        },
+        {
+          "name": "Parsing: <http://f: /c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"0\""
+        },
+        {
+          "name": "Parsing: <http://f:\n/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:fifty-two/c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"0\""
+        },
+        {
+          "name": "Parsing: <http://f:999999/c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"http:\" but got \":\""
+        },
+        {
+          "name": "Parsing: <http://f: 21 / b ? d # e > against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"0\""
+        },
+        {
+          "name": "Parsing: <> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <  \t> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:foo.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:foo.com\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:a> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:#> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#;?> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <?> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:23> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </:23> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <::> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <::23> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo://> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://a:b@c:29/d> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http::@c:29> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://&a:foo(b]c@d:2/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://&a:foo(b]c@d:2/\" but got \"http://&a:foo(b%5Dc@d:2/\""
+        },
+        {
+          "name": "Parsing: <http://::@c@d:2> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://::%40c@d:2/\" but got \"http://:%3A%40c@d:2/\""
+        },
+        {
+          "name": "Parsing: <http://foo.com:b@d/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo.com/\\@> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:\\\\foo.com\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:\\\\a\\b:c\\d@foo.com\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo:/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo:/bar.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo://///////> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo://///////bar.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo:////://///> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <c:/foo> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <//foo/bar> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo/path;a??e#f#g> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo/abcd?efgh?ijkl> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo/abcd#foo?bar> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <[61:24:74]:98> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:[61:27]/:foo> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://[1::2]:3:4> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"0\""
+        },
+        {
+          "name": "Parsing: <http://2001::1> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"0\""
+        },
+        {
+          "name": "Parsing: <http://2001::1]> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://2001::1]\" but got \"http://2001::1]/\""
+        },
+        {
+          "name": "Parsing: <http://2001::1]:80> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://2001::1]:80\" but got \"http://2001::1]/\""
+        },
+        {
+          "name": "Parsing: <http://[2001::1]> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://[2001::1]:80> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftps:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <javascript:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <mailto:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftps:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <javascript:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <mailto:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a/b/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a/ /c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a%2fc> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a/%2f/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#β> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:text/html,test#test> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:c:\\foo\\bar.html> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/c:/foo/bar.html\" but got \"/tmp/mock/c:/foo/bar.html\""
+        },
+        {
+          "name": "Parsing: <  File:c|////foo\\bar.html> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/c:////foo/bar.html\" but got \"/tmp/mock/c%7C////foo/bar.html\""
+        },
+        {
+          "name": "Parsing: <C|/foo/bar> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/C:/foo/bar\" but got \"/tmp/mock/C%7C/foo/bar\""
+        },
+        {
+          "name": "Parsing: </C|\\foo\\bar> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/C:/foo/bar\" but got \"/C%7C/foo/bar\""
+        },
+        {
+          "name": "Parsing: <//C|/foo/bar> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"\" but got \"c%7C\""
+        },
+        {
+          "name": "Parsing: <//server/file> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <\\\\server\\file> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </\\server/file> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:///foo/bar.txt> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:///home/me> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <//> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <///> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <///test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file://test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file://localhost> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file://localhost/> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file://localhost/test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/././foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/./.foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/.> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/./> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/../> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/..bar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/../ton> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/../ton/../../a> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/../../..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/../../../ton> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/%2e> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/%2e%2> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo/%2e%2\" but got \"/foo/.%2\""
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/%2e./%2e%2e/.%2e/%2e.bar> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/%2e.bar\" but got \"/..bar\""
+        },
+        {
+          "name": "Parsing: <http://example.com////../..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar//../..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar//..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/%20foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%2> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%2zbar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%2©zbar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%41%7a> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo%41%7a\" but got \"/fooAz\""
+        },
+        {
+          "name": "Parsing: <http://example.com/foo\t‘%91> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%00%51> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"http:\" but got \":\""
+        },
+        {
+          "name": "Parsing: <http://example.com/(%28:%3A%29)> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/%3A%3a%3C%3c> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo\tbar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com\\\\foo\\\\bar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/%7Ffp3%3Eju%3Dduvgw%3Dd> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/@asdf%40> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/你好你好> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/‥/foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com//foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/‮/foo/‭/bar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.google.com/foo?bar=baz#> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.google.com/foo?bar=baz# »> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:test# »> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: hash expected \"# »\" but got \"# %C2%BB\""
+        },
+        {
+          "name": "Parsing: <http://[www.google.com]/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.google.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://192.0x00A80001> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www/foo%2Ehtml> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo%2Ehtml\" but got \"/foo.html\""
+        },
+        {
+          "name": "Parsing: <http://www/foo/%2E/html> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://user:pass@/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://%25DOMAIN:foobar@foodomain.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:\\\\www.google.com\\foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo:81/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <httpa://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo:-80/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"0\""
+        },
+        {
+          "name": "Parsing: <https://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp://foo:21/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher://foo:70/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:81/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:815/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:81/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:815/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftps:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <javascript:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <mailto:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftps:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <javascript:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <mailto:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:a:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/a:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://a:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://@pple.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http::b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/:@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http:/:@/www.example.com\" but got \"http:///www.example.com\""
+        },
+        {
+          "name": "Parsing: <http://user@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http:@/www.example.com\" but got \"http:///www.example.com\""
+        },
+        {
+          "name": "Parsing: <http:/@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http:/@/www.example.com\" but got \"http:///www.example.com\""
+        },
+        {
+          "name": "Parsing: <http://@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://@/www.example.com\" but got \"http:///www.example.com\""
+        },
+        {
+          "name": "Parsing: <https:@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"https:@/www.example.com\" but got \"https:///www.example.com\""
+        },
+        {
+          "name": "Parsing: <http:a:b@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http:a:b@/www.example.com\" but got \"http://a:b@/www.example.com\""
+        },
+        {
+          "name": "Parsing: <http:/a:b@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http:/a:b@/www.example.com\" but got \"http://a:b@/www.example.com\""
+        },
+        {
+          "name": "Parsing: <http://a:b@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http::@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http::@/www.example.com\" but got \"http:///www.example.com\""
+        },
+        {
+          "name": "Parsing: <http:a:@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://a:@www.example.com/\" but got \"http://a@www.example.com/\""
+        },
+        {
+          "name": "Parsing: <http:/a:@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://a:@www.example.com/\" but got \"http://a@www.example.com/\""
+        },
+        {
+          "name": "Parsing: <http://a:@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://a:@www.example.com/\" but got \"http://a@www.example.com/\""
+        },
+        {
+          "name": "Parsing: <http://www.@pple.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:@:www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"0\""
+        },
+        {
+          "name": "Parsing: <http:/@:www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"0\""
+        },
+        {
+          "name": "Parsing: <http://@:www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"0\""
+        },
+        {
+          "name": "Parsing: <http://:@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://:@www.example.com/\" but got \"http://www.example.com/\""
+        },
+        {
+          "name": "Parsing: </> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <.> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <..> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <./test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <../test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <../aaa/test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <../../test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <中/test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.example2.com> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <//www.example2.com> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://ExAmPlE.CoM> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example example.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://Goo%20 goo%7C|.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://GOO  goo.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://GOO​⁠goo.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.foo。bar.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://﷐zyx.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://﷐zyx.com\" but got \"http://%EF%BF%BDzyx.com/\""
+        },
+        {
+          "name": "Parsing: <http://%ef%b7%90zyx.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://%ef%b7%90zyx.com\" but got \"http://%EF%BF%BDzyx.com/\""
+        },
+        {
+          "name": "Parsing: <http://Go.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://%41.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%ef%bc%85%ef%bc%94%ef%bc%91.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%00.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://%00.com\" but got \"http://%00.com/\""
+        },
+        {
+          "name": "Parsing: <http://%ef%bc%85%ef%bc%90%ef%bc%90.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://%ef%bc%85%ef%bc%90%ef%bc%90.com\" but got \"http://%00.com/\""
+        },
+        {
+          "name": "Parsing: <http://你好你好> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://%zz%66%a.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://%zz%66%a.com\" but got \"http://%25zzf%25a.com/\""
+        },
+        {
+          "name": "Parsing: <http://%25> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://%25\" but got \"http://%25/\""
+        },
+        {
+          "name": "Parsing: <http://hello%00> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://hello%00\" but got \"http://hello%00/\""
+        },
+        {
+          "name": "Parsing: <http://%30%78%63%30%2e%30%32%35%30.01> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://%30%78%63%30%2e%30%32%35%30.01%2e> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"0xc0.0250.01.\" but got \"192.168.0.1\""
+        },
+        {
+          "name": "Parsing: <http://192.168.0.257> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://192.168.0.257\" but got \"http://192.168.0.257/\""
+        },
+        {
+          "name": "Parsing: <http://%3g%78%63%30%2e%30%32%35%30%2E.01> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://%3g%78%63%30%2e%30%32%35%30%2E.01\" but got \"http://%253gxc0.0250..01/\""
+        },
+        {
+          "name": "Parsing: <http://192.168.0.1 hello> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://0Xc0.0250.01> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://[google.com]> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://[google.com]\" but got \"http://[google.com]/\""
+        },
+        {
+          "name": "Parsing: <http://foo:💩@example.com/bar> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <x> against <test:test>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"x\" but got \"\""
+        }
+      ],
+      "status": "OK",
+      "message": null
+    }
+  ]
+}
diff --git a/okhttp-tests/src/test/resources/web-platform-test-results-url-firefox-37.0.json b/okhttp-tests/src/test/resources/web-platform-test-results-url-firefox-37.0.json
new file mode 100644
index 0000000..750ad4e
--- /dev/null
+++ b/okhttp-tests/src/test/resources/web-platform-test-results-url-firefox-37.0.json
@@ -0,0 +1,1341 @@
+{
+  "results": [
+    {
+      "test": "/url/a-element.html",
+      "subtests": [
+        {
+          "name": "Loading data…",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example\t.\norg> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://user:pass@foo:21/bar;par?b#c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:foo.com> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <\t   :foo.com   \n> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: < foo.com  > against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <a:\t foo.com> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \" foo.com\" but got \"\""
+        },
+        {
+          "name": "Parsing: <http://f:21/ b ? d # e > against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://f:21/%20b%20?%20d%20# e\" but got \"http://f:21/%20b%20?%20d%20#%20e\""
+        },
+        {
+          "name": "Parsing: <http://f:/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:0/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:00000000000000/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:00000000000000000000080/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:b/c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://f: /c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://f:\n/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:fifty-two/c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://f:999999/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f: 21 / b ? d # e > against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <  \t> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:foo.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:foo.com\\> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo/:foo.com/\" but got \"/foo/:foo.com%5C\""
+        },
+        {
+          "name": "Parsing: <:> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:a> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:\\> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo/:/\" but got \"/foo/:%5C\""
+        },
+        {
+          "name": "Parsing: <:#> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#;?> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <?> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:23> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </:23> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <::> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <::23> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo://> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"//\" but got \"\""
+        },
+        {
+          "name": "Parsing: <http://a:b@c:29/d> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http::@c:29> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo/:@c:29\" but got \"/foo/http::@c:29\""
+        },
+        {
+          "name": "Parsing: <http://&a:foo(b]c@d:2/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://&a:foo(b]c@d:2/\" but got \"http://&a:foo(b%5Dc@d:2/\""
+        },
+        {
+          "name": "Parsing: <http://::@c@d:2> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"d\" but got \"\""
+        },
+        {
+          "name": "Parsing: <http://foo.com:b@d/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://foo.com:b@d/\" but got \"http://foo%2Ecom:b@d/\""
+        },
+        {
+          "name": "Parsing: <http://foo.com/\\@> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"//@\" but got \"/%5C@\""
+        },
+        {
+          "name": "Parsing: <http:\\\\foo.com\\> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"foo.com\" but got \"example.org\""
+        },
+        {
+          "name": "Parsing: <http:\\\\a\\b:c\\d@foo.com\\> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"a\" but got \"example.org\""
+        },
+        {
+          "name": "Parsing: <foo:/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <foo:/bar.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/bar.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <foo://///////> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/////////\" but got \"\""
+        },
+        {
+          "name": "Parsing: <foo://///////bar.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/////////bar.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <foo:////://///> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"////://///\" but got \"\""
+        },
+        {
+          "name": "Parsing: <c:/foo> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo\" but got \"\""
+        },
+        {
+          "name": "Parsing: <//foo/bar> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo/path;a??e#f#g> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo/abcd?efgh?ijkl> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo/abcd#foo?bar> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <[61:24:74]:98> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo/[61:24:74]:98\" but got \"/foo/%5B61:24:74%5D:98\""
+        },
+        {
+          "name": "Parsing: <http:[61:27]/:foo> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo/[61:27]/:foo\" but got \"/foo/%5B61:27%5D/:foo\""
+        },
+        {
+          "name": "Parsing: <http://[1::2]:3:4> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://2001::1> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://2001::1]> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://2001::1]:80> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://[2001::1]> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://[2001::1]:80> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:/example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <file:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftps:/example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <gopher:/example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"example.com\" but got \"\""
+        },
+        {
+          "name": "Parsing: <ws:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:/example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"data:\" but got \"http:\""
+        },
+        {
+          "name": "Parsing: <javascript:/example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <mailto:/example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <http:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <ftps:example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <gopher:example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"example.com\" but got \"\""
+        },
+        {
+          "name": "Parsing: <ws:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"data:\" but got \"http:\""
+        },
+        {
+          "name": "Parsing: <javascript:example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <mailto:example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: </a/b/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a/ /c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a%2fc> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a/%2f/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#β> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://example.org/foo/bar#β\" but got \"http://example.org/foo/bar#%CE%B2\""
+        },
+        {
+          "name": "Parsing: <data:text/html,test#test> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"text/html,test\" but got \"\""
+        },
+        {
+          "name": "Parsing: <file:c:\\foo\\bar.html> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/c:/foo/bar.html\" but got \"/tmp/mock/c:%5Cfoo%5Cbar.html\""
+        },
+        {
+          "name": "Parsing: <  File:c|////foo\\bar.html> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/c:////foo/bar.html\" but got \"/tmp/mock/c|////foo%5Cbar.html\""
+        },
+        {
+          "name": "Parsing: <C|/foo/bar> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/C:/foo/bar\" but got \"/tmp/mock/C|/foo/bar\""
+        },
+        {
+          "name": "Parsing: </C|\\foo\\bar> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/C:/foo/bar\" but got \"/C|%5Cfoo%5Cbar\""
+        },
+        {
+          "name": "Parsing: <//C|/foo/bar> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/C:/foo/bar\" but got \"/foo/bar\""
+        },
+        {
+          "name": "Parsing: <//server/file> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"server\" but got \"\""
+        },
+        {
+          "name": "Parsing: <\\\\server\\file> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"server\" but got \"\""
+        },
+        {
+          "name": "Parsing: </\\server/file> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"server\" but got \"\""
+        },
+        {
+          "name": "Parsing: <file:///foo/bar.txt> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:///home/me> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <//> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <///> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <///test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file://test> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"test\" but got \"\""
+        },
+        {
+          "name": "Parsing: <file://localhost> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"localhost\" but got \"\""
+        },
+        {
+          "name": "Parsing: <file://localhost/> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"localhost\" but got \"\""
+        },
+        {
+          "name": "Parsing: <file://localhost/test> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"localhost\" but got \"\""
+        },
+        {
+          "name": "Parsing: <test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/././foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/./.foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/.> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/./> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/../> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/..bar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/../ton> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/../ton/../../a> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/../../..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/../../../ton> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/%2e> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo/\" but got \"/foo/%2e\""
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/%2e%2> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/%2e./%2e%2e/.%2e/%2e.bar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com////../..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar//../..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar//..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/%20foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%2> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%2zbar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%2©zbar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%41%7a> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo\t‘%91> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%00%51> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/(%28:%3A%29)> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/%3A%3a%3C%3c> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo\tbar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com\\\\foo\\\\bar> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"example.com\" but got \"example.com\\\\\\foo\\\\bar\""
+        },
+        {
+          "name": "Parsing: <http://example.com/%7Ffp3%3Eju%3Dduvgw%3Dd> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/@asdf%40> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/你好你好> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/‥/foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com//foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/‮/foo/‭/bar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.google.com/foo?bar=baz#> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.google.com/foo?bar=baz# »> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://www.google.com/foo?bar=baz# »\" but got \"http://www.google.com/foo?bar=baz#%20%C2%BB\""
+        },
+        {
+          "name": "Parsing: <data:test# »> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"data:\" but got \"http:\""
+        },
+        {
+          "name": "Parsing: <http://[www.google.com]/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://www.google.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://192.0x00A80001> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"192.168.0.1\" but got \"192.0x00a80001\""
+        },
+        {
+          "name": "Parsing: <http://www/foo%2Ehtml> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www/foo/%2E/html> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://user:pass@/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%25DOMAIN:foobar@foodomain.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:\\\\www.google.com\\foo> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"www.google.com\" but got \"\\\\\\www.google.com\\foo\""
+        },
+        {
+          "name": "Parsing: <http://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo:81/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <httpa://foo:80/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"//foo:80/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <http://foo:-80/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <https://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp://foo:21/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher://foo:70/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"foo\" but got \"\""
+        },
+        {
+          "name": "Parsing: <gopher://foo:443/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"foo\" but got \"\""
+        },
+        {
+          "name": "Parsing: <ws://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:81/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:815/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:81/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:815/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:/example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <file:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftps:/example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <gopher:/example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"example.com\" but got \"\""
+        },
+        {
+          "name": "Parsing: <ws:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:/example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"data:\" but got \"http:\""
+        },
+        {
+          "name": "Parsing: <javascript:/example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <mailto:/example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <http:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <ftps:example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <gopher:example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"example.com\" but got \"\""
+        },
+        {
+          "name": "Parsing: <ws:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"data:\" but got \"http:\""
+        },
+        {
+          "name": "Parsing: <javascript:example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <mailto:example.com/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"example.com/\" but got \"\""
+        },
+        {
+          "name": "Parsing: <http:@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:a:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/a:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://a:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://@pple.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http::b@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"www.example.com\" but got \"\""
+        },
+        {
+          "name": "Parsing: <http:/:b@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"www.example.com\" but got \"\""
+        },
+        {
+          "name": "Parsing: <http://:b@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"www.example.com\" but got \"\""
+        },
+        {
+          "name": "Parsing: <http:/:@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://user@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http:@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http:/@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <https:@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http:a:b@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http:/a:b@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://a:b@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http::@/www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http:a:@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/a:@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://a:@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.@pple.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://www.@pple.com/\" but got \"http://www%2E@pple.com/\""
+        },
+        {
+          "name": "Parsing: <http:@:www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http:/@:www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://@:www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://:@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"www.example.com\" but got \"\""
+        },
+        {
+          "name": "Parsing: </> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <.> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <..> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <./test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <../test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <../aaa/test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <../../test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <中/test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.example2.com> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <//www.example2.com> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://ExAmPlE.CoM> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example example.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://Goo%20 goo%7C|.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://GOO  goo.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://GOO​⁠goo.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.foo。bar.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://﷐zyx.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%ef%b7%90zyx.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://Go.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://%41.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%ef%bc%85%ef%bc%94%ef%bc%91.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%00.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%ef%bc%85%ef%bc%90%ef%bc%90.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://你好你好> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"xn--6qqa088eba\" but got \"你好你好\""
+        },
+        {
+          "name": "Parsing: <http://%zz%66%a.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%25> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://hello%00> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%30%78%63%30%2e%30%32%35%30.01> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"192.168.0.1\" but got \"%30%78%63%30%2e%30%32%35%30.01\""
+        },
+        {
+          "name": "Parsing: <http://%30%78%63%30%2e%30%32%35%30.01%2e> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"0xc0.0250.01.\" but got \"%30%78%63%30%2e%30%32%35%30.01%2e\""
+        },
+        {
+          "name": "Parsing: <http://192.168.0.257> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%3g%78%63%30%2e%30%32%35%30%2E.01> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://192.168.0.1 hello> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://0Xc0.0250.01> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"192.168.0.1\" but got \"0xc0.0250.01\""
+        },
+        {
+          "name": "Parsing: <http://[google.com]> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://foo:💩@example.com/bar> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <x> against <test:test>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        }
+      ],
+      "status": "OK",
+      "message": null
+    }
+  ]
+}
\ No newline at end of file
diff --git a/okhttp-tests/src/test/resources/web-platform-test-results-url-safari-7.1.json b/okhttp-tests/src/test/resources/web-platform-test-results-url-safari-7.1.json
new file mode 100644
index 0000000..de3b5d3
--- /dev/null
+++ b/okhttp-tests/src/test/resources/web-platform-test-results-url-safari-7.1.json
@@ -0,0 +1,1341 @@
+{
+  "results": [
+    {
+      "test": "/url/a-element.html",
+      "subtests": [
+        {
+          "name": "Loading data…",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example\t.\norg> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"http:\" but got \":\""
+        },
+        {
+          "name": "Parsing: <http://user:pass@foo:21/bar;par?b#c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:foo.com> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <\t   :foo.com   \n> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: < foo.com  > against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <a:\t foo.com> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:21/ b ? d # e > against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:0/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:00000000000000/c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://f:0/c\" but got \"http://f:00000000000000/c\""
+        },
+        {
+          "name": "Parsing: <http://f:00000000000000000000080/c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"\" but got \"80\""
+        },
+        {
+          "name": "Parsing: <http://f:b/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f: /c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:\n/c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"http:\" but got \":\""
+        },
+        {
+          "name": "Parsing: <http://f:fifty-two/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://f:999999/c> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: port expected \"999999\" but got \"65535\""
+        },
+        {
+          "name": "Parsing: <http://f: 21 / b ? d # e > against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://f: 21 / b ? d # e \" but got \"http://f: 21 / b ? d # e\""
+        },
+        {
+          "name": "Parsing: <> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <  \t> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:foo.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:foo.com\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:a> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:#> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#;?> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <?> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <:23> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </:23> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <::> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <::23> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo://> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://a:b@c:29/d> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http::@c:29> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://&a:foo(b]c@d:2/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"http:\" but got \":\""
+        },
+        {
+          "name": "Parsing: <http://::@c@d:2> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"http:\" but got \":\""
+        },
+        {
+          "name": "Parsing: <http://foo.com:b@d/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo.com/\\@> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:\\\\foo.com\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:\\\\a\\b:c\\d@foo.com\\> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo:/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo:/bar.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo://///////> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo://///////bar.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <foo:////://///> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <c:/foo> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <//foo/bar> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo/path;a??e#f#g> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo/abcd?efgh?ijkl> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo/abcd#foo?bar> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <[61:24:74]:98> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:[61:27]/:foo> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://[1::2]:3:4> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://2001::1> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://2001::1]> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://2001::1]:80> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://[2001::1]> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://[2001::1]:80> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/example.com/> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"example.org\" but got \"example.com\""
+        },
+        {
+          "name": "Parsing: <ftp:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftps:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <javascript:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <mailto:/example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftps:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <javascript:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <mailto:example.com/> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a/b/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a/ /c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a%2fc> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </a/%2f/c> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <#β> against <http://example.org/foo/bar>",
+          "status": "FAIL",
+          "message": "assert_equals: hash expected \"#β\" but got \"#%CE%B2\""
+        },
+        {
+          "name": "Parsing: <data:text/html,test#test> against <http://example.org/foo/bar>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:c:\\foo\\bar.html> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/c:/foo/bar.html\" but got \"/tmp/mock/c:/foo/bar.html\""
+        },
+        {
+          "name": "Parsing: <  File:c|////foo\\bar.html> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/c:////foo/bar.html\" but got \"/tmp/mock/c|////foo/bar.html\""
+        },
+        {
+          "name": "Parsing: <C|/foo/bar> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/C:/foo/bar\" but got \"/tmp/mock/C|/foo/bar\""
+        },
+        {
+          "name": "Parsing: </C|\\foo\\bar> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/C:/foo/bar\" but got \"/C|/foo/bar\""
+        },
+        {
+          "name": "Parsing: <//C|/foo/bar> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"file:\" but got \":\""
+        },
+        {
+          "name": "Parsing: <//server/file> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <\\\\server\\file> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </\\server/file> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:///foo/bar.txt> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:///home/me> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <//> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <///> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <///test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file://test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file://localhost> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"localhost\" but got \"\""
+        },
+        {
+          "name": "Parsing: <file://localhost/> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"localhost\" but got \"\""
+        },
+        {
+          "name": "Parsing: <file://localhost/test> against <file:///tmp/mock/path>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"localhost\" but got \"\""
+        },
+        {
+          "name": "Parsing: <test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:test> against <file:///tmp/mock/path>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/././foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/./.foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/.> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/./> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/../> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/..bar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/../ton> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar/../ton/../../a> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/../../..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/../../../ton> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/%2e> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo/\" but got \"/foo/%2e\""
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/%2e%2> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/%2e./%2e%2e/.%2e/%2e.bar> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/%2e.bar\" but got \"/foo/%2e./%2e%2e/.%2e/%2e.bar\""
+        },
+        {
+          "name": "Parsing: <http://example.com////../..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar//../..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo/bar//..> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/%20foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%2> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%2zbar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%2©zbar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%41%7a> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo\t‘%91> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo%00%51> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/(%28:%3A%29)> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/%3A%3a%3C%3c> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/foo\tbar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com\\\\foo\\\\bar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/%7Ffp3%3Eju%3Dduvgw%3Dd> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/@asdf%40> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/你好你好> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/‥/foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com//foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example.com/‮/foo/‭/bar> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.google.com/foo?bar=baz#> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.google.com/foo?bar=baz# »> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: hash expected \"# »\" but got \"# %C2%BB\""
+        },
+        {
+          "name": "Parsing: <data:test# »> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: hash expected \"# »\" but got \"# %C2%BB\""
+        },
+        {
+          "name": "Parsing: <http://[www.google.com]/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.google.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://192.0x00A80001> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"192.168.0.1\" but got \"192.0x00a80001\""
+        },
+        {
+          "name": "Parsing: <http://www/foo%2Ehtml> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www/foo/%2E/html> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: path expected \"/foo/html\" but got \"/foo/%2E/html\""
+        },
+        {
+          "name": "Parsing: <http://user:pass@/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://%25DOMAIN:foobar@foodomain.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:\\\\www.google.com\\foo> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo:81/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <httpa://foo:80/> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"\" but got \"foo\""
+        },
+        {
+          "name": "Parsing: <http://foo:-80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp://foo:21/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher://foo:70/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:81/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws://foo:815/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:80/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:81/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:443/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss://foo:815/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <file:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftps:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <javascript:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <mailto:/example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftp:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <madeupscheme:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ftps:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <gopher:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <ws:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <wss:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <data:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <javascript:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <mailto:example.com/> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:a:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/a:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://a:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://@pple.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http::b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://:b@www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/:@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://user@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <https:@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:a:b@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/a:b@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://a:b@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http::@/www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:a:@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://a:@www.example.com/\" but got \"http://a@www.example.com/\""
+        },
+        {
+          "name": "Parsing: <http:/a:@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://a:@www.example.com/\" but got \"http://a@www.example.com/\""
+        },
+        {
+          "name": "Parsing: <http://a:@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://a:@www.example.com/\" but got \"http://a@www.example.com/\""
+        },
+        {
+          "name": "Parsing: <http://www.@pple.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:@:www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http:/@:www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://@:www.example.com> against <about:blank>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://:@www.example.com> against <about:blank>",
+          "status": "FAIL",
+          "message": "assert_equals: href expected \"http://:@www.example.com/\" but got \"http://www.example.com/\""
+        },
+        {
+          "name": "Parsing: </> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: </test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <.> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <..> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <./test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <../test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <../aaa/test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <../../test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <中/test.txt> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.example2.com> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <//www.example2.com> against <http://www.example.com/test>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://ExAmPlE.CoM> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://example example.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://Goo%20 goo%7C|.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://GOO  goo.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://GOO​⁠goo.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://www.foo。bar.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://﷐zyx.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%ef%b7%90zyx.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://Go.com> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://%41.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%ef%bc%85%ef%bc%94%ef%bc%91.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%00.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%ef%bc%85%ef%bc%90%ef%bc%90.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://你好你好> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://%zz%66%a.com> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%25> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://hello%00> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%30%78%63%30%2e%30%32%35%30.01> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"192.168.0.1\" but got \"%30%78%63%30%2e%30%32%35%30.01\""
+        },
+        {
+          "name": "Parsing: <http://%30%78%63%30%2e%30%32%35%30.01%2e> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"0xc0.0250.01.\" but got \"%30%78%63%30%2e%30%32%35%30.01%2e\""
+        },
+        {
+          "name": "Parsing: <http://192.168.0.257> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://%3g%78%63%30%2e%30%32%35%30%2E.01> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_unreached: Expected URL to fail parsing Reached unreachable code"
+        },
+        {
+          "name": "Parsing: <http://192.168.0.1 hello> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://0Xc0.0250.01> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: host expected \"192.168.0.1\" but got \"0xc0.0250.01\""
+        },
+        {
+          "name": "Parsing: <http://[google.com]> against <http://other.com/>",
+          "status": "PASS",
+          "message": null
+        },
+        {
+          "name": "Parsing: <http://foo:💩@example.com/bar> against <http://other.com/>",
+          "status": "FAIL",
+          "message": "assert_equals: scheme expected \"http:\" but got \":\""
+        },
+        {
+          "name": "Parsing: <x> against <test:test>",
+          "status": "PASS",
+          "message": null
+        }
+      ],
+      "status": "OK",
+      "message": null
+    }
+  ]
+}
diff --git a/okhttp-tests/src/test/resources/web-platform-test-urltestdata.txt b/okhttp-tests/src/test/resources/web-platform-test-urltestdata.txt
new file mode 100644
index 0000000..87c4f67
--- /dev/null
+++ b/okhttp-tests/src/test/resources/web-platform-test-urltestdata.txt
@@ -0,0 +1,342 @@
+# FORMAT NOT DOCUMENTED YET (parser is urltestparser.js)
+
+# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/script-tests/segments.js
+http://example\t.\norg http://example.org/foo/bar s:http h:example.org p:/
+http://user:pass@foo:21/bar;par?b#c  s:http u:user pass:pass h:foo port:21 p:/bar;par q:?b f:#c
+http:foo.com  s:http h:example.org p:/foo/foo.com
+\t\s\s\s:foo.com\s\s\s\n  s:http h:example.org p:/foo/:foo.com
+\sfoo.com\s\s  s:http h:example.org p:/foo/foo.com
+a:\t\sfoo.com  s:a p:\sfoo.com
+http://f:21/\sb\s?\sd\s#\se\s  s:http h:f port:21 p:/%20b%20 q:?%20d%20 f:#\se
+http://f:/c  s:http h:f p:/c
+http://f:0/c  s:http h:f port:0 p:/c
+http://f:00000000000000/c  s:http h:f port:0 p:/c
+http://f:00000000000000000000080/c  s:http h:f p:/c
+http://f:b/c
+http://f:\s/c
+http://f:\n/c  s:http h:f p:/c
+http://f:fifty-two/c
+http://f:999999/c  s:http h:f port:999999 p:/c
+http://f:\s21\s/\sb\s?\sd\s#\se\s
+  s:http h:example.org p:/foo/bar
+\s\s\t  s:http h:example.org p:/foo/bar
+:foo.com/  s:http h:example.org p:/foo/:foo.com/
+:foo.com\\  s:http h:example.org p:/foo/:foo.com/
+:  s:http h:example.org p:/foo/:
+:a  s:http h:example.org p:/foo/:a
+:/  s:http h:example.org p:/foo/:/
+:\\  s:http h:example.org p:/foo/:/
+:#  s:http h:example.org p:/foo/: f:#
+\#  s:http h:example.org p:/foo/bar f:#
+\#/  s:http h:example.org p:/foo/bar f:#/
+\#\\  s:http h:example.org p:/foo/bar f:#\\
+\#;?  s:http h:example.org p:/foo/bar f:#;?
+?  s:http h:example.org p:/foo/bar q:?
+/  s:http h:example.org p:/
+:23  s:http h:example.org p:/foo/:23
+/:23  s:http h:example.org p:/:23
+::  s:http h:example.org p:/foo/::
+::23  s:http h:example.org p:/foo/::23
+foo://  s:foo p://
+http://a:b@c:29/d  s:http u:a pass:b h:c port:29 p:/d
+http::@c:29  s:http h:example.org p:/foo/:@c:29
+http://&a:foo(b]c@d:2/  s:http u:&a pass:foo(b]c h:d port:2 p:/
+http://::@c@d:2  s:http pass::%40c h:d port:2 p:/
+http://foo.com:b@d/  s:http u:foo.com pass:b h:d p:/
+http://foo.com/\\@  s:http h:foo.com p://@
+http:\\\\foo.com\\  s:http h:foo.com p:/
+http:\\\\a\\b:c\\d@foo.com\\  s:http h:a p:/b:c/d@foo.com/
+foo:/  s:foo p:/
+foo:/bar.com/  s:foo p:/bar.com/
+foo://///////  s:foo p://///////
+foo://///////bar.com/  s:foo p://///////bar.com/
+foo:////://///  s:foo p:////://///
+c:/foo  s:c p:/foo
+//foo/bar  s:http h:foo p:/bar
+http://foo/path;a??e#f#g  s:http h:foo p:/path;a q:??e f:#f#g
+http://foo/abcd?efgh?ijkl  s:http h:foo p:/abcd q:?efgh?ijkl
+http://foo/abcd#foo?bar  s:http h:foo p:/abcd f:#foo?bar
+[61:24:74]:98  s:http h:example.org p:/foo/[61:24:74]:98
+http:[61:27]/:foo  s:http h:example.org p:/foo/[61:27]/:foo
+http://[1::2]:3:4
+http://2001::1
+http://2001::1]
+http://2001::1]:80
+http://[2001::1]  s:http h:[2001::1] p:/
+http://[2001::1]:80  s:http h:[2001::1] p:/
+http:/example.com/  s:http h:example.org p:/example.com/
+ftp:/example.com/  s:ftp h:example.com p:/
+https:/example.com/  s:https h:example.com p:/
+madeupscheme:/example.com/  s:madeupscheme p:/example.com/
+file:/example.com/  s:file p:/example.com/
+ftps:/example.com/  s:ftps p:/example.com/
+gopher:/example.com/  s:gopher h:example.com p:/
+ws:/example.com/  s:ws h:example.com p:/
+wss:/example.com/  s:wss h:example.com p:/
+data:/example.com/  s:data p:/example.com/
+javascript:/example.com/  s:javascript p:/example.com/
+mailto:/example.com/  s:mailto p:/example.com/
+http:example.com/  s:http h:example.org p:/foo/example.com/
+ftp:example.com/  s:ftp h:example.com p:/
+https:example.com/  s:https h:example.com p:/
+madeupscheme:example.com/  s:madeupscheme p:example.com/
+ftps:example.com/  s:ftps p:example.com/
+gopher:example.com/  s:gopher h:example.com p:/
+ws:example.com/  s:ws h:example.com p:/
+wss:example.com/  s:wss h:example.com p:/
+data:example.com/  s:data p:example.com/
+javascript:example.com/  s:javascript p:example.com/
+mailto:example.com/  s:mailto p:example.com/
+/a/b/c  s:http h:example.org p:/a/b/c
+/a/\s/c  s:http h:example.org p:/a/%20/c
+/a%2fc  s:http h:example.org p:/a%2fc
+/a/%2f/c  s:http h:example.org p:/a/%2f/c
+\#\u03B2  s:http h:example.org p:/foo/bar f:#\u03B2
+data:text/html,test#test  s:data p:text/html,test f:#test
+
+# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/file.html
+
+# Basic canonicalization, uppercase should be converted to lowercase
+file:c:\\foo\\bar.html file:///tmp/mock/path s:file p:/c:/foo/bar.html
+
+# Spaces should fail
+\s\sFile:c|////foo\\bar.html  s:file p:/c:////foo/bar.html
+
+# This should fail
+C|/foo/bar  s:file p:/C:/foo/bar
+
+# This should fail
+/C|\\foo\\bar  s:file p:/C:/foo/bar
+//C|/foo/bar  s:file p:/C:/foo/bar
+//server/file  s:file h:server p:/file
+\\\\server\\file  s:file h:server p:/file
+/\\server/file  s:file h:server p:/file
+file:///foo/bar.txt  s:file p:/foo/bar.txt
+file:///home/me  s:file p:/home/me
+//  s:file p:/
+///  s:file p:/
+///test  s:file p:/test
+file://test  s:file h:test p:/
+file://localhost  s:file h:localhost p:/
+file://localhost/  s:file h:localhost p:/
+file://localhost/test  s:file h:localhost p:/test
+test  s:file p:/tmp/mock/test
+file:test  s:file p:/tmp/mock/test
+
+# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/script-tests/path.js
+http://example.com/././foo about:blank s:http h:example.com p:/foo
+http://example.com/./.foo  s:http h:example.com p:/.foo
+http://example.com/foo/.  s:http h:example.com p:/foo/
+http://example.com/foo/./  s:http h:example.com p:/foo/
+http://example.com/foo/bar/..  s:http h:example.com p:/foo/
+http://example.com/foo/bar/../  s:http h:example.com p:/foo/
+http://example.com/foo/..bar  s:http h:example.com p:/foo/..bar
+http://example.com/foo/bar/../ton  s:http h:example.com p:/foo/ton
+http://example.com/foo/bar/../ton/../../a  s:http h:example.com p:/a
+http://example.com/foo/../../..  s:http h:example.com p:/
+http://example.com/foo/../../../ton  s:http h:example.com p:/ton
+http://example.com/foo/%2e  s:http h:example.com p:/foo/
+http://example.com/foo/%2e%2  s:http h:example.com p:/foo/%2e%2
+http://example.com/foo/%2e./%2e%2e/.%2e/%2e.bar  s:http h:example.com p:/%2e.bar
+http://example.com////../..  s:http h:example.com p://
+http://example.com/foo/bar//../..  s:http h:example.com p:/foo/
+http://example.com/foo/bar//..  s:http h:example.com p:/foo/bar/
+http://example.com/foo  s:http h:example.com p:/foo
+http://example.com/%20foo  s:http h:example.com p:/%20foo
+http://example.com/foo%  s:http h:example.com p:/foo%
+http://example.com/foo%2  s:http h:example.com p:/foo%2
+http://example.com/foo%2zbar  s:http h:example.com p:/foo%2zbar
+http://example.com/foo%2\u00C2\u00A9zbar  s:http h:example.com p:/foo%2%C3%82%C2%A9zbar
+http://example.com/foo%41%7a  s:http h:example.com p:/foo%41%7a
+http://example.com/foo\t\u0091%91  s:http h:example.com p:/foo%C2%91%91
+http://example.com/foo%00%51  s:http h:example.com p:/foo%00%51
+http://example.com/(%28:%3A%29)  s:http h:example.com p:/(%28:%3A%29)
+http://example.com/%3A%3a%3C%3c  s:http h:example.com p:/%3A%3a%3C%3c
+http://example.com/foo\tbar  s:http h:example.com p:/foobar
+http://example.com\\\\foo\\\\bar  s:http h:example.com p://foo//bar
+http://example.com/%7Ffp3%3Eju%3Dduvgw%3Dd  s:http h:example.com p:/%7Ffp3%3Eju%3Dduvgw%3Dd
+http://example.com/@asdf%40  s:http h:example.com p:/@asdf%40
+http://example.com/\u4F60\u597D\u4F60\u597D  s:http h:example.com p:/%E4%BD%A0%E5%A5%BD%E4%BD%A0%E5%A5%BD
+http://example.com/\u2025/foo  s:http h:example.com p:/%E2%80%A5/foo
+http://example.com/\uFEFF/foo  s:http h:example.com p:/%EF%BB%BF/foo
+http://example.com/\u202E/foo/\u202D/bar  s:http h:example.com p:/%E2%80%AE/foo/%E2%80%AD/bar
+
+# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/script-tests/relative.js
+http://www.google.com/foo?bar=baz# about:blank s:http h:www.google.com p:/foo q:?bar=baz f:#
+http://www.google.com/foo?bar=baz#\s\u00BB  s:http h:www.google.com p:/foo q:?bar=baz f:#\s\u00BB
+data:test#\s\u00BB  s:data p:test f:#\s\u00BB
+http://[www.google.com]/
+http://www.google.com  s:http h:www.google.com p:/
+http://192.0x00A80001  s:http h:192.168.0.1 p:/
+http://www/foo%2Ehtml  s:http h:www p:/foo%2Ehtml
+http://www/foo/%2E/html  s:http h:www p:/foo/html
+http://user:pass@/
+http://%25DOMAIN:foobar@foodomain.com/  s:http u:%25DOMAIN pass:foobar h:foodomain.com p:/
+http:\\\\www.google.com\\foo  s:http h:www.google.com p:/foo
+http://foo:80/  s:http h:foo p:/
+http://foo:81/  s:http h:foo port:81 p:/
+httpa://foo:80/  s:httpa p://foo:80/
+http://foo:-80/
+https://foo:443/  s:https h:foo p:/
+https://foo:80/  s:https h:foo port:80 p:/
+ftp://foo:21/  s:ftp h:foo p:/
+ftp://foo:80/  s:ftp h:foo port:80 p:/
+gopher://foo:70/  s:gopher h:foo p:/
+gopher://foo:443/  s:gopher h:foo port:443 p:/
+ws://foo:80/  s:ws h:foo p:/
+ws://foo:81/  s:ws h:foo port:81 p:/
+ws://foo:443/  s:ws h:foo port:443 p:/
+ws://foo:815/  s:ws h:foo port:815 p:/
+wss://foo:80/  s:wss h:foo port:80 p:/
+wss://foo:81/  s:wss h:foo port:81 p:/
+wss://foo:443/  s:wss h:foo p:/
+wss://foo:815/  s:wss h:foo port:815 p:/
+http:/example.com/  s:http h:example.com p:/
+ftp:/example.com/  s:ftp h:example.com p:/
+https:/example.com/  s:https h:example.com p:/
+madeupscheme:/example.com/  s:madeupscheme p:/example.com/
+file:/example.com/  s:file p:/example.com/
+ftps:/example.com/  s:ftps p:/example.com/
+gopher:/example.com/  s:gopher h:example.com p:/
+ws:/example.com/  s:ws h:example.com p:/
+wss:/example.com/  s:wss h:example.com p:/
+data:/example.com/  s:data p:/example.com/
+javascript:/example.com/  s:javascript p:/example.com/
+mailto:/example.com/  s:mailto p:/example.com/
+http:example.com/  s:http h:example.com p:/
+ftp:example.com/  s:ftp h:example.com p:/
+https:example.com/  s:https h:example.com p:/
+madeupscheme:example.com/  s:madeupscheme p:example.com/
+ftps:example.com/  s:ftps p:example.com/
+gopher:example.com/  s:gopher h:example.com p:/
+ws:example.com/  s:ws h:example.com p:/
+wss:example.com/  s:wss h:example.com p:/
+data:example.com/  s:data p:example.com/
+javascript:example.com/  s:javascript p:example.com/
+mailto:example.com/  s:mailto p:example.com/
+
+# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/segments-userinfo-vs-host.html
+http:@www.example.com about:blank s:http h:www.example.com p:/
+http:/@www.example.com  s:http h:www.example.com p:/
+http://@www.example.com  s:http h:www.example.com p:/
+http:a:b@www.example.com  s:http u:a pass:b h:www.example.com p:/
+http:/a:b@www.example.com  s:http u:a pass:b h:www.example.com p:/
+http://a:b@www.example.com  s:http u:a pass:b h:www.example.com p:/
+http://@pple.com  s:http h:pple.com p:/
+http::b@www.example.com  s:http pass:b h:www.example.com p:/
+http:/:b@www.example.com  s:http pass:b h:www.example.com p:/
+http://:b@www.example.com  s:http pass:b h:www.example.com p:/
+http:/:@/www.example.com
+http://user@/www.example.com
+http:@/www.example.com
+http:/@/www.example.com
+http://@/www.example.com
+https:@/www.example.com
+http:a:b@/www.example.com
+http:/a:b@/www.example.com
+http://a:b@/www.example.com
+http::@/www.example.com
+http:a:@www.example.com  s:http u:a pass: h:www.example.com p:/
+http:/a:@www.example.com  s:http u:a pass: h:www.example.com p:/
+http://a:@www.example.com  s:http u:a pass: h:www.example.com p:/
+http://www.@pple.com  s:http u:www. h:pple.com p:/
+http:@:www.example.com
+http:/@:www.example.com
+http://@:www.example.com
+http://:@www.example.com  s:http pass: h:www.example.com p:/
+
+#Others
+/ http://www.example.com/test s:http h:www.example.com p:/
+/test.txt  s:http h:www.example.com p:/test.txt
+.  s:http h:www.example.com p:/
+..  s:http h:www.example.com p:/
+test.txt  s:http h:www.example.com p:/test.txt
+./test.txt  s:http h:www.example.com p:/test.txt
+../test.txt  s:http h:www.example.com p:/test.txt
+../aaa/test.txt  s:http h:www.example.com p:/aaa/test.txt
+../../test.txt  s:http h:www.example.com p:/test.txt
+\u4E2D/test.txt  s:http h:www.example.com p:/%E4%B8%AD/test.txt
+http://www.example2.com  s:http h:www.example2.com p:/
+//www.example2.com  s:http h:www.example2.com p:/
+
+# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/host.html
+
+# Basic canonicalization, uppercase should be converted to lowercase
+http://ExAmPlE.CoM http://other.com/ s:http p:/ h:example.com
+
+# Spaces should fail
+http://example\sexample.com
+
+# This should fail
+http://Goo%20\sgoo%7C|.com
+
+# U+3000 is mapped to U+0020 (space) which is disallowed
+http://GOO\u00a0\u3000goo.com
+
+# Other types of space (no-break, zero-width, zero-width-no-break) are
+# name-prepped away to nothing.
+# U+200B, U+2060, and U+FEFF, are ignored
+http://GOO\u200b\u2060\ufeffgoo.com  s:http p:/ h:googoo.com
+
+# Ideographic full stop (full-width period for Chinese, etc.) should be
+# treated as a dot.
+# U+3002 is mapped to U+002E (dot)
+http://www.foo\u3002bar.com  s:http p:/ h:www.foo.bar.com
+
+# Invalid unicode characters should fail...
+# U+FDD0 is disallowed; %ef%b7%90 is U+FDD0
+http://\ufdd0zyx.com
+
+# ...This is the same as previous but escaped.
+http://%ef%b7%90zyx.com
+
+# Test name prepping, fullwidth input should be converted to ASCII and NOT
+# IDN-ized. This is "Go" in fullwidth UTF-8/UTF-16.
+http://\uff27\uff4f.com  s:http p:/ h:go.com
+
+# URL spec forbids the following.
+# https://www.w3.org/Bugs/Public/show_bug.cgi?id=24257
+http://\uff05\uff14\uff11.com
+http://%ef%bc%85%ef%bc%94%ef%bc%91.com
+
+# ...%00 in fullwidth should fail (also as escaped UTF-8 input)
+http://\uff05\uff10\uff10.com
+http://%ef%bc%85%ef%bc%90%ef%bc%90.com
+
+# Basic IDN support, UTF-8 and UTF-16 input should be converted to IDN
+http://\u4f60\u597d\u4f60\u597d  s:http p:/ h:xn--6qqa088eba
+
+# Invalid escaped characters should fail and the percents should be
+# escaped. https://www.w3.org/Bugs/Public/show_bug.cgi?id=24191
+http://%zz%66%a.com
+
+# If we get an invalid character that has been escaped.
+http://%25
+http://hello%00
+
+# Escaped numbers should be treated like IP addresses if they are.
+# No special handling for IPv4 or IPv4-like URLs
+http://%30%78%63%30%2e%30%32%35%30.01  s:http p:/ h:192.168.0.1
+http://%30%78%63%30%2e%30%32%35%30.01%2e  s:http p:/ h:0xc0.0250.01.
+http://192.168.0.257
+
+# Invalid escaping should trigger the regular host error handling.
+http://%3g%78%63%30%2e%30%32%35%30%2E.01
+
+# Something that isn't exactly an IP should get treated as a host and
+# spaces escaped.
+http://192.168.0.1\shello
+
+# Fullwidth and escaped UTF-8 fullwidth should still be treated as IP.
+# These are "0Xc0.0250.01" in fullwidth.
+http://\uff10\uff38\uff43\uff10\uff0e\uff10\uff12\uff15\uff10\uff0e\uff10\uff11  s:http p:/ h:192.168.0.1
+
+# Broken IPv6
+http://[google.com]
+
+# Misc Unicode
+http://foo:\uD83D\uDCA9@example.com/bar  s:http h:example.com p:/bar u:foo pass:%F0%9F%92%A9
+
+# resolving a relative reference against an unknown scheme results in an error
+x test:test
+
diff --git a/okhttp-urlconnection/pom.xml b/okhttp-urlconnection/pom.xml
index 932a072..a093827 100644
--- a/okhttp-urlconnection/pom.xml
+++ b/okhttp-urlconnection/pom.xml
@@ -18,6 +18,12 @@
       <artifactId>okhttp</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>com.squareup.okhttp</groupId>
+      <artifactId>okhttp-testing-support</artifactId>
+      <version>${project.version}</version>
+      <scope>test</scope>
+    </dependency>
 
     <dependency>
       <groupId>junit</groupId>
diff --git a/okhttp-urlconnection/src/main/java/com/squareup/okhttp/internal/huc/HttpURLConnectionImpl.java b/okhttp-urlconnection/src/main/java/com/squareup/okhttp/internal/huc/HttpURLConnectionImpl.java
index 04ac552..d09e971 100644
--- a/okhttp-urlconnection/src/main/java/com/squareup/okhttp/internal/huc/HttpURLConnectionImpl.java
+++ b/okhttp-urlconnection/src/main/java/com/squareup/okhttp/internal/huc/HttpURLConnectionImpl.java
@@ -23,15 +23,18 @@
 import com.squareup.okhttp.OkHttpClient;
 import com.squareup.okhttp.Protocol;
 import com.squareup.okhttp.Request;
+import com.squareup.okhttp.RequestBody;
 import com.squareup.okhttp.Response;
 import com.squareup.okhttp.Route;
 import com.squareup.okhttp.internal.Internal;
 import com.squareup.okhttp.internal.Platform;
+import com.squareup.okhttp.internal.http.RouteException;
 import com.squareup.okhttp.internal.Util;
 import com.squareup.okhttp.internal.http.HttpDate;
 import com.squareup.okhttp.internal.http.HttpEngine;
 import com.squareup.okhttp.internal.http.HttpMethod;
 import com.squareup.okhttp.internal.http.OkHeaders;
+import com.squareup.okhttp.internal.http.RequestException;
 import com.squareup.okhttp.internal.http.RetryableSink;
 import com.squareup.okhttp.internal.http.StatusLine;
 import java.io.FileNotFoundException;
@@ -73,6 +76,7 @@
 public class HttpURLConnectionImpl extends HttpURLConnection {
   private static final Set<String> METHODS = new LinkedHashSet<>(
       Arrays.asList("OPTIONS", "GET", "HEAD", "POST", "PUT", "DELETE", "TRACE", "PATCH"));
+  private static final RequestBody EMPTY_REQUEST_BODY = RequestBody.create(null, new byte[0]);
 
   final OkHttpClient client;
 
@@ -314,9 +318,13 @@
 
   private HttpEngine newHttpEngine(String method, Connection connection,
       RetryableSink requestBody, Response priorResponse) {
+    // OkHttp's Call API requires a placeholder body; the real body will be streamed separately.
+    RequestBody placeholderBody = HttpMethod.requiresRequestBody(method)
+        ? EMPTY_REQUEST_BODY
+        : null;
     Request.Builder builder = new Request.Builder()
         .url(getURL())
-        .method(method, null /* No body; that's passed separately. */);
+        .method(method, placeholderBody);
     Headers headers = requestHeaders.build();
     for (int i = 0, size = headers.size(); i < size; i++) {
       builder.addHeader(headers.name(i), headers.value(i));
@@ -432,7 +440,25 @@
       }
 
       return true;
+    } catch (RequestException e) {
+      // An attempt to interpret a request failed.
+      IOException toThrow = e.getCause();
+      httpEngineFailure = toThrow;
+      throw toThrow;
+    } catch (RouteException e) {
+      // The attempt to connect via a route failed. The request will not have been sent.
+      HttpEngine retryEngine = httpEngine.recover(e);
+      if (retryEngine != null) {
+        httpEngine = retryEngine;
+        return false;
+      }
+
+      // Give up; recovery is not possible.
+      IOException toThrow = e.getLastConnectException();
+      httpEngineFailure = toThrow;
+      throw toThrow;
     } catch (IOException e) {
+      // An attempt to communicate with a server failed. The request may have been sent.
       HttpEngine retryEngine = httpEngine.recover(e);
       if (retryEngine != null) {
         httpEngine = retryEngine;
diff --git a/okhttp-ws-tests/pom.xml b/okhttp-ws-tests/pom.xml
index 424f4a5..af4ea7e 100644
--- a/okhttp-ws-tests/pom.xml
+++ b/okhttp-ws-tests/pom.xml
@@ -15,6 +15,12 @@
   <dependencies>
     <dependency>
       <groupId>com.squareup.okhttp</groupId>
+      <artifactId>okhttp-testing-support</artifactId>
+      <version>${project.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>com.squareup.okhttp</groupId>
       <artifactId>okhttp-ws</artifactId>
       <version>${project.version}</version>
     </dependency>
diff --git a/okhttp-ws-tests/src/test/java/com/squareup/okhttp/internal/ws/RealWebSocketTest.java b/okhttp-ws-tests/src/test/java/com/squareup/okhttp/internal/ws/RealWebSocketTest.java
index 857f00c..241376d 100644
--- a/okhttp-ws-tests/src/test/java/com/squareup/okhttp/internal/ws/RealWebSocketTest.java
+++ b/okhttp-ws-tests/src/test/java/com/squareup/okhttp/internal/ws/RealWebSocketTest.java
@@ -19,7 +19,10 @@
 import java.io.IOException;
 import java.net.ProtocolException;
 import java.util.Random;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
 import okio.Buffer;
 import okio.BufferedSink;
 import okio.ByteString;
@@ -39,12 +42,14 @@
   // zero effect on the behavior of the WebSocket API which is why tests are only written once
   // from the perspective of a single peer.
 
+  private final Executor clientExecutor = Executors.newSingleThreadExecutor();
   private RealWebSocket client;
   private boolean clientConnectionCloseThrows;
   private boolean clientConnectionClosed;
   private final Buffer client2Server = new Buffer();
   private final WebSocketRecorder clientListener = new WebSocketRecorder();
 
+  private final Executor serverExecutor = Executors.newSingleThreadExecutor();
   private RealWebSocket server;
   private final Buffer server2client = new Buffer();
   private final WebSocketRecorder serverListener = new WebSocketRecorder();
@@ -53,13 +58,7 @@
     Random random = new Random(0);
     String url = "http://example.com/websocket";
 
-    Executor synchronousExecutor = new Executor() {
-      @Override public void execute(Runnable command) {
-        command.run();
-      }
-    };
-
-    client = new RealWebSocket(true, server2client, client2Server, random, synchronousExecutor,
+    client = new RealWebSocket(true, server2client, client2Server, random, clientExecutor,
         clientListener, url) {
       @Override protected void closeConnection() throws IOException {
         clientConnectionClosed = true;
@@ -68,7 +67,7 @@
         }
       }
     };
-    server = new RealWebSocket(false, client2Server, server2client, random, synchronousExecutor,
+    server = new RealWebSocket(false, client2Server, server2client, random, serverExecutor,
         serverListener, url) {
       @Override protected void closeConnection() throws IOException {
       }
@@ -109,6 +108,7 @@
     sink.close();
     server.readMessage();
     serverListener.assertTextMessage("Hello!");
+    waitForExecutor(serverExecutor); // Pong write happens asynchronously.
     client.readMessage();
     clientListener.assertPong(new Buffer().writeUtf8("Pong?"));
   }
@@ -116,6 +116,7 @@
   @Test public void pingWritesPong() throws IOException, InterruptedException {
     client.sendPing(new Buffer().writeUtf8("Hello!"));
     server.readMessage(); // Read the ping, write the pong.
+    waitForExecutor(serverExecutor); // Pong write happens asynchronously.
     client.readMessage(); // Read the pong.
     clientListener.assertPong(new Buffer().writeUtf8("Hello!"));
   }
@@ -128,9 +129,9 @@
 
   @Test public void close() throws IOException {
     client.close(1000, "Hello!");
-    server.readMessage(); // This will trigger a close response.
+    assertFalse(server.readMessage()); // This will trigger a close response.
     serverListener.assertClose(1000, "Hello!");
-    client.readMessage();
+    assertFalse(client.readMessage());
     clientListener.assertClose(1000, "Hello!");
   }
 
@@ -224,7 +225,8 @@
     server.readMessage(); // Read client close, send server close.
     serverListener.assertClose(1000, "Hello!");
 
-    client.readMessage();
+    client.readMessage(); // Read server close.
+    waitForExecutor(clientExecutor); // Close happens asynchronously.
     assertTrue(clientConnectionClosed);
     clientListener.assertClose(1000, "Hello!");
   }
@@ -247,6 +249,7 @@
     assertFalse(clientConnectionClosed);
 
     client.readMessage(); // Read close, should NOT send close.
+    waitForExecutor(clientExecutor); // Close happens asynchronously.
     assertTrue(clientConnectionClosed);
     clientListener.assertClose(1000, "Hello!");
 
@@ -300,4 +303,20 @@
     server.readMessage();
     serverListener.assertClose(1000, "Bye!");
   }
+
+  private static void waitForExecutor(Executor executor) {
+    final CountDownLatch latch = new CountDownLatch(1);
+    executor.execute(new Runnable() {
+      @Override public void run() {
+        latch.countDown();
+      }
+    });
+    try {
+      if (!latch.await(10, TimeUnit.SECONDS)) {
+        throw new IllegalStateException("Timed out waiting for executor.");
+      }
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+    }
+  }
 }
diff --git a/okhttp-ws-tests/src/test/java/com/squareup/okhttp/internal/ws/WebSocketWriterTest.java b/okhttp-ws-tests/src/test/java/com/squareup/okhttp/internal/ws/WebSocketWriterTest.java
index d31687b..a98e6bb 100644
--- a/okhttp-ws-tests/src/test/java/com/squareup/okhttp/internal/ws/WebSocketWriterTest.java
+++ b/okhttp-ws-tests/src/test/java/com/squareup/okhttp/internal/ws/WebSocketWriterTest.java
@@ -206,12 +206,8 @@
   }
 
   @Test public void closeWithOnlyReasonThrows() throws IOException {
-    try {
-      clientWriter.writeClose(0, "Hello");
-      fail();
-    } catch (IllegalArgumentException e) {
-      assertEquals("Code required to include reason.", e.getMessage());
-    }
+    clientWriter.writeClose(0, "Hello");
+    assertData("888760b420bb60b468de0cd84f");
   }
 
   @Test public void closeCodeOutOfRangeThrows() throws IOException {
diff --git a/okhttp-ws-tests/src/test/java/com/squareup/okhttp/ws/AutobahnTester.java b/okhttp-ws-tests/src/test/java/com/squareup/okhttp/ws/AutobahnTester.java
new file mode 100644
index 0000000..037903c
--- /dev/null
+++ b/okhttp-ws-tests/src/test/java/com/squareup/okhttp/ws/AutobahnTester.java
@@ -0,0 +1,187 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp.ws;
+
+import com.squareup.okhttp.OkHttpClient;
+import com.squareup.okhttp.Request;
+import com.squareup.okhttp.Response;
+import com.squareup.okhttp.internal.Version;
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import okio.Buffer;
+import okio.BufferedSource;
+
+/**
+ * Exercises the web socket implementation against the
+ * <a href="http://autobahn.ws/testsuite/">Autobahn Testsuite</a>.
+ */
+public final class AutobahnTester {
+  private static final String HOST = "ws://localhost:9001";
+
+  public static void main(String... args) throws IOException {
+    new AutobahnTester().run();
+  }
+
+  final OkHttpClient client = new OkHttpClient();
+
+  private WebSocketCall newWebSocket(String path) {
+    Request request = new Request.Builder().url(HOST + path).build();
+    return WebSocketCall.create(client, request);
+  }
+
+  public void run() throws IOException {
+    try {
+      long count = getTestCount();
+      System.out.println("Test count: " + count);
+
+      for (long number = 1; number <= count; number++) {
+        runTest(number, count);
+      }
+
+      updateReports();
+    } finally {
+      client.getDispatcher().getExecutorService().shutdown();
+    }
+  }
+
+  private void runTest(final long number, final long count) throws IOException {
+    final CountDownLatch latch = new CountDownLatch(1);
+    newWebSocket("/runCase?case=" + number + "&agent=" + Version.userAgent()) //
+        .enqueue(new WebSocketListener() {
+          private final ExecutorService sendExecutor = Executors.newSingleThreadExecutor();
+          private WebSocket webSocket;
+
+          @Override public void onOpen(WebSocket webSocket, Request request, Response response)
+              throws IOException {
+            System.out.println("Executing test case " + number + "/" + count);
+            this.webSocket = webSocket;
+          }
+
+          @Override public void onMessage(BufferedSource payload, final WebSocket.PayloadType type)
+              throws IOException {
+            final Buffer buffer = new Buffer();
+            payload.readAll(buffer);
+            payload.close();
+
+            sendExecutor.execute(new Runnable() {
+              @Override public void run() {
+                try {
+                  webSocket.sendMessage(type, buffer);
+                } catch (IOException e) {
+                  e.printStackTrace();
+                }
+              }
+            });
+          }
+
+          @Override public void onPong(Buffer payload) {
+          }
+
+          @Override public void onClose(int code, String reason) {
+            sendExecutor.shutdown();
+            latch.countDown();
+          }
+
+          @Override public void onFailure(IOException e) {
+            latch.countDown();
+          }
+        });
+    try {
+      if (!latch.await(10, TimeUnit.SECONDS)) {
+        throw new IllegalStateException("Timed out waiting for count.");
+      }
+    } catch (InterruptedException e) {
+      throw new AssertionError();
+    }
+  }
+
+  private long getTestCount() throws IOException {
+    final CountDownLatch latch = new CountDownLatch(1);
+    final AtomicLong countRef = new AtomicLong();
+    final AtomicReference<IOException> failureRef = new AtomicReference<>();
+    newWebSocket("/getCaseCount").enqueue(new WebSocketListener() {
+      @Override public void onOpen(WebSocket webSocket, Request request, Response response)
+          throws IOException {
+      }
+
+      @Override public void onMessage(BufferedSource payload, WebSocket.PayloadType type)
+          throws IOException {
+        countRef.set(payload.readDecimalLong());
+        payload.close();
+      }
+
+      @Override public void onPong(Buffer payload) {
+      }
+
+      @Override public void onClose(int code, String reason) {
+        latch.countDown();
+      }
+
+      @Override public void onFailure(IOException e) {
+        failureRef.set(e);
+        latch.countDown();
+      }
+    });
+    try {
+      if (!latch.await(10, TimeUnit.SECONDS)) {
+        throw new IllegalStateException("Timed out waiting for count.");
+      }
+    } catch (InterruptedException e) {
+      throw new AssertionError();
+    }
+    IOException failure = failureRef.get();
+    if (failure != null) {
+      throw failure;
+    }
+    return countRef.get();
+  }
+
+  private void updateReports() {
+    final CountDownLatch latch = new CountDownLatch(1);
+    newWebSocket("/updateReports?agent=" + Version.userAgent()).enqueue(new WebSocketListener() {
+      @Override public void onOpen(WebSocket webSocket, Request request, Response response)
+          throws IOException {
+      }
+
+      @Override public void onMessage(BufferedSource payload, WebSocket.PayloadType type)
+          throws IOException {
+      }
+
+      @Override public void onPong(Buffer payload) {
+      }
+
+      @Override public void onClose(int code, String reason) {
+        latch.countDown();
+      }
+
+      @Override public void onFailure(IOException e) {
+        latch.countDown();
+      }
+    });
+    try {
+      if (!latch.await(10, TimeUnit.SECONDS)) {
+        throw new IllegalStateException("Timed out waiting for count.");
+      }
+    } catch (InterruptedException e) {
+      throw new AssertionError();
+    }
+  }
+}
diff --git a/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/RealWebSocket.java b/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/RealWebSocket.java
index a647ac7..07d763b 100644
--- a/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/RealWebSocket.java
+++ b/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/RealWebSocket.java
@@ -69,9 +69,17 @@
       }
 
       @Override public void onClose(final int code, final String reason) {
+        final boolean writeCloseResponse;
+        synchronized (closeLock) {
+          readerSentClose = true;
+
+          // If the writer has not indicated a desire to close we will write a close response.
+          writeCloseResponse = !writerSentClose;
+        }
+
         replyExecutor.execute(new NamedRunnable("OkHttp %s WebSocket Close Reply", url) {
           @Override protected void execute() {
-            peerClose(code, reason);
+            peerClose(code, reason, writeCloseResponse);
           }
         });
       }
@@ -132,15 +140,7 @@
   }
 
   /** Replies and closes this web socket when a close frame is read from the peer. */
-  private void peerClose(int code, String reason) {
-    boolean writeCloseResponse;
-    synchronized (closeLock) {
-      readerSentClose = true;
-
-      // If the writer has not indicated a desire to close we will write a close response.
-      writeCloseResponse = !writerSentClose;
-    }
-
+  private void peerClose(int code, String reason, boolean writeCloseResponse) {
     if (writeCloseResponse) {
       try {
         writer.writeClose(code, reason);
diff --git a/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/WebSocketReader.java b/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/WebSocketReader.java
index ee4e482..ce548b1 100644
--- a/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/WebSocketReader.java
+++ b/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/WebSocketReader.java
@@ -89,7 +89,7 @@
    * <ul>
    * <li>If it is a control frame this will result in a single call to {@link FrameCallback}.</li>
    * <li>If it is a message frame this will result in a single call to {@link
-   * WebSocketListener#onMessage}. If the message spans multiple frames, each interleaved control
+   * FrameCallback#onMessage}. If the message spans multiple frames, each interleaved control
    * frame will result in a corresponding call to {@link FrameCallback}.
    * </ul>
    */
@@ -185,14 +185,21 @@
         int code = 0;
         String reason = "";
         if (buffer != null) {
+          if (buffer.size() < 2) {
+            throw new ProtocolException("Close payload must be at least two bytes.");
+          }
           code = buffer.readShort();
+          if (code < 1000 || code >= 5000) {
+            throw new ProtocolException("Code must be in range [1000,5000): " + code);
+          }
+
           reason = buffer.readUtf8();
         }
         frameCallback.onClose(code, reason);
         closed = true;
         break;
       default:
-        throw new IllegalStateException("Unknown control opcode: " + toHexString(opcode));
+        throw new ProtocolException("Unknown control opcode: " + toHexString(opcode));
     }
   }
 
@@ -206,7 +213,7 @@
         type = PayloadType.BINARY;
         break;
       default:
-        throw new IllegalStateException("Unknown opcode: " + toHexString(opcode));
+        throw new ProtocolException("Unknown opcode: " + toHexString(opcode));
     }
 
     messageClosed = false;
diff --git a/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/WebSocketWriter.java b/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/WebSocketWriter.java
index 74bd083..fc5de75 100644
--- a/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/WebSocketWriter.java
+++ b/okhttp-ws/src/main/java/com/squareup/okhttp/internal/ws/WebSocketWriter.java
@@ -93,12 +93,12 @@
    * @param code Status code as defined by
    * <a href="http://tools.ietf.org/html/rfc6455#section-7.4">Section 7.4 of RFC 6455</a> or
    * {@code 0}.
-   * @param reason Reason for shutting down or {@code null}. {@code code} is required if set.
+   * @param reason Reason for shutting down or {@code null}.
    */
   public void writeClose(int code, String reason) throws IOException {
     Buffer payload = null;
-    if (code != 0) {
-      if (code < 1000 || code >= 5000) {
+    if (code != 0 || reason != null) {
+      if (code != 0 && (code < 1000 || code >= 5000)) {
         throw new IllegalArgumentException("Code must be in range [1000,5000).");
       }
       payload = new Buffer();
@@ -106,8 +106,6 @@
       if (reason != null) {
         payload.writeUtf8(reason);
       }
-    } else if (reason != null) {
-      throw new IllegalArgumentException("Code required to include reason.");
     }
 
     synchronized (sink) {
diff --git a/okhttp-ws/src/main/java/com/squareup/okhttp/ws/WebSocketCall.java b/okhttp-ws/src/main/java/com/squareup/okhttp/ws/WebSocketCall.java
index 422167c..b499485 100644
--- a/okhttp-ws/src/main/java/com/squareup/okhttp/ws/WebSocketCall.java
+++ b/okhttp-ws/src/main/java/com/squareup/okhttp/ws/WebSocketCall.java
@@ -28,7 +28,6 @@
 import com.squareup.okhttp.internal.ws.WebSocketProtocol;
 import java.io.IOException;
 import java.net.ProtocolException;
-import java.net.Socket;
 import java.security.SecureRandom;
 import java.util.Collections;
 import java.util.Random;
@@ -38,7 +37,6 @@
 import okio.BufferedSink;
 import okio.BufferedSource;
 import okio.ByteString;
-import okio.Okio;
 
 import static java.util.concurrent.TimeUnit.SECONDS;
 
@@ -174,13 +172,17 @@
       throw new IllegalStateException("Unable to take ownership of connection.");
     }
 
-    Socket socket = connection.getSocket();
-    BufferedSource source = Okio.buffer(Okio.source(socket));
-    BufferedSink sink = Okio.buffer(Okio.sink(socket));
+    BufferedSource source = Internal.instance.connectionRawSource(connection);
+    BufferedSink sink = Internal.instance.connectionRawSink(connection);
 
     final RealWebSocket webSocket =
         ConnectionWebSocket.create(response, connection, source, sink, random, listener);
 
+    // TODO connection.setOwner(webSocket);
+    Internal.instance.connectionSetOwner(connection, webSocket);
+
+    listener.onOpen(webSocket, request, response);
+
     // Start a dedicated thread for reading the web socket.
     new Thread(new NamedRunnable("OkHttp WebSocket reader %s", request.urlString()) {
       @Override protected void execute() {
@@ -188,11 +190,6 @@
         }
       }
     }).start();
-
-    // TODO connection.setOwner(webSocket);
-    Internal.instance.connectionSetOwner(connection, webSocket);
-
-    listener.onOpen(webSocket, request, response);
   }
 
   // Keep static so that the WebSocketCall instance can be garbage collected.
diff --git a/okhttp/src/main/java/com/squareup/okhttp/Address.java b/okhttp/src/main/java/com/squareup/okhttp/Address.java
index 38768a4..6f6ce08 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/Address.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/Address.java
@@ -139,6 +139,13 @@
     return proxySelector;
   }
 
+  /**
+   * Returns this address's certificate pinner. Only used for secure connections.
+   */
+  public CertificatePinner getCertificatePinner() {
+    return certificatePinner;
+  }
+
   @Override public boolean equals(Object other) {
     if (other instanceof Address) {
       Address that = (Address) other;
diff --git a/okhttp/src/main/java/com/squareup/okhttp/Call.java b/okhttp/src/main/java/com/squareup/okhttp/Call.java
index 107c37c..99393cf 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/Call.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/Call.java
@@ -16,7 +16,9 @@
 package com.squareup.okhttp;
 
 import com.squareup.okhttp.internal.NamedRunnable;
+import com.squareup.okhttp.internal.http.RouteException;
 import com.squareup.okhttp.internal.http.HttpEngine;
+import com.squareup.okhttp.internal.http.RequestException;
 import java.io.IOException;
 import java.net.MalformedURLException;
 import java.net.ProtocolException;
@@ -31,7 +33,7 @@
  * canceled. As this object represents a single request/response pair (stream),
  * it cannot be executed twice.
  */
-public final class Call {
+public class Call {
   private final OkHttpClient client;
 
   // Guarded by this.
@@ -264,13 +266,26 @@
     while (true) {
       if (canceled) {
         engine.releaseConnection();
-        return null;
+        throw new IOException("Canceled");
       }
 
       try {
         engine.sendRequest();
         engine.readResponse();
+      } catch (RequestException e) {
+        // The attempt to interpret the request failed. Give up.
+        throw e.getCause();
+      } catch (RouteException e) {
+        // The attempt to connect via a route failed. The request will not have been sent.
+        HttpEngine retryEngine = engine.recover(e);
+        if (retryEngine != null) {
+          engine = retryEngine;
+          continue;
+        }
+        // Give up; recovery is not possible.
+        throw e.getLastConnectException();
       } catch (IOException e) {
+        // An attempt to communicate with a server failed. The request may have been sent.
         HttpEngine retryEngine = engine.recover(e, null);
         if (retryEngine != null) {
           engine = retryEngine;
diff --git a/okhttp/src/main/java/com/squareup/okhttp/CertificatePinner.java b/okhttp/src/main/java/com/squareup/okhttp/CertificatePinner.java
index 2c5a2af..49221b7 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/CertificatePinner.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/CertificatePinner.java
@@ -100,6 +100,13 @@
  * complexity and limit your ability to migrate between certificate authorities.
  * Do not use certificate pinning without the blessing of your server's TLS
  * administrator!
+ *
+ * <h4>Note about self-signed certificates</h4>
+ * {@link CertificatePinner} can not be used to pin self-signed certificate
+ * if such certificate is not accepted by {@link javax.net.ssl.TrustManager}.
+ *
+ * @see <a href="https://www.owasp.org/index.php/Certificate_and_Public_Key_Pinning">
+ *     OWASP: Certificate and Public Key Pinning</a>
  */
 public final class CertificatePinner {
   public static final CertificatePinner DEFAULT = new Builder().build();
diff --git a/okhttp/src/main/java/com/squareup/okhttp/Connection.java b/okhttp/src/main/java/com/squareup/okhttp/Connection.java
index 7dddc3a..6819952 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/Connection.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/Connection.java
@@ -16,30 +16,20 @@
  */
 package com.squareup.okhttp;
 
-import com.squareup.okhttp.internal.Platform;
-import com.squareup.okhttp.internal.Util;
 import com.squareup.okhttp.internal.http.HttpConnection;
 import com.squareup.okhttp.internal.http.HttpEngine;
 import com.squareup.okhttp.internal.http.HttpTransport;
-import com.squareup.okhttp.internal.http.OkHeaders;
+import com.squareup.okhttp.internal.http.RouteException;
+import com.squareup.okhttp.internal.http.SocketConnector;
 import com.squareup.okhttp.internal.http.SpdyTransport;
 import com.squareup.okhttp.internal.http.Transport;
 import com.squareup.okhttp.internal.spdy.SpdyConnection;
-import com.squareup.okhttp.internal.tls.OkHostnameVerifier;
 import java.io.IOException;
-import java.net.Proxy;
 import java.net.Socket;
-import java.net.URL;
-import java.security.cert.X509Certificate;
-import java.util.concurrent.TimeUnit;
-import javax.net.ssl.SSLPeerUnverifiedException;
-import javax.net.ssl.SSLSocket;
-import okio.Source;
-
-import static com.squareup.okhttp.internal.Util.getDefaultPort;
-import static com.squareup.okhttp.internal.Util.getEffectivePort;
-import static java.net.HttpURLConnection.HTTP_OK;
-import static java.net.HttpURLConnection.HTTP_PROXY_AUTH;
+import java.net.UnknownServiceException;
+import java.util.List;
+import okio.BufferedSink;
+import okio.BufferedSource;
 
 /**
  * The sockets and streams of an HTTP, HTTPS, or HTTPS+SPDY connection. May be
@@ -142,23 +132,42 @@
     socket.close();
   }
 
-  void connect(int connectTimeout, int readTimeout, int writeTimeout, Request tunnelRequest)
-      throws IOException {
+  void connect(int connectTimeout, int readTimeout, int writeTimeout, Request request,
+      List<ConnectionSpec> connectionSpecs, boolean connectionRetryEnabled) throws RouteException {
     if (connected) throw new IllegalStateException("already connected");
 
-    if (route.proxy.type() == Proxy.Type.DIRECT || route.proxy.type() == Proxy.Type.HTTP) {
-      socket = route.address.socketFactory.createSocket();
+    SocketConnector socketConnector = new SocketConnector(this, pool);
+    SocketConnector.ConnectedSocket connectedSocket;
+    if (route.address.getSslSocketFactory() != null) {
+      // https:// communication
+      connectedSocket = socketConnector.connectTls(connectTimeout, readTimeout, writeTimeout,
+          request, route, connectionSpecs, connectionRetryEnabled);
     } else {
-      socket = new Socket(route.proxy);
+      // http:// communication.
+      if (!connectionSpecs.contains(ConnectionSpec.CLEARTEXT)) {
+        throw new RouteException(
+            new UnknownServiceException(
+                "CLEARTEXT communication not supported: " + connectionSpecs));
+      }
+      connectedSocket = socketConnector.connectCleartext(connectTimeout, readTimeout, route);
     }
 
-    socket.setSoTimeout(readTimeout);
-    Platform.get().connectSocket(socket, route.inetSocketAddress, connectTimeout);
+    socket = connectedSocket.socket;
+    handshake = connectedSocket.handshake;
+    protocol = connectedSocket.alpnProtocol == null
+        ? Protocol.HTTP_1_1 : connectedSocket.alpnProtocol;
 
-    if (route.address.sslSocketFactory != null) {
-      upgradeToTls(tunnelRequest, readTimeout, writeTimeout);
-    } else {
-      httpConnection = new HttpConnection(pool, this, socket);
+    try {
+      if (protocol == Protocol.SPDY_3 || protocol == Protocol.HTTP_2) {
+        socket.setSoTimeout(0); // SPDY timeouts are set per-stream.
+        spdyConnection = new SpdyConnection.Builder(route.address.uriHost, true, socket)
+            .protocol(protocol).build();
+        spdyConnection.sendConnectionPreface();
+      } else {
+        httpConnection = new HttpConnection(pool, this, socket);
+      }
+    } catch (IOException e) {
+      throw new RouteException(e);
     }
     connected = true;
   }
@@ -167,13 +176,14 @@
    * Connects this connection if it isn't already. This creates tunnels, shares
    * the connection with the connection pool, and configures timeouts.
    */
-  void connectAndSetOwner(OkHttpClient client, Object owner, Request request) throws IOException {
+  void connectAndSetOwner(OkHttpClient client, Object owner, Request request)
+      throws RouteException {
     setOwner(owner);
 
     if (!isConnected()) {
-      Request tunnelRequest = tunnelRequest(request);
-      connect(client.getConnectTimeout(), client.getReadTimeout(),
-          client.getWriteTimeout(), tunnelRequest);
+      List<ConnectionSpec> connectionSpecs = route.address.getConnectionSpecs();
+      connect(client.getConnectTimeout(), client.getReadTimeout(), client.getWriteTimeout(),
+          request, connectionSpecs, client.getRetryOnConnectionFailure());
       if (isSpdy()) {
         client.getConnectionPool().share(this);
       }
@@ -183,97 +193,6 @@
     setTimeouts(client.getReadTimeout(), client.getWriteTimeout());
   }
 
-  /**
-   * Returns a request that creates a TLS tunnel via an HTTP proxy, or null if
-   * no tunnel is necessary. Everything in the tunnel request is sent
-   * unencrypted to the proxy server, so tunnels include only the minimum set of
-   * headers. This avoids sending potentially sensitive data like HTTP cookies
-   * to the proxy unencrypted.
-   */
-  private Request tunnelRequest(Request request) throws IOException {
-    if (!route.requiresTunnel()) return null;
-
-    String host = request.url().getHost();
-    int port = getEffectivePort(request.url());
-    String authority = (port == getDefaultPort("https")) ? host : (host + ":" + port);
-    Request.Builder result = new Request.Builder()
-        .url(new URL("https", host, port, "/"))
-        .header("Host", authority)
-        .header("Proxy-Connection", "Keep-Alive"); // For HTTP/1.0 proxies like Squid.
-
-    // Copy over the User-Agent header if it exists.
-    String userAgent = request.header("User-Agent");
-    if (userAgent != null) {
-      result.header("User-Agent", userAgent);
-    }
-
-    // Copy over the Proxy-Authorization header if it exists.
-    String proxyAuthorization = request.header("Proxy-Authorization");
-    if (proxyAuthorization != null) {
-      result.header("Proxy-Authorization", proxyAuthorization);
-    }
-
-    return result.build();
-  }
-
-  /**
-   * Create an {@code SSLSocket} and perform the TLS handshake and certificate
-   * validation.
-   */
-  private void upgradeToTls(Request tunnelRequest, int readTimeout, int writeTimeout)
-      throws IOException {
-    Platform platform = Platform.get();
-
-    // Make an SSL Tunnel on the first message pair of each SSL + proxy connection.
-    if (tunnelRequest != null) {
-      makeTunnel(tunnelRequest, readTimeout, writeTimeout);
-    }
-
-    // Create the wrapper over connected socket.
-    socket = route.address.sslSocketFactory
-        .createSocket(socket, route.address.uriHost, route.address.uriPort, true /* autoClose */);
-    SSLSocket sslSocket = (SSLSocket) socket;
-
-    // Configure the socket's ciphers, TLS versions, and extensions.
-    route.connectionSpec.apply(sslSocket, route);
-
-    try {
-      // Force handshake. This can throw!
-      sslSocket.startHandshake();
-
-      String maybeProtocol;
-      if (route.connectionSpec.supportsTlsExtensions()
-          && (maybeProtocol = platform.getSelectedProtocol(sslSocket)) != null) {
-        protocol = Protocol.get(maybeProtocol); // Throws IOE on unknown.
-      }
-    } finally {
-      platform.afterHandshake(sslSocket);
-    }
-
-    handshake = Handshake.get(sslSocket.getSession());
-
-    // Verify that the socket's certificates are acceptable for the target host.
-    if (!route.address.hostnameVerifier.verify(route.address.uriHost, sslSocket.getSession())) {
-      X509Certificate cert = (X509Certificate) sslSocket.getSession().getPeerCertificates()[0];
-      throw new SSLPeerUnverifiedException("Hostname " + route.address.uriHost + " not verified:"
-          + "\n    certificate: " + CertificatePinner.pin(cert)
-          + "\n    DN: " + cert.getSubjectDN().getName()
-          + "\n    subjectAltNames: " + OkHostnameVerifier.allSubjectAltNames(cert));
-    }
-
-    // Check that the certificate pinner is satisfied by the certificates presented.
-    route.address.certificatePinner.check(route.address.uriHost, handshake.peerCertificates());
-
-    if (protocol == Protocol.SPDY_3 || protocol == Protocol.HTTP_2) {
-      sslSocket.setSoTimeout(0); // SPDY timeouts are set per-stream.
-      spdyConnection = new SpdyConnection.Builder(route.address.getUriHost(), true, socket)
-          .protocol(protocol).build();
-      spdyConnection.sendConnectionPreface();
-    } else {
-      httpConnection = new HttpConnection(pool, this, socket);
-    }
-  }
-
   /** Returns true if {@link #connect} has been attempted on this connection. */
   boolean isConnected() {
     return connected;
@@ -292,6 +211,16 @@
     return socket;
   }
 
+  BufferedSource rawSource() {
+    if (httpConnection == null) throw new UnsupportedOperationException();
+    return httpConnection.rawSource();
+  }
+
+  BufferedSink rawSink() {
+    if (httpConnection == null) throw new UnsupportedOperationException();
+    return httpConnection.rawSink();
+  }
+
   /** Returns true if this connection is alive. */
   boolean isAlive() {
     return !socket.isClosed() && !socket.isInputShutdown() && !socket.isOutputShutdown();
@@ -361,12 +290,17 @@
     this.protocol = protocol;
   }
 
-  void setTimeouts(int readTimeoutMillis, int writeTimeoutMillis) throws IOException {
+  void setTimeouts(int readTimeoutMillis, int writeTimeoutMillis)
+      throws RouteException {
     if (!connected) throw new IllegalStateException("setTimeouts - not connected");
 
     // Don't set timeouts on shared SPDY connections.
     if (httpConnection != null) {
-      socket.setSoTimeout(readTimeoutMillis);
+      try {
+        socket.setSoTimeout(readTimeoutMillis);
+      } catch (IOException e) {
+        throw new RouteException(e);
+      }
       httpConnection.setTimeouts(readTimeoutMillis, writeTimeoutMillis);
     }
   }
@@ -383,55 +317,6 @@
     return recycleCount;
   }
 
-  /**
-   * To make an HTTPS connection over an HTTP proxy, send an unencrypted
-   * CONNECT request to create the proxy connection. This may need to be
-   * retried if the proxy requires authorization.
-   */
-  private void makeTunnel(Request request, int readTimeout, int writeTimeout)
-      throws IOException {
-    HttpConnection tunnelConnection = new HttpConnection(pool, this, socket);
-    tunnelConnection.setTimeouts(readTimeout, writeTimeout);
-    URL url = request.url();
-    String requestLine = "CONNECT " + url.getHost() + ":" + url.getPort() + " HTTP/1.1";
-    while (true) {
-      tunnelConnection.writeRequest(request.headers(), requestLine);
-      tunnelConnection.flush();
-      Response response = tunnelConnection.readResponse().request(request).build();
-      // The response body from a CONNECT should be empty, but if it is not then we should consume
-      // it before proceeding.
-      long contentLength = OkHeaders.contentLength(response);
-      if (contentLength == -1L) {
-        contentLength = 0L;
-      }
-      Source body = tunnelConnection.newFixedLengthSource(contentLength);
-      Util.skipAll(body, Integer.MAX_VALUE, TimeUnit.MILLISECONDS);
-      body.close();
-
-      switch (response.code()) {
-        case HTTP_OK:
-          // Assume the server won't send a TLS ServerHello until we send a TLS ClientHello. If that
-          // happens, then we will have buffered bytes that are needed by the SSLSocket!
-          // This check is imperfect: it doesn't tell us whether a handshake will succeed, just that
-          // it will almost certainly fail because the proxy has sent unexpected data.
-          if (tunnelConnection.bufferSize() > 0) {
-            throw new IOException("TLS tunnel buffered too many bytes!");
-          }
-          return;
-
-        case HTTP_PROXY_AUTH:
-          request = OkHeaders.processAuthHeader(
-              route.address.authenticator, response, route.proxy);
-          if (request != null) continue;
-          throw new IOException("Failed to authenticate with proxy");
-
-        default:
-          throw new IOException(
-              "Unexpected response code for CONNECT: " + response.code());
-      }
-    }
-  }
-
   @Override public String toString() {
     return "Connection{"
         + route.address.uriHost + ":" + route.address.uriPort
diff --git a/okhttp/src/main/java/com/squareup/okhttp/ConnectionSpec.java b/okhttp/src/main/java/com/squareup/okhttp/ConnectionSpec.java
index e3d5936..5e0f7d8 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/ConnectionSpec.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/ConnectionSpec.java
@@ -15,7 +15,6 @@
  */
 package com.squareup.okhttp;
 
-import com.squareup.okhttp.internal.Platform;
 import com.squareup.okhttp.internal.Util;
 import java.util.Arrays;
 import java.util.List;
@@ -23,36 +22,38 @@
 
 /**
  * Specifies configuration for the socket connection that HTTP traffic travels through. For {@code
- * https:} URLs, this includes the TLS version and ciphers to use when negotiating a secure
+ * https:} URLs, this includes the TLS version and cipher suites to use when negotiating a secure
  * connection.
  */
 public final class ConnectionSpec {
 
+  // This is a subset of the cipher suites supported in Chrome 37, current as of 2014-10-5.
+  // All of these suites are available on Android 5.0; earlier releases support a subset of
+  // these suites. https://github.com/square/okhttp/issues/330
+  private static final CipherSuite[] APPROVED_CIPHER_SUITES = new CipherSuite[] {
+      CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+      CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+      CipherSuite.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256,
+
+      // Note that the following cipher suites are all on HTTP/2's bad cipher suites list. We'll
+      // continue to include them until better suites are commonly available. For example, none
+      // of the better cipher suites listed above shipped with Android 4.4 or Java 7.
+      CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
+      CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
+      CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
+      CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
+      CipherSuite.TLS_DHE_RSA_WITH_AES_128_CBC_SHA,
+      CipherSuite.TLS_DHE_DSS_WITH_AES_128_CBC_SHA,
+      CipherSuite.TLS_DHE_RSA_WITH_AES_256_CBC_SHA,
+      CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256,
+      CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA,
+      CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA,
+      CipherSuite.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
+  };
+
   /** A modern TLS connection with extensions like SNI and ALPN available. */
   public static final ConnectionSpec MODERN_TLS = new Builder(true)
-      .cipherSuites(
-          // This is a subset of the cipher suites supported in Chrome 37, current as of 2014-10-5.
-          // All of these suites are available on Android 5.0; earlier releases support a subset of
-          // these suites. https://github.com/square/okhttp/issues/330
-          CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
-          CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
-          CipherSuite.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256,
-
-          // Note that the following cipher suites are all on HTTP/2's bad cipher suites list. We'll
-          // continue to include them until better suites are commonly available. For example, none
-          // of the better cipher suites listed above shipped with Android 4.4 or Java 7.
-          CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
-          CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
-          CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
-          CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
-          CipherSuite.TLS_DHE_RSA_WITH_AES_128_CBC_SHA,
-          CipherSuite.TLS_DHE_DSS_WITH_AES_128_CBC_SHA,
-          CipherSuite.TLS_DHE_RSA_WITH_AES_256_CBC_SHA,
-          CipherSuite.TLS_RSA_WITH_AES_128_GCM_SHA256,
-          CipherSuite.TLS_RSA_WITH_AES_128_CBC_SHA,
-          CipherSuite.TLS_RSA_WITH_AES_256_CBC_SHA,
-          CipherSuite.TLS_RSA_WITH_3DES_EDE_CBC_SHA
-      )
+      .cipherSuites(APPROVED_CIPHER_SUITES)
       .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
       .supportsTlsExtensions(true)
       .build();
@@ -91,8 +92,8 @@
   }
 
   /**
-   * Return the cipher suites to use with the connection. This method can return {@code null} if the
-   * ciphers enabled by default should be used.
+   * Returns the cipher suites to use for a connection. This method can return {@code null} if the
+   * cipher suites enabled by default should be used.
    */
   public List<CipherSuite> cipherSuites() {
     if (cipherSuites == null) {
@@ -117,14 +118,32 @@
     return supportsTlsExtensions;
   }
 
-  /** Applies this spec to {@code sslSocket} for {@code route}. */
-  void apply(SSLSocket sslSocket, Route route) {
-    ConnectionSpec specToApply = supportedSpec(sslSocket);
+  /** Applies this spec to {@code sslSocket}. */
+  void apply(SSLSocket sslSocket, boolean isFallback) {
+    ConnectionSpec specToApply = supportedSpec(sslSocket, isFallback);
 
     sslSocket.setEnabledProtocols(specToApply.tlsVersions);
 
     String[] cipherSuitesToEnable = specToApply.cipherSuites;
-    if (route.shouldSendTlsFallbackIndicator) {
+    // null means "use default set".
+    if (cipherSuitesToEnable != null) {
+      sslSocket.setEnabledCipherSuites(cipherSuitesToEnable);
+    }
+  }
+
+  /**
+   * Returns a copy of this that omits cipher suites and TLS versions not enabled by
+   * {@code sslSocket}.
+   */
+  private ConnectionSpec supportedSpec(SSLSocket sslSocket, boolean isFallback) {
+    String[] cipherSuitesToEnable = null;
+    if (cipherSuites != null) {
+      String[] cipherSuitesToSelectFrom = sslSocket.getEnabledCipherSuites();
+      cipherSuitesToEnable =
+          Util.intersect(String.class, cipherSuites, cipherSuitesToSelectFrom);
+    }
+
+    if (isFallback) {
       // In accordance with https://tools.ietf.org/html/draft-ietf-tls-downgrade-scsv-00
       // the SCSV cipher is added to signal that a protocol fallback has taken place.
       final String fallbackScsv = "TLS_FALLBACK_SCSV";
@@ -132,7 +151,7 @@
           Arrays.asList(sslSocket.getSupportedCipherSuites()).contains(fallbackScsv);
 
       if (socketSupportsFallbackScsv) {
-        // Add the SCSV cipher to the set of enabled ciphers iff it is supported.
+        // Add the SCSV cipher to the set of enabled cipher suites iff it is supported.
         String[] oldEnabledCipherSuites = cipherSuitesToEnable != null
             ? cipherSuitesToEnable
             : sslSocket.getEnabledCipherSuites();
@@ -143,35 +162,71 @@
         cipherSuitesToEnable = newEnabledCipherSuites;
       }
     }
-    // null means "use default set".
-    if (cipherSuitesToEnable != null) {
-      sslSocket.setEnabledCipherSuites(cipherSuitesToEnable);
-    }
 
-    Platform platform = Platform.get();
-    if (specToApply.supportsTlsExtensions) {
-      platform.configureTlsExtensions(sslSocket, route.address.uriHost, route.address.protocols);
-    }
+    String[] protocolsToSelectFrom = sslSocket.getEnabledProtocols();
+    String[] protocolsToEnable = Util.intersect(String.class, tlsVersions, protocolsToSelectFrom);
+    return new Builder(this)
+        .cipherSuites(cipherSuitesToEnable)
+        .tlsVersions(protocolsToEnable)
+        .build();
   }
 
   /**
-   * Returns a copy of this that omits cipher suites and TLS versions not
-   * enabled by {@code sslSocket}.
+   * Returns {@code true} if the socket, as currently configured, supports this ConnectionSpec.
+   * In order for a socket to be compatible the enabled cipher suites and protocols must intersect.
+   *
+   * <p>For cipher suites, at least one of the {@link #cipherSuites() required cipher suites} must
+   * match the socket's enabled cipher suites. If there are no required cipher suites the socket
+   * must have at least one cipher suite enabled.
+   *
+   * <p>For protocols, at least one of the {@link #tlsVersions() required protocols} must match the
+   * socket's enabled protocols.
    */
-  private ConnectionSpec supportedSpec(SSLSocket sslSocket) {
-    String[] cipherSuitesToEnable = null;
-    if (cipherSuites != null) {
-      String[] cipherSuitesToSelectFrom = sslSocket.getEnabledCipherSuites();
-      cipherSuitesToEnable =
-          Util.intersect(String.class, cipherSuites, cipherSuitesToSelectFrom);
+  public boolean isCompatible(SSLSocket socket) {
+    if (!tls) {
+      return false;
     }
 
-    String[] protocolsToSelectFrom = sslSocket.getEnabledProtocols();
-    String[] tlsVersionsToEnable = Util.intersect(String.class, tlsVersions, protocolsToSelectFrom);
-    return new Builder(this)
-        .cipherSuites(cipherSuitesToEnable)
-        .tlsVersions(tlsVersionsToEnable)
-        .build();
+    String[] enabledProtocols = socket.getEnabledProtocols();
+    boolean requiredProtocolsEnabled = nonEmptyIntersection(tlsVersions, enabledProtocols);
+    if (!requiredProtocolsEnabled) {
+      return false;
+    }
+
+    boolean requiredCiphersEnabled;
+    if (cipherSuites == null) {
+      requiredCiphersEnabled = socket.getEnabledCipherSuites().length > 0;
+    } else {
+      String[] enabledCipherSuites = socket.getEnabledCipherSuites();
+      requiredCiphersEnabled = nonEmptyIntersection(cipherSuites, enabledCipherSuites);
+    }
+    return requiredCiphersEnabled;
+  }
+
+  /**
+   * An N*M intersection that terminates if any intersection is found. The sizes of both
+   * arguments are assumed to be so small, and the likelihood of an intersection so great, that it
+   * is not worth the CPU cost of sorting or the memory cost of hashing.
+   */
+  private static boolean nonEmptyIntersection(String[] a, String[] b) {
+    if (a == null || b == null || a.length == 0 || b.length == 0) {
+      return false;
+    }
+    for (String toFind : a) {
+      if (contains(b, toFind)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static <T> boolean contains(T[] array, T value) {
+    for (T arrayValue : array) {
+      if (Util.equal(value, arrayValue)) {
+        return true;
+      }
+    }
+    return false;
   }
 
   @Override public boolean equals(Object other) {
@@ -257,6 +312,9 @@
 
     public Builder tlsVersions(TlsVersion... tlsVersions) {
       if (!tls) throw new IllegalStateException("no TLS versions for cleartext connections");
+      if (tlsVersions.length == 0) {
+        throw new IllegalArgumentException("At least one TlsVersion is required");
+      }
 
       // Convert enums to the string names Java wants. This makes a defensive copy!
       String[] strings = new String[tlsVersions.length];
diff --git a/okhttp/src/main/java/com/squareup/okhttp/Dispatcher.java b/okhttp/src/main/java/com/squareup/okhttp/Dispatcher.java
index a696c0c..a934670 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/Dispatcher.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/Dispatcher.java
@@ -177,4 +177,12 @@
   synchronized void finished(Call call) {
     if (!executedCalls.remove(call)) throw new AssertionError("Call wasn't in-flight!");
   }
+
+  public synchronized int getRunningCallCount() {
+    return runningCalls.size();
+  }
+
+  public synchronized int getQueuedCallCount() {
+    return readyCalls.size();
+  }
 }
diff --git a/okhttp/src/main/java/com/squareup/okhttp/FormEncodingBuilder.java b/okhttp/src/main/java/com/squareup/okhttp/FormEncodingBuilder.java
index 891fbff..63eac1a 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/FormEncodingBuilder.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/FormEncodingBuilder.java
@@ -15,29 +15,29 @@
  */
 package com.squareup.okhttp;
 
-import com.squareup.okhttp.internal.Util;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
+import okio.Buffer;
 
 /**
  * Fluent API to build <a href="http://www.w3.org/MarkUp/html-spec/html-spec_8.html#SEC8.2.1">HTML
  * 2.0</a>-compliant form data.
  */
 public final class FormEncodingBuilder {
-  private static final MediaType CONTENT_TYPE
-      = MediaType.parse("application/x-www-form-urlencoded");
+  private static final MediaType CONTENT_TYPE =
+      MediaType.parse("application/x-www-form-urlencoded");
 
-  private final StringBuilder content = new StringBuilder();
+  private final Buffer content = new Buffer();
 
   /** Add new key-value pair. */
   public FormEncodingBuilder add(String name, String value) {
-    if (content.length() > 0) {
-      content.append('&');
+    if (content.size() > 0) {
+      content.writeByte('&');
     }
     try {
-      content.append(URLEncoder.encode(name, "UTF-8"))
-          .append('=')
-          .append(URLEncoder.encode(value, "UTF-8"));
+      content.writeUtf8(URLEncoder.encode(name, "UTF-8"));
+      content.writeByte('=');
+      content.writeUtf8(URLEncoder.encode(value, "UTF-8"));
     } catch (UnsupportedEncodingException e) {
       throw new AssertionError(e);
     }
@@ -45,12 +45,9 @@
   }
 
   public RequestBody build() {
-    if (content.length() == 0) {
+    if (content.size() == 0) {
       throw new IllegalStateException("Form encoded body must have at least one part.");
     }
-
-    // Convert to bytes so RequestBody.create() doesn't add a charset to the content-type.
-    byte[] contentBytes = content.toString().getBytes(Util.UTF_8);
-    return RequestBody.create(CONTENT_TYPE, contentBytes);
+    return RequestBody.create(CONTENT_TYPE, content.snapshot());
   }
 }
diff --git a/okhttp/src/main/java/com/squareup/okhttp/Headers.java b/okhttp/src/main/java/com/squareup/okhttp/Headers.java
index 2be385c..475d120 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/Headers.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/Headers.java
@@ -237,9 +237,9 @@
 
     /**
      * Add a field with the specified value without any validation. Only
-     * appropriate for headers from the remote peer.
+     * appropriate for headers from the remote peer or cache.
      */
-    private Builder addLenient(String name, String value) {
+    Builder addLenient(String name, String value) {
       namesAndValues.add(name);
       namesAndValues.add(value.trim());
       return this;
diff --git a/okhttp/src/main/java/com/squareup/okhttp/HttpUrl.java b/okhttp/src/main/java/com/squareup/okhttp/HttpUrl.java
new file mode 100644
index 0000000..0698528
--- /dev/null
+++ b/okhttp/src/main/java/com/squareup/okhttp/HttpUrl.java
@@ -0,0 +1,864 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp;
+
+import com.squareup.okhttp.internal.Util;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import okio.Buffer;
+
+/**
+ * A <a href="https://url.spec.whatwg.org/">URL</a> with an {@code http} or {@code https} scheme.
+ *
+ * TODO: discussion on canonicalization
+ *
+ * TODO: discussion on encoding-by-parts
+ *
+ * TODO: discussion on this vs. java.net.URL vs. java.net.URI
+ */
+public final class HttpUrl {
+  private static final char[] HEX_DIGITS =
+      { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' };
+
+  /** Either "http" or "https". */
+  private final String scheme;
+
+  /** Encoded username. */
+  private final String username;
+
+  /** Encoded password. */
+  private final String password;
+
+  /** Encoded hostname. */
+  // TODO(jwilson): implement punycode.
+  private final String host;
+
+  /** Either 80, 443 or a user-specified port. In range [1..65535]. */
+  private final int port;
+
+  /** Encoded path. */
+  private final String path;
+
+  /** Encoded query. */
+  private final String query;
+
+  /** Encoded fragment. */
+  private final String fragment;
+
+  /** Canonical URL. */
+  private final String url;
+
+  private HttpUrl(String scheme, String username, String password, String host, int port,
+      String path, String query, String fragment, String url) {
+    this.scheme = scheme;
+    this.username = username;
+    this.password = password;
+    this.host = host;
+    this.port = port;
+    this.path = path;
+    this.query = query;
+    this.fragment = fragment;
+    this.url = url;
+  }
+
+  public URL url() {
+    throw new UnsupportedOperationException(); // TODO(jwilson).
+  }
+
+  public URI uri() throws IOException {
+    throw new UnsupportedOperationException(); // TODO(jwilson).
+  }
+
+  /** Returns either "http" or "https". */
+  public String scheme() {
+    return scheme;
+  }
+
+  public boolean isHttps() {
+    return scheme.equals("https");
+  }
+
+  public String username() {
+    return username;
+  }
+
+  public String decodeUsername() {
+    return decode(username, 0, username.length());
+  }
+
+  /** Returns the encoded password if one is present; null otherwise. */
+  public String password() {
+    return password;
+  }
+
+  /** Returns the decoded password if one is present; null otherwise. */
+  public String decodePassword() {
+    return password != null ? decode(password, 0, password.length()) : null;
+  }
+
+  /**
+   * Returns the host address suitable for use with {@link InetAddress#getAllByName(String)}. May
+   * be:
+   * <ul>
+   *   <li>A regular host name, like {@code android.com}.
+   *   <li>An IPv4 address, like {@code 127.0.0.1}.
+   *   <li>An IPv6 address, like {@code ::1}. Note that there are no square braces.
+   *   <li>An encoded IDN, like {@code xn--n3h.net}.
+   * </ul>
+   */
+  public String host() {
+    return host;
+  }
+
+  /**
+   * Returns the decoded (potentially non-ASCII) hostname. The returned string may contain non-ASCII
+   * characters and is <strong>not suitable</strong> for DNS lookups; for that use {@link
+   * #host}. For example, this may return {@code ☃.net} which is a user-displayable IDN that cannot
+   * be used for DNS lookups without encoding.
+   */
+  public String decodeHost() {
+    throw new UnsupportedOperationException(); // TODO(jwilson).
+  }
+
+  /**
+   * Returns the explicitly-specified port if one was provided, or the default port for this URL's
+   * scheme. For example, this returns 8443 for {@code https://square.com:8443/} and 443 for {@code
+   * https://square.com/}. The result is in {@code [1..65535]}.
+   */
+  public int port() {
+    return port;
+  }
+
+  /**
+   * Returns 80 if {@code scheme.equals("http")}, 443 if {@code scheme.equals("https")} and -1
+   * otherwise.
+   */
+  public static int defaultPort(String scheme) {
+    if (scheme.equals("http")) {
+      return 80;
+    } else if (scheme.equals("https")) {
+      return 443;
+    } else {
+      return -1;
+    }
+  }
+
+  /**
+   * Returns the entire path of this URL, encoded for use in HTTP resource resolution. The
+   * returned path is always nonempty and is prefixed with {@code /}.
+   */
+  public String path() {
+    return path;
+  }
+
+  public List<String> decodePathSegments() {
+    List<String> result = new ArrayList<>();
+    int segmentStart = 1; // Path always starts with '/'.
+    for (int i = segmentStart; i < path.length(); i++) {
+      if (path.charAt(i) == '/') {
+        result.add(decode(path, segmentStart, i));
+        segmentStart = i + 1;
+      }
+    }
+    result.add(decode(path, segmentStart, path.length()));
+    return Util.immutableList(result);
+  }
+
+  /**
+   * Returns the query of this URL, encoded for use in HTTP resource resolution. The returned string
+   * may be null (for URLs with no query), empty (for URLs with an empty query) or non-empty (all
+   * other URLs).
+   */
+  public String query() {
+    return query;
+  }
+
+  /**
+   * Returns the first query parameter named {@code name} decoded using UTF-8, or null if there is
+   * no such query parameter.
+   */
+  public String queryParameter(String name) {
+    throw new UnsupportedOperationException(); // TODO(jwilson).
+  }
+
+  public Set<String> queryParameterNames() {
+    throw new UnsupportedOperationException(); // TODO(jwilson).
+  }
+
+  public List<String> queryParameterValues(String name) {
+    throw new UnsupportedOperationException(); // TODO(jwilson).
+  }
+
+  public String queryParameterName(int index) {
+    throw new UnsupportedOperationException(); // TODO(jwilson).
+  }
+
+  public String queryParameterValue(int index) {
+    throw new UnsupportedOperationException(); // TODO(jwilson).
+  }
+
+  public String fragment() {
+    return fragment;
+  }
+
+  /**
+   * Returns the URL that would be retrieved by following {@code link} from this URL.
+   *
+   * TODO: explain better.
+   */
+  public HttpUrl resolve(String link) {
+    return new Builder().parse(this, link);
+  }
+
+  public Builder newBuilder() {
+    return new Builder(this);
+  }
+
+  /**
+   * Returns a new {@code OkUrl} representing {@code url} if it is a well-formed HTTP or HTTPS URL,
+   * or null if it isn't.
+   */
+  public static HttpUrl parse(String url) {
+    return new Builder().parse(null, url);
+  }
+
+  public static HttpUrl get(URL url) {
+    return parse(url.toString());
+  }
+
+  public static HttpUrl get(URI uri) {
+    return parse(uri.toString());
+  }
+
+  @Override public boolean equals(Object o) {
+    return o instanceof HttpUrl && ((HttpUrl) o).url.equals(url);
+  }
+
+  @Override public int hashCode() {
+    return url.hashCode();
+  }
+
+  @Override public String toString() {
+    return url;
+  }
+
+  public static final class Builder {
+    String scheme;
+    String username = "";
+    String password;
+    String host;
+    int port = -1;
+    StringBuilder pathBuilder = new StringBuilder();
+    String query;
+    String fragment;
+
+    public Builder() {
+    }
+
+    private Builder(HttpUrl url) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder scheme(String scheme) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder user(String user) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder encodedUser(String encodedUser) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder password(String password) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder encodedPassword(String encodedPassword) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    /**
+     * @param host either a regular hostname, International Domain Name, IPv4 address, or IPv6
+     *     address.
+     */
+    public Builder host(String host) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder port(int port) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder addPathSegment(String pathSegment) {
+      if (pathSegment == null) throw new IllegalArgumentException("pathSegment == null");
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder addEncodedPathSegment(String encodedPathSegment) {
+      if (encodedPathSegment == null) {
+        throw new IllegalArgumentException("encodedPathSegment == null");
+      }
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder encodedPath(String encodedPath) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder encodedQuery(String encodedQuery) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    /** Encodes the query parameter using UTF-8 and adds it to this URL's query string. */
+    public Builder addQueryParameter(String name, String value) {
+      if (name == null) throw new IllegalArgumentException("name == null");
+      if (value == null) throw new IllegalArgumentException("value == null");
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    /** Adds the pre-encoded query parameter to this URL's query string. */
+    public Builder addEncodedQueryParameter(String encodedName, String encodedValue) {
+      if (encodedName == null) throw new IllegalArgumentException("encodedName == null");
+      if (encodedValue == null) throw new IllegalArgumentException("encodedValue == null");
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder setQueryParameter(String name, String value) {
+      if (name == null) throw new IllegalArgumentException("name == null");
+      if (value == null) throw new IllegalArgumentException("value == null");
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder setEncodedQueryParameter(String encodedName, String encodedValue) {
+      if (encodedName == null) throw new IllegalArgumentException("encodedName == null");
+      if (encodedValue == null) throw new IllegalArgumentException("encodedValue == null");
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder removeAllQueryParameters(String name) {
+      if (name == null) throw new IllegalArgumentException("name == null");
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder removeAllEncodedQueryParameters(String encodedName) {
+      if (encodedName == null) throw new IllegalArgumentException("encodedName == null");
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public Builder fragment(String fragment) {
+      throw new UnsupportedOperationException(); // TODO(jwilson)
+    }
+
+    public HttpUrl build() {
+      StringBuilder url = new StringBuilder();
+      url.append(scheme);
+      url.append("://");
+
+      String effectivePassword = (password != null && !password.isEmpty()) ? password : null;
+      if (!username.isEmpty() || effectivePassword != null) {
+        url.append(username);
+        if (effectivePassword != null) {
+          url.append(':');
+          url.append(effectivePassword);
+        }
+        url.append('@');
+      }
+
+      if (host.indexOf(':') != -1) {
+        // Host is an IPv6 address.
+        url.append('[');
+        url.append(host);
+        url.append(']');
+      } else {
+        url.append(host);
+      }
+
+      int defaultPort = defaultPort(scheme);
+      int effectivePort = port != -1 ? port : defaultPort;
+      if (effectivePort != defaultPort) {
+        url.append(':');
+        url.append(port);
+      }
+
+      String effectivePath = pathBuilder.length() > 0
+          ? pathBuilder.toString()
+          : "/";
+      url.append(effectivePath);
+
+      if (query != null) {
+        url.append('?');
+        url.append(query);
+      }
+
+      if (fragment != null) {
+        url.append('#');
+        url.append(fragment);
+      }
+
+      return new HttpUrl(scheme, username, effectivePassword, host, effectivePort, effectivePath,
+          query, fragment, url.toString());
+    }
+
+    HttpUrl parse(HttpUrl base, String input) {
+      int pos = skipLeadingAsciiWhitespace(input, 0, input.length());
+      int limit = skipTrailingAsciiWhitespace(input, pos, input.length());
+
+      // Scheme.
+      int schemeDelimiterOffset = schemeDelimiterOffset(input, pos, limit);
+      if (schemeDelimiterOffset != -1) {
+        if (input.regionMatches(true, pos, "https:", 0, 6)) {
+          this.scheme = "https";
+          pos += "https:".length();
+        } else if (input.regionMatches(true, pos, "http:", 0, 5)) {
+          this.scheme = "http";
+          pos += "http:".length();
+        } else {
+          return null; // Not an HTTP scheme.
+        }
+      } else if (base != null) {
+        this.scheme = base.scheme;
+      } else {
+        return null; // No scheme.
+      }
+
+      // Authority.
+      boolean hasUsername = false;
+      int slashCount = slashCount(input, pos, limit);
+      if (slashCount >= 2 || base == null || !base.scheme.equals(this.scheme)) {
+        // Read an authority if either:
+        //  * The input starts with 2 or more slashes. These follow the scheme if it exists.
+        //  * The input scheme exists and is different from the base URL's scheme.
+        //
+        // The structure of an authority is:
+        //   username:password@host:port
+        //
+        // Username, password and port are optional.
+        //   [username[:password]@]host[:port]
+        pos += slashCount;
+        authority:
+        while (true) {
+          int componentDelimiterOffset = delimiterOffset(input, pos, limit, "@/\\?#");
+          int c = componentDelimiterOffset != limit
+              ? input.charAt(componentDelimiterOffset)
+              : -1;
+          switch (c) {
+            case '@':
+              // User info precedes.
+              if (this.password == null) {
+                int passwordColonOffset = delimiterOffset(
+                    input, pos, componentDelimiterOffset, ":");
+                this.username = hasUsername
+                    ? (this.username + "%40" + username(input, pos, passwordColonOffset))
+                    : username(input, pos, passwordColonOffset);
+                if (passwordColonOffset != componentDelimiterOffset) {
+                  this.password = password(
+                      input, passwordColonOffset + 1, componentDelimiterOffset);
+                }
+                hasUsername = true;
+              } else {
+                this.password = this.password + "%40"
+                    + password(input, pos, componentDelimiterOffset);
+              }
+              pos = componentDelimiterOffset + 1;
+              break;
+
+            case -1:
+            case '/':
+            case '\\':
+            case '?':
+            case '#':
+              // Host info precedes.
+              int portColonOffset = portColonOffset(input, pos, componentDelimiterOffset);
+              if (portColonOffset + 1 < componentDelimiterOffset) {
+                this.host = host(input, pos, portColonOffset);
+                this.port = port(input, portColonOffset + 1, componentDelimiterOffset);
+                if (this.port == -1) return null; // Invalid port.
+              } else {
+                this.host = host(input, pos, portColonOffset);
+                this.port = defaultPort(this.scheme);
+              }
+              if (this.host == null) return null; // Invalid host.
+              pos = componentDelimiterOffset;
+              break authority;
+          }
+        }
+      } else {
+        // This is a relative link. Copy over all authority components. Also maybe the path & query.
+        this.username = base.username;
+        this.password = base.password;
+        this.host = base.host;
+        this.port = base.port;
+        int c = pos != limit
+            ? input.charAt(pos)
+            : -1;
+        switch (c) {
+          case -1:
+          case '#':
+            pathBuilder.append(base.path);
+            this.query = base.query;
+            break;
+
+          case '?':
+            pathBuilder.append(base.path);
+            break;
+
+          case '/':
+          case '\\':
+            break;
+
+          default:
+            pathBuilder.append(base.path);
+            pathBuilder.append('/'); // Because pop wants the input to end with '/'.
+            pop();
+            break;
+        }
+      }
+
+      // Resolve the relative path.
+      int pathDelimiterOffset = delimiterOffset(input, pos, limit, "?#");
+      while (pos < pathDelimiterOffset) {
+        int pathSegmentDelimiterOffset = delimiterOffset(input, pos, pathDelimiterOffset, "/\\");
+        int segmentLength = pathSegmentDelimiterOffset - pos;
+
+        if ((segmentLength == 2 && input.regionMatches(false, pos, "..", 0, 2))
+            || (segmentLength == 4 && input.regionMatches(true, pos, "%2e.", 0, 4))
+            || (segmentLength == 4 && input.regionMatches(true, pos, ".%2e", 0, 4))
+            || (segmentLength == 6 && input.regionMatches(true, pos, "%2e%2e", 0, 6))) {
+          pop();
+        } else if ((segmentLength == 1 && input.regionMatches(false, pos, ".", 0, 1))
+            || (segmentLength == 3 && input.regionMatches(true, pos, "%2e", 0, 3))) {
+          // Skip '.' path segments.
+        } else if (pathSegmentDelimiterOffset < pathDelimiterOffset) {
+          pathSegment(input, pos, pathSegmentDelimiterOffset);
+          pathBuilder.append('/');
+        } else {
+          pathSegment(input, pos, pathSegmentDelimiterOffset);
+        }
+
+        pos = pathSegmentDelimiterOffset;
+        if (pathSegmentDelimiterOffset < pathDelimiterOffset) {
+          pos++; // Eat '/'.
+        }
+      }
+
+      // Query.
+      if (pos < limit && input.charAt(pos) == '?') {
+        int queryDelimiterOffset = delimiterOffset(input, pos, limit, "#");
+        this.query = query(input, pos + 1, queryDelimiterOffset);
+        pos = queryDelimiterOffset;
+      }
+
+      // Fragment.
+      if (pos < limit && input.charAt(pos) == '#') {
+        this.fragment = fragment(input, pos + 1, limit);
+      }
+
+      return build();
+    }
+
+    /** Remove the last character '/' of path, plus all characters after the preceding '/'. */
+    private void pop() {
+      if (pathBuilder.charAt(pathBuilder.length() - 1) != '/') throw new IllegalStateException();
+
+      for (int i = pathBuilder.length() - 2; i >= 0; i--) {
+        if (pathBuilder.charAt(i) == '/') {
+          pathBuilder.delete(i + 1, pathBuilder.length());
+          return;
+        }
+      }
+
+      // If we get this far, there's nothing to pop. Do nothing.
+    }
+
+    /**
+     * Increments {@code pos} until {@code input[pos]} is not ASCII whitespace. Stops at {@code
+     * limit}.
+     */
+    private int skipLeadingAsciiWhitespace(String input, int pos, int limit) {
+      for (int i = pos; i < limit; i++) {
+        switch (input.charAt(i)) {
+          case '\t':
+          case '\n':
+          case '\f':
+          case '\r':
+          case ' ':
+            continue;
+          default:
+            return i;
+        }
+      }
+      return limit;
+    }
+
+    /**
+     * Decrements {@code limit} until {@code input[limit - 1]} is not ASCII whitespace. Stops at
+     * {@code pos}.
+     */
+    private int skipTrailingAsciiWhitespace(String input, int pos, int limit) {
+      for (int i = limit - 1; i >= pos; i--) {
+        switch (input.charAt(i)) {
+          case '\t':
+          case '\n':
+          case '\f':
+          case '\r':
+          case ' ':
+            continue;
+          default:
+            return i + 1;
+        }
+      }
+      return pos;
+    }
+
+    /**
+     * Returns the index of the ':' in {@code input} that is after scheme characters. Returns -1 if
+     * {@code input} does not have a scheme that starts at {@code pos}.
+     */
+    private static int schemeDelimiterOffset(String input, int pos, int limit) {
+      if (limit - pos < 2) return -1;
+
+      char c0 = input.charAt(pos);
+      if ((c0 < 'a' || c0 > 'z') && (c0 < 'A' || c0 > 'Z')) return -1; // Not a scheme start char.
+
+      for (int i = pos + 1; i < limit; i++) {
+        char c = input.charAt(i);
+
+        if ((c >= 'a' && c <= 'z')
+            || (c >= 'A' && c <= 'Z')
+            || c == '+'
+            || c == '-'
+            || c == '.') {
+          continue; // Scheme character. Keep going.
+        } else if (c == ':') {
+          return i; // Scheme prefix!
+        } else {
+          return -1; // Non-scheme character before the first ':'.
+        }
+      }
+
+      return -1; // No ':'; doesn't start with a scheme.
+    }
+
+    /** Returns the number of '/' and '\' slashes in {@code input}, starting at {@code pos}. */
+    private static int slashCount(String input, int pos, int limit) {
+      int slashCount = 0;
+      while (pos < limit) {
+        char c = input.charAt(pos);
+        if (c == '\\' || c == '/') {
+          slashCount++;
+          pos++;
+        } else {
+          break;
+        }
+      }
+      return slashCount;
+    }
+
+    /**
+     * Returns the index of the first character in {@code input} that contains a character in {@code
+     * delimiters}. Returns limit if there is no such character.
+     */
+    private static int delimiterOffset(String input, int pos, int limit, String delimiters) {
+      for (int i = pos; i < limit; i++) {
+        if (delimiters.indexOf(input.charAt(i)) != -1) return i;
+      }
+      return limit;
+    }
+
+    /** Finds the first ':' in {@code input}, skipping characters between square braces "[...]". */
+    private static int portColonOffset(String input, int pos, int limit) {
+      for (int i = pos; i < limit; i++) {
+        switch (input.charAt(i)) {
+          case '[':
+            while (++i < limit) {
+              if (input.charAt(i) == ']') break;
+            }
+            break;
+          case ':':
+            return i;
+        }
+      }
+      return limit; // No colon.
+    }
+
+    private String username(String input, int pos, int limit) {
+      return encode(input, pos, limit, " \"';<=>@[]^`{}|");
+    }
+
+    private String password(String input, int pos, int limit) {
+      return encode(input, pos, limit, " \"':;<=>@[]\\^`{}|");
+    }
+
+    private static String host(String input, int pos, int limit) {
+      // Start by percent decoding the host. The WHATWG spec suggests doing this only after we've
+      // checked for IPv6 square braces. But Chrome does it first, and that's more lenient.
+      String percentDecoded = decode(input, pos, limit);
+
+      // If the input is encased in square braces "[...]", drop 'em. We have an IPv6 address.
+      if (percentDecoded.startsWith("[") && percentDecoded.endsWith("]")) {
+        return decodeIpv6(percentDecoded, 1, percentDecoded.length() - 1);
+      }
+
+      // Do IDN decoding. This converts {@code ☃.net} to {@code xn--n3h.net}.
+      String idnDecoded = domainToAscii(percentDecoded);
+
+      // Confirm that the decoded result doesn't contain any illegal characters.
+      int length = idnDecoded.length();
+      if (delimiterOffset(idnDecoded, 0, length, "\u0000\t\n\r #%/:?@[\\]") != length) {
+        return null;
+      }
+
+      return idnDecoded;
+    }
+
+    private static String decodeIpv6(String input, int pos, int limit) {
+      return input.substring(pos, limit); // TODO(jwilson) implement IPv6 decoding.
+    }
+
+    private static String domainToAscii(String input) {
+      return input; // TODO(jwilson): implement IDN decoding.
+    }
+
+    private int port(String input, int pos, int limit) {
+      try {
+        String portString = encode(input, pos, limit, ""); // To skip '\n' etc.
+        int i = Integer.parseInt(portString);
+        if (i > 0 && i <= 65535) return i;
+        return -1;
+      } catch (NumberFormatException e) {
+        return -1; // Invalid port.
+      }
+    }
+
+    private void pathSegment(String input, int pos, int limit) {
+      encode(pathBuilder, input, pos, limit, " \"<>^`{}|");
+    }
+
+    private String query(String input, int pos, int limit) {
+      return encode(input, pos, limit, " \"'<>");
+    }
+
+    private String fragment(String input, int pos, int limit) {
+      return encode(input, pos, limit, ""); // To skip '\n' etc.
+    }
+  }
+
+  private static String decode(String encoded, int pos, int limit) {
+    for (int i = pos; i < limit; i++) {
+      if (encoded.charAt(i) == '%') {
+        // Slow path: the character at i requires decoding!
+        Buffer out = new Buffer();
+        out.writeUtf8(encoded, pos, i);
+        return decode(out, encoded, i, limit);
+      }
+    }
+
+    // Fast path: no characters in [pos..limit) required decoding.
+    return encoded.substring(pos, limit);
+  }
+
+  private static String decode(Buffer out, String encoded, int pos, int limit) {
+    int codePoint;
+    for (int i = pos; i < limit; i += Character.charCount(codePoint)) {
+      codePoint = encoded.codePointAt(i);
+      if (codePoint == '%' && i + 2 < limit) {
+        int d1 = decodeHexDigit(encoded.charAt(i + 1));
+        int d2 = decodeHexDigit(encoded.charAt(i + 2));
+        if (d1 != -1 && d2 != -1) {
+          out.writeByte((d1 << 4) + d2);
+          i += 2;
+          continue;
+        }
+      }
+      out.writeUtf8CodePoint(codePoint);
+    }
+    return out.readUtf8();
+  }
+
+  private static int decodeHexDigit(char c) {
+    if (c >= '0' && c <= '9') return c - '0';
+    if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+    if (c >= 'A' && c <= 'F') return c - 'A' + 10;
+    return -1;
+  }
+
+  /**
+   * Returns a substring of {@code input} on the range {@code [pos..limit)} with the following
+   * transformations:
+   * <ul>
+   *   <li>Tabs, newlines, form feeds and carriage returns are skipped.
+   *   <li>Characters in {@code encodeSet} are percent-encoded.
+   *   <li>Control characters and non-ASCII characters are percent-encoded.
+   *   <li>All other characters are copied without transformation.
+   * </ul>
+   */
+  static String encode(String input, int pos, int limit, String encodeSet) {
+    int codePoint;
+    for (int i = pos; i < limit; i += Character.charCount(codePoint)) {
+      codePoint = input.codePointAt(i);
+      if (codePoint < 0x20
+          || codePoint >= 0x7f
+          || encodeSet.indexOf(codePoint) != -1) {
+        // Slow path: the character at i requires encoding!
+        StringBuilder out = new StringBuilder();
+        out.append(input, pos, i);
+        encode(out, input, i, limit, encodeSet);
+        return out.toString();
+      }
+    }
+
+    // Fast path: no characters in [pos..limit) required encoding.
+    return input.substring(pos, limit);
+  }
+
+  static void encode(StringBuilder out, String input, int pos, int limit, String encodeSet) {
+    Buffer utf8Buffer = null; // Lazily allocated.
+    int codePoint;
+    for (int i = pos; i < limit; i += Character.charCount(codePoint)) {
+      codePoint = input.codePointAt(i);
+      if (codePoint == '\t'
+          || codePoint == '\n'
+          || codePoint == '\f'
+          || codePoint == '\r') {
+        // Skip this character.
+      } else if (codePoint < 0x20
+          || codePoint >= 0x7f
+          || encodeSet.indexOf(codePoint) != -1) {
+        // Percent encode this character.
+        if (utf8Buffer == null) {
+          utf8Buffer = new Buffer();
+        }
+        utf8Buffer.writeUtf8CodePoint(codePoint);
+        while (!utf8Buffer.exhausted()) {
+          int b = utf8Buffer.readByte() & 0xff;
+          out.append('%');
+          out.append(HEX_DIGITS[(b >> 4) & 0xf]);
+          out.append(HEX_DIGITS[b & 0xf]);
+        }
+      } else {
+        // This character doesn't need encoding. Just copy it over.
+        out.append((char) codePoint);
+      }
+    }
+  }
+}
diff --git a/okhttp/src/main/java/com/squareup/okhttp/OkHttpClient.java b/okhttp/src/main/java/com/squareup/okhttp/OkHttpClient.java
index 70f525c..ed0811e 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/OkHttpClient.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/OkHttpClient.java
@@ -22,6 +22,7 @@
 import com.squareup.okhttp.internal.Util;
 import com.squareup.okhttp.internal.http.AuthenticatorAdapter;
 import com.squareup.okhttp.internal.http.HttpEngine;
+import com.squareup.okhttp.internal.http.RouteException;
 import com.squareup.okhttp.internal.http.Transport;
 import com.squareup.okhttp.internal.tls.OkHostnameVerifier;
 import java.io.IOException;
@@ -36,7 +37,10 @@
 import javax.net.SocketFactory;
 import javax.net.ssl.HostnameVerifier;
 import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocket;
 import javax.net.ssl.SSLSocketFactory;
+import okio.BufferedSink;
+import okio.BufferedSource;
 
 /**
  * Configures and creates HTTP connections. Most applications can use a single
@@ -49,7 +53,7 @@
  * {@link #clone()} to make a shallow copy of the OkHttpClient that can be
  * safely modified with further configuration changes.
  */
-public final class OkHttpClient implements Cloneable {
+public class OkHttpClient implements Cloneable {
   private static final List<Protocol> DEFAULT_PROTOCOLS = Util.immutableList(
       Protocol.HTTP_2, Protocol.SPDY_3, Protocol.HTTP_1_1);
 
@@ -91,6 +95,10 @@
         builder.addLenient(line);
       }
 
+      @Override public void addLenient(Headers.Builder builder, String name, String value) {
+        builder.addLenient(name, value);
+      }
+
       @Override public void setCache(OkHttpClient client, InternalCache internalCache) {
         client.setInternalCache(internalCache);
       }
@@ -116,7 +124,7 @@
       }
 
       @Override public void connectAndSetOwner(OkHttpClient client, Connection connection,
-          HttpEngine owner, Request request) throws IOException {
+          HttpEngine owner, Request request) throws RouteException {
         connection.connectAndSetOwner(client, owner, request);
       }
 
@@ -133,9 +141,22 @@
         return call.engine.getConnection();
       }
 
+      @Override public BufferedSource connectionRawSource(Connection connection) {
+        return connection.rawSource();
+      }
+
+      @Override public BufferedSink connectionRawSink(Connection connection) {
+        return connection.rawSink();
+      }
+
       @Override public void connectionSetOwner(Connection connection, Object owner) {
         connection.setOwner(owner);
       }
+
+      @Override
+      public void apply(ConnectionSpec tlsConfiguration, SSLSocket sslSocket, boolean isFallback) {
+        tlsConfiguration.apply(sslSocket, isFallback);
+      }
     };
   }
 
diff --git a/okhttp/src/main/java/com/squareup/okhttp/Request.java b/okhttp/src/main/java/com/squareup/okhttp/Request.java
index 098ee9b..84fd045 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/Request.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/Request.java
@@ -16,7 +16,6 @@
 package com.squareup.okhttp;
 
 import com.squareup.okhttp.internal.Platform;
-import com.squareup.okhttp.internal.Util;
 import com.squareup.okhttp.internal.http.HttpMethod;
 import java.io.IOException;
 import java.net.MalformedURLException;
@@ -215,7 +214,7 @@
     }
 
     public Builder delete() {
-      return method("DELETE", null);
+      return delete(RequestBody.create(null, new byte[0]));
     }
 
     public Builder put(RequestBody body) {
@@ -233,8 +232,8 @@
       if (body != null && !HttpMethod.permitsRequestBody(method)) {
         throw new IllegalArgumentException("method " + method + " must not have a request body.");
       }
-      if (body == null && HttpMethod.permitsRequestBody(method)) {
-        body = RequestBody.create(null, Util.EMPTY_BYTE_ARRAY);
+      if (body == null && HttpMethod.requiresRequestBody(method)) {
+        throw new IllegalArgumentException("method " + method + " must have a request body.");
       }
       this.method = method;
       this.body = body;
diff --git a/okhttp/src/main/java/com/squareup/okhttp/RequestBody.java b/okhttp/src/main/java/com/squareup/okhttp/RequestBody.java
index 19ee211..50933f7 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/RequestBody.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/RequestBody.java
@@ -20,6 +20,7 @@
 import java.io.IOException;
 import java.nio.charset.Charset;
 import okio.BufferedSink;
+import okio.ByteString;
 import okio.Okio;
 import okio.Source;
 
@@ -56,6 +57,23 @@
   }
 
   /** Returns a new request body that transmits {@code content}. */
+  public static RequestBody create(final MediaType contentType, final ByteString content) {
+    return new RequestBody() {
+      @Override public MediaType contentType() {
+        return contentType;
+      }
+
+      @Override public long contentLength() throws IOException {
+        return content.size();
+      }
+
+      @Override public void writeTo(BufferedSink sink) throws IOException {
+        sink.write(content);
+      }
+    };
+  }
+
+  /** Returns a new request body that transmits {@code content}. */
   public static RequestBody create(final MediaType contentType, final byte[] content) {
     return create(contentType, content, 0, content.length);
   }
diff --git a/okhttp/src/main/java/com/squareup/okhttp/Route.java b/okhttp/src/main/java/com/squareup/okhttp/Route.java
index f244311..2d27a03 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/Route.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/Route.java
@@ -28,8 +28,6 @@
  *   <li><strong>IP address:</strong> whether connecting directly to an origin
  *       server or a proxy, opening a socket requires an IP address. The DNS
  *       server may return multiple IP addresses to attempt.
- *   <li><strong>TLS configuration:</strong> which cipher suites and TLS
- *       versions to attempt with the HTTPS connection.
  * </ul>
  * Each route is a specific selection of these options.
  */
@@ -37,17 +35,8 @@
   final Address address;
   final Proxy proxy;
   final InetSocketAddress inetSocketAddress;
-  final ConnectionSpec connectionSpec;
-  final boolean shouldSendTlsFallbackIndicator;
 
-  public Route(Address address, Proxy proxy, InetSocketAddress inetSocketAddress,
-      ConnectionSpec connectionSpec) {
-    this(address, proxy, inetSocketAddress, connectionSpec,
-        false /* shouldSendTlsFallbackIndicator */);
-  }
-
-  public Route(Address address, Proxy proxy, InetSocketAddress inetSocketAddress,
-      ConnectionSpec connectionSpec, boolean shouldSendTlsFallbackIndicator) {
+  public Route(Address address, Proxy proxy, InetSocketAddress inetSocketAddress) {
     if (address == null) {
       throw new NullPointerException("address == null");
     }
@@ -57,14 +46,9 @@
     if (inetSocketAddress == null) {
       throw new NullPointerException("inetSocketAddress == null");
     }
-    if (connectionSpec == null) {
-      throw new NullPointerException("connectionConfiguration == null");
-    }
     this.address = address;
     this.proxy = proxy;
     this.inetSocketAddress = inetSocketAddress;
-    this.connectionSpec = connectionSpec;
-    this.shouldSendTlsFallbackIndicator = shouldSendTlsFallbackIndicator;
   }
 
   public Address getAddress() {
@@ -86,14 +70,6 @@
     return inetSocketAddress;
   }
 
-  public ConnectionSpec getConnectionSpec() {
-    return connectionSpec;
-  }
-
-  public boolean getShouldSendTlsFallbackIndicator() {
-    return shouldSendTlsFallbackIndicator;
-  }
-
   /**
    * Returns true if this route tunnels HTTPS through an HTTP proxy. See <a
    * href="http://www.ietf.org/rfc/rfc2817.txt">RFC 2817, Section 5.2</a>.
@@ -107,9 +83,7 @@
       Route other = (Route) obj;
       return address.equals(other.address)
           && proxy.equals(other.proxy)
-          && inetSocketAddress.equals(other.inetSocketAddress)
-          && connectionSpec.equals(other.connectionSpec)
-          && shouldSendTlsFallbackIndicator == other.shouldSendTlsFallbackIndicator;
+          && inetSocketAddress.equals(other.inetSocketAddress);
     }
     return false;
   }
@@ -119,8 +93,6 @@
     result = 31 * result + address.hashCode();
     result = 31 * result + proxy.hashCode();
     result = 31 * result + inetSocketAddress.hashCode();
-    result = 31 * result + connectionSpec.hashCode();
-    result = 31 * result + (shouldSendTlsFallbackIndicator ? 1 : 0);
     return result;
   }
 }
diff --git a/okhttp/src/main/java/com/squareup/okhttp/TlsVersion.java b/okhttp/src/main/java/com/squareup/okhttp/TlsVersion.java
index a8d7b9b..bfa95c4 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/TlsVersion.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/TlsVersion.java
@@ -43,4 +43,8 @@
     }
     throw new IllegalArgumentException("Unexpected TLS version: " + javaName);
   }
+
+  public String javaName() {
+    return javaName;
+  }
 }
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/ConnectionSpecSelector.java b/okhttp/src/main/java/com/squareup/okhttp/internal/ConnectionSpecSelector.java
new file mode 100644
index 0000000..dabe8b2
--- /dev/null
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/ConnectionSpecSelector.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.squareup.okhttp.internal;
+
+import com.squareup.okhttp.ConnectionSpec;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.net.ProtocolException;
+import java.net.UnknownServiceException;
+import java.security.cert.CertificateException;
+import java.util.Arrays;
+import java.util.List;
+import javax.net.ssl.SSLHandshakeException;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLProtocolException;
+import javax.net.ssl.SSLSocket;
+
+/**
+ * Handles the connection spec fallback strategy: When a secure socket connection fails
+ * due to a handshake / protocol problem the connection may be retried with different protocols.
+ * Instances are stateful and should be created and used for a single connection attempt.
+ */
+public final class ConnectionSpecSelector {
+
+  private final List<ConnectionSpec> connectionSpecs;
+  private int nextModeIndex;
+  private boolean isFallbackPossible;
+  private boolean isFallback;
+
+  public ConnectionSpecSelector(List<ConnectionSpec> connectionSpecs) {
+    this.nextModeIndex = 0;
+    this.connectionSpecs = connectionSpecs;
+  }
+
+  /**
+   * Configures the supplied {@link SSLSocket} to connect to the specified host using an appropriate
+   * {@link ConnectionSpec}. Returns the chosen {@link ConnectionSpec}, never {@code null}.
+   *
+   * @throws IOException if the socket does not support any of the TLS modes available
+   */
+  public ConnectionSpec configureSecureSocket(SSLSocket sslSocket) throws IOException {
+    ConnectionSpec tlsConfiguration = null;
+    for (int i = nextModeIndex, size = connectionSpecs.size(); i < size; i++) {
+      ConnectionSpec connectionSpec = connectionSpecs.get(i);
+      if (connectionSpec.isCompatible(sslSocket)) {
+        tlsConfiguration = connectionSpec;
+        nextModeIndex = i + 1;
+        break;
+      }
+    }
+
+    if (tlsConfiguration == null) {
+      // This may be the first time a connection has been attempted and the socket does not support
+      // any the required protocols, or it may be a retry (but this socket supports fewer
+      // protocols than was suggested by a prior socket).
+      throw new UnknownServiceException(
+          "Unable to find acceptable protocols. isFallback=" + isFallback
+              + ", modes=" + connectionSpecs
+              + ", supported protocols=" + Arrays.toString(sslSocket.getEnabledProtocols()));
+    }
+
+    isFallbackPossible = isFallbackPossible(sslSocket);
+
+    Internal.instance.apply(tlsConfiguration, sslSocket, isFallback);
+
+    return tlsConfiguration;
+  }
+
+  /**
+   * Reports a failure to complete a connection. Determines the next {@link ConnectionSpec} to
+   * try, if any.
+   *
+   * @return {@code true} if the connection should be retried using
+   *     {@link #configureSecureSocket(SSLSocket)} or {@code false} if not
+   */
+  public boolean connectionFailed(IOException e) {
+    // Any future attempt to connect using this strategy will be a fallback attempt.
+    isFallback = true;
+
+    // TODO(nfuller): This is the same logic as in HttpEngine.
+    // If there was a protocol problem, don't recover.
+    if (e instanceof ProtocolException) {
+      return false;
+    }
+
+    // If there was an interruption or timeout, don't recover.
+    if (e instanceof InterruptedIOException) {
+      return false;
+    }
+
+    // Look for known client-side or negotiation errors that are unlikely to be fixed by trying
+    // again with a different connection spec.
+    if (e instanceof SSLHandshakeException) {
+      // If the problem was a CertificateException from the X509TrustManager,
+      // do not retry.
+      if (e.getCause() instanceof CertificateException) {
+        return false;
+      }
+    }
+    if (e instanceof SSLPeerUnverifiedException) {
+      // e.g. a certificate pinning error.
+      return false;
+    }
+    // TODO(nfuller): End of common code.
+
+
+    // On Android, SSLProtocolExceptions can be caused by TLS_FALLBACK_SCSV failures, which means we
+    // retry those when we probably should not.
+    return ((e instanceof SSLHandshakeException || e instanceof SSLProtocolException))
+        && isFallbackPossible;
+  }
+
+  /**
+   * Returns {@code true} if any later {@link ConnectionSpec} in the fallback strategy looks
+   * possible based on the supplied {@link SSLSocket}. It assumes that a future socket will have the
+   * same capabilities as the supplied socket.
+   */
+  private boolean isFallbackPossible(SSLSocket socket) {
+    for (int i = nextModeIndex; i < connectionSpecs.size(); i++) {
+      if (connectionSpecs.get(i).isCompatible(socket)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/Internal.java b/okhttp/src/main/java/com/squareup/okhttp/internal/Internal.java
index d806b48..1e583ba 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/internal/Internal.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/Internal.java
@@ -19,14 +19,19 @@
 import com.squareup.okhttp.Callback;
 import com.squareup.okhttp.Connection;
 import com.squareup.okhttp.ConnectionPool;
+import com.squareup.okhttp.ConnectionSpec;
 import com.squareup.okhttp.Headers;
 import com.squareup.okhttp.OkHttpClient;
 import com.squareup.okhttp.Protocol;
 import com.squareup.okhttp.Request;
 import com.squareup.okhttp.internal.http.HttpEngine;
+import com.squareup.okhttp.internal.http.RouteException;
 import com.squareup.okhttp.internal.http.Transport;
 import java.io.IOException;
 import java.util.logging.Logger;
+import javax.net.ssl.SSLSocket;
+import okio.BufferedSink;
+import okio.BufferedSource;
 
 /**
  * Escalate internal APIs in {@code com.squareup.okhttp} so they can be used
@@ -35,6 +40,12 @@
  */
 public abstract class Internal {
   public static final Logger logger = Logger.getLogger(OkHttpClient.class.getName());
+
+  public static void initializeInstanceForTests() {
+    // Needed in tests to ensure that the instance is actually pointing to something.
+    new OkHttpClient();
+  }
+
   public static Internal instance;
 
   public abstract Transport newTransport(Connection connection, HttpEngine httpEngine)
@@ -54,6 +65,8 @@
 
   public abstract void addLenient(Headers.Builder builder, String line);
 
+  public abstract void addLenient(Headers.Builder builder, String name, String value);
+
   public abstract void setCache(OkHttpClient client, InternalCache internalCache);
 
   public abstract InternalCache internalCache(OkHttpClient client);
@@ -67,11 +80,16 @@
   public abstract void setNetwork(OkHttpClient client, Network network);
 
   public abstract void connectAndSetOwner(OkHttpClient client, Connection connection,
-      HttpEngine owner, Request request) throws IOException;
+      HttpEngine owner, Request request) throws RouteException;
+
+  public abstract void apply(ConnectionSpec tlsConfiguration, SSLSocket sslSocket,
+      boolean isFallback);
 
   // TODO delete the following when web sockets move into the main package.
   public abstract void callEnqueue(Call call, Callback responseCallback, boolean forWebSocket);
   public abstract void callEngineReleaseConnection(Call call) throws IOException;
   public abstract Connection callEngineGetConnection(Call call);
+  public abstract BufferedSource connectionRawSource(Connection connection);
+  public abstract BufferedSink connectionRawSink(Connection connection);
   public abstract void connectionSetOwner(Connection connection, Object owner);
 }
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/RouteDatabase.java b/okhttp/src/main/java/com/squareup/okhttp/internal/RouteDatabase.java
index 52c211e..1c96c7f 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/internal/RouteDatabase.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/RouteDatabase.java
@@ -22,9 +22,8 @@
 /**
  * A blacklist of failed routes to avoid when creating a new connection to a
  * target address. This is used so that OkHttp can learn from its mistakes: if
- * there was a failure attempting to connect to a specific IP address, proxy
- * server or TLS mode, that failure is remembered and alternate routes are
- * preferred.
+ * there was a failure attempting to connect to a specific IP address or proxy
+ * server, that failure is remembered and alternate routes are preferred.
  */
 public final class RouteDatabase {
   private final Set<Route> failedRoutes = new LinkedHashSet<>();
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/http/HttpConnection.java b/okhttp/src/main/java/com/squareup/okhttp/internal/http/HttpConnection.java
index 6bf1802..d07b8b7 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/internal/http/HttpConnection.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/http/HttpConnection.java
@@ -257,6 +257,14 @@
     return new UnknownLengthSource();
   }
 
+  public BufferedSink rawSink() {
+    return sink;
+  }
+
+  public BufferedSource rawSource() {
+    return source;
+  }
+
   /** An HTTP body with a fixed length known in advance. */
   private final class FixedLengthSink implements Sink {
     private boolean closed;
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/http/HttpEngine.java b/okhttp/src/main/java/com/squareup/okhttp/internal/http/HttpEngine.java
index 8e44883..0fdce80 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/internal/http/HttpEngine.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/http/HttpEngine.java
@@ -210,8 +210,15 @@
    * Figures out what the response source will be, and opens a socket to that
    * source if necessary. Prepares the request headers and gets ready to start
    * writing the request body if it exists.
+   *
+   * @throws RequestException if there was a problem with request setup. Unrecoverable.
+   * @throws RouteException if the was a problem during connection via a specific route. Sometimes
+   *     recoverable. See {@link #recover(RouteException)}.
+   * @throws IOException if there was a problem while making a request. Sometimes recoverable. See
+   *     {@link #recover(IOException)}.
+   *
    */
-  public void sendRequest() throws IOException {
+  public void sendRequest() throws RequestException, RouteException, IOException {
     if (cacheStrategy != null) return; // Already sent.
     if (transport != null) throw new IllegalStateException();
 
@@ -308,12 +315,16 @@
   }
 
   /** Connect to the origin server either directly or via a proxy. */
-  private void connect() throws IOException {
+  private void connect() throws RequestException, RouteException {
     if (connection != null) throw new IllegalStateException();
 
     if (routeSelector == null) {
       address = createAddress(client, networkRequest);
-      routeSelector = RouteSelector.get(address, networkRequest, client);
+      try {
+        routeSelector = RouteSelector.get(address, networkRequest, client);
+      } catch (IOException e) {
+        throw new RequestException(e);
+      }
     }
 
     connection = nextConnection();
@@ -325,13 +336,13 @@
    *
    * @throws java.util.NoSuchElementException if there are no more routes to attempt.
    */
-  private Connection nextConnection() throws IOException {
+  private Connection nextConnection() throws RouteException {
     Connection connection = createNextConnection();
     Internal.instance.connectAndSetOwner(client, connection, this, networkRequest);
     return connection;
   }
 
-  private Connection createNextConnection() throws IOException {
+  private Connection createNextConnection() throws RouteException {
     ConnectionPool pool = client.getConnectionPool();
 
     // Always prefer pooled connections over new connections.
@@ -339,10 +350,15 @@
       if (networkRequest.method().equals("GET") || Internal.instance.isReadable(pooled)) {
         return pooled;
       }
-      pooled.getSocket().close();
+      closeQuietly(pooled.getSocket());
     }
-    Route route = routeSelector.next();
-    return new Connection(pool, route);
+
+    try {
+      Route route = routeSelector.next();
+      return new Connection(pool, route);
+    } catch (IOException e) {
+      throw new RouteException(e);
+    }
   }
 
   /**
@@ -393,8 +409,75 @@
   }
 
   /**
-   * Report and attempt to recover from {@code e}. Returns a new HTTP engine
-   * that should be used for the retry if {@code e} is recoverable, or null if
+   * Attempt to recover from failure to connect via a route. Returns a new HTTP engine
+   * that should be used for the retry if there are other routes to try, or null if
+   * there are no more routes to try.
+   */
+  public HttpEngine recover(RouteException e) {
+    if (routeSelector != null && connection != null) {
+      connectFailed(routeSelector, e.getLastConnectException());
+    }
+
+    if (routeSelector == null && connection == null // No connection.
+        || routeSelector != null && !routeSelector.hasNext() // No more routes to attempt.
+        || !isRecoverable(e)) {
+      return null;
+    }
+
+    Connection connection = close();
+
+    // For failure recovery, use the same route selector with a new connection.
+    return new HttpEngine(client, userRequest, bufferRequestBody, callerWritesRequestBody,
+        forWebSocket, connection, routeSelector, (RetryableSink) requestBodyOut, priorResponse);
+  }
+
+  private boolean isRecoverable(RouteException e) {
+    // If the application has opted-out of recovery, don't recover.
+    if (!client.getRetryOnConnectionFailure()) {
+      return false;
+    }
+
+    // Problems with a route may mean the connection can be retried with a new route, or may
+    // indicate a client-side or server-side issue that should not be retried. To tell, we must look
+    // at the cause.
+
+    IOException ioe = e.getLastConnectException();
+
+    // TODO(nfuller): This is the same logic as in ConnectionSpecSelector
+    // If there was a protocol problem, don't recover.
+    if (ioe instanceof ProtocolException) {
+      return false;
+    }
+
+    // If there was an interruption or timeout, don't recover.
+    if (ioe instanceof InterruptedIOException) {
+      return false;
+    }
+
+    // Look for known client-side or negotiation errors that are unlikely to be fixed by trying
+    // again with a different route.
+    if (ioe instanceof SSLHandshakeException) {
+      // If the problem was a CertificateException from the X509TrustManager,
+      // do not retry.
+      if (ioe.getCause() instanceof CertificateException) {
+        return false;
+      }
+    }
+    if (ioe instanceof SSLPeerUnverifiedException) {
+      // e.g. a certificate pinning error.
+      return false;
+    }
+    // TODO(nfuller): End of common code.
+
+    // An example of one we might want to retry with a different route is a problem connecting to a
+    // proxy and would manifest as a standard IOException. Unless it is one we know we should not
+    // retry, we return true and try a new route.
+    return true;
+  }
+
+  /**
+   * Report and attempt to recover from a failure to communicate with a server. Returns a new
+   * HTTP engine that should be used for the retry if {@code e} is recoverable, or null if
    * the failure is permanent. Requests with a body can only be recovered if the
    * body is buffered.
    */
@@ -435,13 +518,6 @@
       return false;
     }
 
-    // If the problem was a CertificateException from the X509TrustManager,
-    // do not retry, we didn't have an abrupt server-initiated exception.
-    if (e instanceof SSLPeerUnverifiedException
-        || (e instanceof SSLHandshakeException && e.getCause() instanceof CertificateException)) {
-      return false;
-    }
-
     // If there was a protocol problem, don't recover.
     if (e instanceof ProtocolException) {
       return false;
@@ -1053,10 +1129,10 @@
   }
 
   private static Address createAddress(OkHttpClient client, Request request)
-      throws UnknownHostException {
+      throws RequestException {
     String uriHost = request.url().getHost();
     if (uriHost == null || uriHost.length() == 0) {
-      throw new UnknownHostException(request.url().toString());
+      throw new RequestException(new UnknownHostException(request.url().toString()));
     }
 
     SSLSocketFactory sslSocketFactory = null;
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/http/OkHeaders.java b/okhttp/src/main/java/com/squareup/okhttp/internal/http/OkHeaders.java
index a39c657..c381c47 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/internal/http/OkHeaders.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/http/OkHeaders.java
@@ -146,16 +146,31 @@
    * be cached.
    */
   public static boolean hasVaryAll(Response response) {
-    return varyFields(response).contains("*");
+    return hasVaryAll(response.headers());
+  }
+
+  /**
+   * Returns true if a Vary header contains an asterisk. Such responses cannot
+   * be cached.
+   */
+  public static boolean hasVaryAll(Headers responseHeaders) {
+    return varyFields(responseHeaders).contains("*");
   }
 
   private static Set<String> varyFields(Response response) {
-    Set<String> result = Collections.emptySet();
-    Headers headers = response.headers();
-    for (int i = 0, size = headers.size(); i < size; i++) {
-      if (!"Vary".equalsIgnoreCase(headers.name(i))) continue;
+    return varyFields(response.headers());
+  }
 
-      String value = headers.value(i);
+  /**
+   * Returns the names of the request headers that need to be checked for
+   * equality when caching.
+   */
+  public static Set<String> varyFields(Headers responseHeaders) {
+    Set<String> result = Collections.emptySet();
+    for (int i = 0, size = responseHeaders.size(); i < size; i++) {
+      if (!"Vary".equalsIgnoreCase(responseHeaders.name(i))) continue;
+
+      String value = responseHeaders.value(i);
       if (result.isEmpty()) {
         result = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
       }
@@ -171,13 +186,21 @@
    * impact the content of response's body.
    */
   public static Headers varyHeaders(Response response) {
-    Set<String> varyFields = varyFields(response);
-    if (varyFields.isEmpty()) return new Headers.Builder().build();
-
     // Use the request headers sent over the network, since that's what the
     // response varies on. Otherwise OkHttp-supplied headers like
     // "Accept-Encoding: gzip" may be lost.
     Headers requestHeaders = response.networkResponse().request().headers();
+    Headers responseHeaders = response.headers();
+    return varyHeaders(requestHeaders, responseHeaders);
+  }
+
+  /**
+   * Returns the subset of the headers in {@code requestHeaders} that
+   * impact the content of response's body.
+   */
+  public static Headers varyHeaders(Headers requestHeaders, Headers responseHeaders) {
+    Set<String> varyFields = varyFields(responseHeaders);
+    if (varyFields.isEmpty()) return new Headers.Builder().build();
 
     Headers.Builder result = new Headers.Builder();
     for (int i = 0, size = requestHeaders.size(); i < size; i++) {
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/http/RequestException.java b/okhttp/src/main/java/com/squareup/okhttp/internal/http/RequestException.java
new file mode 100644
index 0000000..16893ac
--- /dev/null
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/http/RequestException.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp.internal.http;
+
+import java.io.IOException;
+
+/**
+ * Indicates a problem with interpreting a request. It may indicate there was a problem with the
+ * request itself, or the environment being used to interpret the request (network failure, etc.).
+ */
+public final class RequestException extends Exception {
+
+  public RequestException(IOException cause) {
+    super(cause);
+  }
+
+  @Override
+  public IOException getCause() {
+    return (IOException) super.getCause();
+  }
+}
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/http/RouteException.java b/okhttp/src/main/java/com/squareup/okhttp/internal/http/RouteException.java
new file mode 100644
index 0000000..62b3175
--- /dev/null
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/http/RouteException.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp.internal.http;
+
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+/**
+ * An exception thrown to indicate a problem connecting via a single Route. Multiple attempts may
+ * have been made with alternative protocols, none of which were successful.
+ */
+public final class RouteException extends Exception {
+  private static final Method addSuppressedExceptionMethod;
+  static {
+    Method m;
+    try {
+      m = Throwable.class.getDeclaredMethod("addSuppressed", Throwable.class);
+    } catch (Exception e) {
+      m = null;
+    }
+    addSuppressedExceptionMethod = m;
+  }
+  private IOException lastException;
+
+  public RouteException(IOException cause) {
+    super(cause);
+    lastException = cause;
+  }
+
+  public IOException getLastConnectException() {
+    return lastException;
+  }
+
+  public void addConnectException(IOException e) {
+    addSuppressedIfPossible(e, lastException);
+    lastException = e;
+  }
+
+  private void addSuppressedIfPossible(IOException e, IOException suppressed) {
+    if (addSuppressedExceptionMethod != null) {
+      try {
+        addSuppressedExceptionMethod.invoke(e, suppressed);
+      } catch (InvocationTargetException | IllegalAccessException ignored) {
+      }
+    }
+  }
+}
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/http/RouteSelector.java b/okhttp/src/main/java/com/squareup/okhttp/internal/http/RouteSelector.java
index 4a65324..16448e4 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/internal/http/RouteSelector.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/http/RouteSelector.java
@@ -16,7 +16,6 @@
 package com.squareup.okhttp.internal.http;
 
 import com.squareup.okhttp.Address;
-import com.squareup.okhttp.ConnectionSpec;
 import com.squareup.okhttp.OkHttpClient;
 import com.squareup.okhttp.Request;
 import com.squareup.okhttp.Route;
@@ -30,13 +29,10 @@
 import java.net.SocketAddress;
 import java.net.SocketException;
 import java.net.URI;
-import java.net.UnknownServiceException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.NoSuchElementException;
-import javax.net.ssl.SSLHandshakeException;
-import javax.net.ssl.SSLProtocolException;
 
 import static com.squareup.okhttp.internal.Util.getEffectivePort;
 
@@ -51,12 +47,10 @@
   private final Network network;
   private final OkHttpClient client;
   private final RouteDatabase routeDatabase;
-  private final Request request;
 
   /* The most recently attempted route. */
   private Proxy lastProxy;
   private InetSocketAddress lastInetSocketAddress;
-  private ConnectionSpec lastSpec;
 
   /* State for negotiating the next proxy to use. */
   private List<Proxy> proxies = Collections.emptyList();
@@ -66,27 +60,22 @@
   private List<InetSocketAddress> inetSocketAddresses = Collections.emptyList();
   private int nextInetSocketAddressIndex;
 
-  /* Specs to attempt with the connection. */
-  private List<ConnectionSpec> connectionSpecs = Collections.emptyList();
-  private int nextSpecIndex;
-
   /* State for negotiating failed routes */
   private final List<Route> postponedRoutes = new ArrayList<>();
 
-  private RouteSelector(Address address, URI uri, OkHttpClient client, Request request) {
+  private RouteSelector(Address address, URI uri, OkHttpClient client) {
     this.address = address;
     this.uri = uri;
     this.client = client;
     this.routeDatabase = Internal.instance.routeDatabase(client);
     this.network = Internal.instance.network(client);
-    this.request = request;
 
     resetNextProxy(uri, address.getProxy());
   }
 
   public static RouteSelector get(Address address, Request request, OkHttpClient client)
       throws IOException {
-    return new RouteSelector(address, request.uri(), client, request);
+    return new RouteSelector(address, request.uri(), client);
   }
 
   /**
@@ -94,31 +83,25 @@
    * least one route.
    */
   public boolean hasNext() {
-    return hasNextConnectionSpec()
-        || hasNextInetSocketAddress()
+    return hasNextInetSocketAddress()
         || hasNextProxy()
         || hasNextPostponed();
   }
 
   public Route next() throws IOException {
     // Compute the next route to attempt.
-    if (!hasNextConnectionSpec()) {
-      if (!hasNextInetSocketAddress()) {
-        if (!hasNextProxy()) {
-          if (!hasNextPostponed()) {
-            throw new NoSuchElementException();
-          }
-          return nextPostponed();
+    if (!hasNextInetSocketAddress()) {
+      if (!hasNextProxy()) {
+        if (!hasNextPostponed()) {
+          throw new NoSuchElementException();
         }
-        lastProxy = nextProxy();
+        return nextPostponed();
       }
-      lastInetSocketAddress = nextInetSocketAddress();
+      lastProxy = nextProxy();
     }
-    lastSpec = nextConnectionSpec();
+    lastInetSocketAddress = nextInetSocketAddress();
 
-    final boolean shouldSendTlsFallbackIndicator = shouldSendTlsFallbackIndicator(lastSpec);
-    Route route = new Route(address, lastProxy, lastInetSocketAddress, lastSpec,
-        shouldSendTlsFallbackIndicator);
+    Route route = new Route(address, lastProxy, lastInetSocketAddress);
     if (routeDatabase.shouldPostpone(route)) {
       postponedRoutes.add(route);
       // We will only recurse in order to skip previously failed routes. They will be tried last.
@@ -128,11 +111,6 @@
     return route;
   }
 
-  private boolean shouldSendTlsFallbackIndicator(ConnectionSpec connectionSpec) {
-    return connectionSpec != connectionSpecs.get(0)
-        && connectionSpec.isTls();
-  }
-
   /**
    * Clients should invoke this method when they encounter a connectivity
    * failure on a connection returned by this route selector.
@@ -144,20 +122,6 @@
     }
 
     routeDatabase.failed(failedRoute);
-
-    // If the previously returned route's problem was not related to the connection's spec, and the
-    // next route only changes that, we shouldn't even attempt it. This suppresses it in both this
-    // selector and also in the route database.
-    if (!(failure instanceof SSLHandshakeException) && !(failure instanceof SSLProtocolException)) {
-      while (nextSpecIndex < connectionSpecs.size()) {
-        ConnectionSpec connectionSpec = connectionSpecs.get(nextSpecIndex++);
-        final boolean shouldSendTlsFallbackIndicator =
-            shouldSendTlsFallbackIndicator(connectionSpec);
-        Route toSuppress = new Route(address, lastProxy, lastInetSocketAddress, connectionSpec,
-            shouldSendTlsFallbackIndicator);
-        routeDatabase.failed(toSuppress);
-      }
-    }
   }
 
   /** Prepares the proxy servers to try. */
@@ -224,6 +188,7 @@
     for (InetAddress inetAddress : network.resolveInetAddresses(socketHost)) {
       inetSocketAddresses.add(new InetSocketAddress(inetAddress, socketPort));
     }
+
     nextInetSocketAddressIndex = 0;
   }
 
@@ -256,42 +221,7 @@
       throw new SocketException("No route to " + address.getUriHost()
           + "; exhausted inet socket addresses: " + inetSocketAddresses);
     }
-    InetSocketAddress result = inetSocketAddresses.get(nextInetSocketAddressIndex++);
-    resetConnectionSpecs();
-    return result;
-  }
-
-  /** Prepares the connection specs to attempt. */
-  private void resetConnectionSpecs() {
-    connectionSpecs = new ArrayList<>();
-    List<ConnectionSpec> specs = address.getConnectionSpecs();
-    for (int i = 0, size = specs.size(); i < size; i++) {
-      ConnectionSpec spec = specs.get(i);
-      if (request.isHttps() == spec.isTls()) {
-        connectionSpecs.add(spec);
-      }
-    }
-    nextSpecIndex = 0;
-  }
-
-  /** Returns true if there's another connection spec to try. */
-  private boolean hasNextConnectionSpec() {
-    return nextSpecIndex < connectionSpecs.size();
-  }
-
-  /** Returns the next connection spec to try. */
-  private ConnectionSpec nextConnectionSpec() throws IOException {
-    if (connectionSpecs.isEmpty()) {
-      throw new UnknownServiceException("No route to "
-          + ((uri.getScheme() != null) ? (uri.getScheme() + "://") : "//") + address.getUriHost()
-          + "; no connection specs");
-    }
-    if (!hasNextConnectionSpec()) {
-      throw new SocketException("No route to "
-          + ((uri.getScheme() != null) ? (uri.getScheme() + "://") : "//") + address.getUriHost()
-          + "; exhausted connection specs: " + connectionSpecs);
-    }
-    return connectionSpecs.get(nextSpecIndex++);
+    return inetSocketAddresses.get(nextInetSocketAddressIndex++);
   }
 
   /** Returns true if there is another postponed route to try. */
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/http/SocketConnector.java b/okhttp/src/main/java/com/squareup/okhttp/internal/http/SocketConnector.java
new file mode 100644
index 0000000..aba3af4
--- /dev/null
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/http/SocketConnector.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright (C) 2015 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.okhttp.internal.http;
+
+import com.squareup.okhttp.Address;
+import com.squareup.okhttp.CertificatePinner;
+import com.squareup.okhttp.Connection;
+import com.squareup.okhttp.ConnectionPool;
+import com.squareup.okhttp.ConnectionSpec;
+import com.squareup.okhttp.Handshake;
+import com.squareup.okhttp.Protocol;
+import com.squareup.okhttp.Request;
+import com.squareup.okhttp.Response;
+import com.squareup.okhttp.Route;
+import com.squareup.okhttp.internal.Platform;
+import com.squareup.okhttp.internal.ConnectionSpecSelector;
+import com.squareup.okhttp.internal.Util;
+import com.squareup.okhttp.internal.tls.OkHostnameVerifier;
+
+import java.io.IOException;
+import java.net.Proxy;
+import java.net.Socket;
+import java.net.URL;
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.SSLSocketFactory;
+
+import okio.Source;
+
+import static com.squareup.okhttp.internal.Util.closeQuietly;
+import static com.squareup.okhttp.internal.Util.getDefaultPort;
+import static com.squareup.okhttp.internal.Util.getEffectivePort;
+import static java.net.HttpURLConnection.HTTP_OK;
+import static java.net.HttpURLConnection.HTTP_PROXY_AUTH;
+
+/**
+ * Helper that can establish a socket connection to a {@link com.squareup.okhttp.Route} using the
+ * specified {@link ConnectionSpec} set. A {@link SocketConnector} can be used multiple times.
+ */
+public class SocketConnector {
+  private final Connection connection;
+  private final ConnectionPool connectionPool;
+
+  public SocketConnector(Connection connection, ConnectionPool connectionPool) {
+    this.connection = connection;
+    this.connectionPool = connectionPool;
+  }
+
+  public ConnectedSocket connectCleartext(int connectTimeout, int readTimeout, Route route)
+      throws RouteException {
+    Socket socket = connectRawSocket(readTimeout, connectTimeout, route);
+    return new ConnectedSocket(route, socket);
+  }
+
+  public ConnectedSocket connectTls(int connectTimeout, int readTimeout,
+      int writeTimeout, Request request, Route route, List<ConnectionSpec> connectionSpecs,
+      boolean connectionRetryEnabled) throws RouteException {
+
+    Address address = route.getAddress();
+    ConnectionSpecSelector connectionSpecSelector = new ConnectionSpecSelector(connectionSpecs);
+    RouteException routeException = null;
+    do {
+      Socket socket = connectRawSocket(readTimeout, connectTimeout, route);
+      if (route.requiresTunnel()) {
+        createTunnel(readTimeout, writeTimeout, request, route, socket);
+      }
+
+      SSLSocket sslSocket = null;
+      try {
+        SSLSocketFactory sslSocketFactory = address.getSslSocketFactory();
+
+        // Create the wrapper over the connected socket.
+        sslSocket = (SSLSocket) sslSocketFactory
+            .createSocket(socket, address.getUriHost(), address.getUriPort(), true /* autoClose */);
+
+        // Configure the socket's ciphers, TLS versions, and extensions.
+        ConnectionSpec connectionSpec = connectionSpecSelector.configureSecureSocket(sslSocket);
+        Platform platform = Platform.get();
+        Handshake handshake = null;
+        Protocol alpnProtocol = null;
+        try {
+          if (connectionSpec.supportsTlsExtensions()) {
+            platform.configureTlsExtensions(
+                sslSocket, address.getUriHost(), address.getProtocols());
+          }
+          // Force handshake. This can throw!
+          sslSocket.startHandshake();
+
+          handshake = Handshake.get(sslSocket.getSession());
+
+          String maybeProtocol;
+          if (connectionSpec.supportsTlsExtensions()
+              && (maybeProtocol = platform.getSelectedProtocol(sslSocket)) != null) {
+            alpnProtocol = Protocol.get(maybeProtocol); // Throws IOE on unknown.
+          }
+        } finally {
+          platform.afterHandshake(sslSocket);
+        }
+
+        // Verify that the socket's certificates are acceptable for the target host.
+        if (!address.getHostnameVerifier().verify(address.getUriHost(), sslSocket.getSession())) {
+          X509Certificate cert = (X509Certificate) sslSocket.getSession()
+              .getPeerCertificates()[0];
+          throw new SSLPeerUnverifiedException(
+              "Hostname " + address.getUriHost() + " not verified:"
+              + "\n    certificate: " + CertificatePinner.pin(cert)
+              + "\n    DN: " + cert.getSubjectDN().getName()
+              + "\n    subjectAltNames: " + OkHostnameVerifier.allSubjectAltNames(cert));
+        }
+
+        // Check that the certificate pinner is satisfied by the certificates presented.
+        address.getCertificatePinner().check(address.getUriHost(), handshake.peerCertificates());
+
+        return new ConnectedSocket(route, sslSocket, alpnProtocol, handshake);
+      } catch (IOException e) {
+        boolean canRetry = connectionRetryEnabled && connectionSpecSelector.connectionFailed(e);
+        closeQuietly(sslSocket);
+        closeQuietly(socket);
+        if (routeException == null) {
+          routeException = new RouteException(e);
+        } else {
+          routeException.addConnectException(e);
+        }
+        if (!canRetry) {
+          throw routeException;
+        }
+      }
+    } while (true);
+  }
+
+  private Socket connectRawSocket(int soTimeout, int connectTimeout, Route route)
+      throws RouteException {
+    Platform platform = Platform.get();
+    try {
+      Proxy proxy = route.getProxy();
+      Address address = route.getAddress();
+      Socket socket;
+      if (proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.HTTP) {
+        socket = address.getSocketFactory().createSocket();
+      } else {
+        socket = new Socket(proxy);
+      }
+      socket.setSoTimeout(soTimeout);
+      platform.connectSocket(socket, route.getSocketAddress(), connectTimeout);
+
+      return socket;
+    } catch (IOException e) {
+      throw new RouteException(e);
+    }
+  }
+
+  /**
+   * To make an HTTPS connection over an HTTP proxy, send an unencrypted
+   * CONNECT request to create the proxy connection. This may need to be
+   * retried if the proxy requires authorization.
+   */
+  private void createTunnel(int readTimeout, int writeTimeout, Request request, Route route,
+      Socket socket) throws RouteException {
+    // Make an SSL Tunnel on the first message pair of each SSL + proxy connection.
+    try {
+      Request tunnelRequest = createTunnelRequest(request);
+      HttpConnection tunnelConnection = new HttpConnection(connectionPool, connection, socket);
+      tunnelConnection.setTimeouts(readTimeout, writeTimeout);
+      URL url = tunnelRequest.url();
+      String requestLine = "CONNECT " + url.getHost() + ":" + url.getPort() + " HTTP/1.1";
+      while (true) {
+        tunnelConnection.writeRequest(tunnelRequest.headers(), requestLine);
+        tunnelConnection.flush();
+        Response response = tunnelConnection.readResponse().request(tunnelRequest).build();
+        // The response body from a CONNECT should be empty, but if it is not then we should consume
+        // it before proceeding.
+        long contentLength = OkHeaders.contentLength(response);
+        if (contentLength == -1L) {
+          contentLength = 0L;
+        }
+        Source body = tunnelConnection.newFixedLengthSource(contentLength);
+        Util.skipAll(body, Integer.MAX_VALUE, TimeUnit.MILLISECONDS);
+        body.close();
+
+        switch (response.code()) {
+          case HTTP_OK:
+            // Assume the server won't send a TLS ServerHello until we send a TLS ClientHello. If
+            // that happens, then we will have buffered bytes that are needed by the SSLSocket!
+            // This check is imperfect: it doesn't tell us whether a handshake will succeed, just
+            // that it will almost certainly fail because the proxy has sent unexpected data.
+            if (tunnelConnection.bufferSize() > 0) {
+              throw new IOException("TLS tunnel buffered too many bytes!");
+            }
+            return;
+
+          case HTTP_PROXY_AUTH:
+            tunnelRequest = OkHeaders.processAuthHeader(
+                route.getAddress().getAuthenticator(), response, route.getProxy());
+            if (tunnelRequest != null) continue;
+            throw new IOException("Failed to authenticate with proxy");
+
+          default:
+            throw new IOException(
+                "Unexpected response code for CONNECT: " + response.code());
+        }
+      }
+    } catch (IOException e) {
+      throw new RouteException(e);
+    }
+  }
+
+  /**
+   * Returns a request that creates a TLS tunnel via an HTTP proxy, or null if
+   * no tunnel is necessary. Everything in the tunnel request is sent
+   * unencrypted to the proxy server, so tunnels include only the minimum set of
+   * headers. This avoids sending potentially sensitive data like HTTP cookies
+   * to the proxy unencrypted.
+   */
+  private Request createTunnelRequest(Request request) throws IOException {
+    String host = request.url().getHost();
+    int port = getEffectivePort(request.url());
+    String authority = (port == getDefaultPort("https")) ? host : (host + ":" + port);
+    Request.Builder result = new Request.Builder()
+        .url(new URL("https", host, port, "/"))
+        .header("Host", authority)
+        .header("Proxy-Connection", "Keep-Alive"); // For HTTP/1.0 proxies like Squid.
+
+    // Copy over the User-Agent header if it exists.
+    String userAgent = request.header("User-Agent");
+    if (userAgent != null) {
+      result.header("User-Agent", userAgent);
+    }
+
+    // Copy over the Proxy-Authorization header if it exists.
+    String proxyAuthorization = request.header("Proxy-Authorization");
+    if (proxyAuthorization != null) {
+      result.header("Proxy-Authorization", proxyAuthorization);
+    }
+
+    return result.build();
+  }
+
+  /**
+   * A connected socket with metadata.
+   */
+  public static class ConnectedSocket {
+    public final Route route;
+    public final Socket socket;
+    public final Protocol alpnProtocol;
+    public final Handshake handshake;
+
+    /** A connected plain / raw (i.e. unencrypted communication) socket. */
+    public ConnectedSocket(Route route, Socket socket) {
+      this.route = route;
+      this.socket = socket;
+      alpnProtocol = null;
+      handshake = null;
+    }
+
+    /** A connected {@link SSLSocket}. */
+    public ConnectedSocket(Route route, SSLSocket socket, Protocol alpnProtocol,
+        Handshake handshake) {
+      this.route = route;
+      this.socket = socket;
+      this.alpnProtocol = alpnProtocol;
+      this.handshake = handshake;
+    }
+  }
+}
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java b/okhttp/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java
index 04bff0b..2966ce0 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/spdy/SpdyConnection.java
@@ -194,6 +194,7 @@
     if (stream != null && streams.isEmpty()) {
       setIdle(true);
     }
+    notifyAll(); // The removed stream may be blocked on a connection-wide window update.
     return stream;
   }
 
@@ -285,19 +286,16 @@
   }
 
   /**
-   * Callers of this method are not thread safe, and sometimes on application
-   * threads.  Most often, this method will be called to send a buffer worth of
-   * data to the peer.
-   * <p>
-   * Writes are subject to the write window of the stream and the connection.
-   * Until there is a window sufficient to send {@code byteCount}, the caller
-   * will block.  For example, a user of {@code HttpURLConnection} who flushes
-   * more bytes to the output stream than the connection's write window will
-   * block.
-   * <p>
-   * Zero {@code byteCount} writes are not subject to flow control and
-   * will not block.  The only use case for zero {@code byteCount} is closing
-   * a flushed output stream.
+   * Callers of this method are not thread safe, and sometimes on application threads. Most often,
+   * this method will be called to send a buffer worth of data to the peer.
+   *
+   * <p>Writes are subject to the write window of the stream and the connection. Until there is a
+   * window sufficient to send {@code byteCount}, the caller will block. For example, a user of
+   * {@code HttpURLConnection} who flushes more bytes to the output stream than the connection's
+   * write window will block.
+   *
+   * <p>Zero {@code byteCount} writes are not subject to flow control and will not block. The only
+   * use case for zero {@code byteCount} is closing a flushed output stream.
    */
   public void writeData(int streamId, boolean outFinished, Buffer buffer, long byteCount)
       throws IOException {
@@ -311,6 +309,11 @@
       synchronized (SpdyConnection.this) {
         try {
           while (bytesLeftInWriteWindow <= 0) {
+            // Before blocking, confirm that the stream we're writing is still open. It's possible
+            // that the stream has since been closed (such as if this write timed out.)
+            if (!streams.containsKey(streamId)) {
+              throw new IOException("stream closed");
+            }
             SpdyConnection.this.wait(); // Wait until we receive a WINDOW_UPDATE.
           }
         } catch (InterruptedException e) {
@@ -575,7 +578,7 @@
         }
         connectionErrorCode = ErrorCode.NO_ERROR;
         streamErrorCode = ErrorCode.CANCEL;
-      } catch (IOException e) {
+      } catch (RuntimeException | IOException e) {
         connectionErrorCode = ErrorCode.PROTOCOL_ERROR;
         streamErrorCode = ErrorCode.PROTOCOL_ERROR;
       } finally {
@@ -640,8 +643,11 @@
             @Override public void execute() {
               try {
                 handler.receive(newStream);
-              } catch (IOException e) {
-                throw new RuntimeException(e);
+              } catch (RuntimeException | IOException e) {
+                try {
+                  newStream.close(ErrorCode.PROTOCOL_ERROR);
+                } catch (IOException ignored) {
+                }
               }
             }
           });
diff --git a/okhttp/src/main/java/com/squareup/okhttp/internal/spdy/SpdyStream.java b/okhttp/src/main/java/com/squareup/okhttp/internal/spdy/SpdyStream.java
index abc5df6..05ce57a 100644
--- a/okhttp/src/main/java/com/squareup/okhttp/internal/spdy/SpdyStream.java
+++ b/okhttp/src/main/java/com/squareup/okhttp/internal/spdy/SpdyStream.java
@@ -501,7 +501,7 @@
         writeTimeout.enter();
         try {
           while (bytesLeftInWriteWindow <= 0 && !finished && !closed && errorCode == null) {
-            waitForIo(); // Wait until we receive a WINDOW_UPDATE.
+            waitForIo(); // Wait until we receive a WINDOW_UPDATE for this stream.
           }
         } finally {
           writeTimeout.exitAndThrowIfTimedOut();
@@ -512,7 +512,12 @@
         bytesLeftInWriteWindow -= toWrite;
       }
 
-      connection.writeData(id, outFinished && toWrite == sendBuffer.size(), sendBuffer, toWrite);
+      writeTimeout.enter();
+      try {
+        connection.writeData(id, outFinished && toWrite == sendBuffer.size(), sendBuffer, toWrite);
+      } finally {
+        writeTimeout.exitAndThrowIfTimedOut();
+      }
     }
 
     @Override public void flush() throws IOException {
@@ -522,8 +527,8 @@
       }
       while (sendBuffer.size() > 0) {
         emitDataFrame(false);
+        connection.flush();
       }
-      connection.flush();
     }
 
     @Override public Timeout timeout() {
diff --git a/okio/okio/src/main/java/okio/Buffer.java b/okio/okio/src/main/java/okio/Buffer.java
index 60eb2a3..04d2793 100644
--- a/okio/okio/src/main/java/okio/Buffer.java
+++ b/okio/okio/src/main/java/okio/Buffer.java
@@ -47,6 +47,7 @@
 public final class Buffer implements BufferedSource, BufferedSink, Cloneable {
   private static final byte[] DIGITS =
       { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' };
+  static final int REPLACEMENT_CHARACTER = '\ufffd';
 
   Segment head;
   long size;
@@ -626,6 +627,81 @@
     }
   }
 
+  @Override public int readUtf8CodePoint() throws EOFException {
+    if (size == 0) throw new EOFException();
+
+    byte b0 = getByte(0);
+    int codePoint;
+    int byteCount;
+    int min;
+
+    if ((b0 & 0x80) == 0) {
+      // 0xxxxxxx.
+      codePoint = b0 & 0x7f;
+      byteCount = 1; // 7 bits (ASCII).
+      min = 0x0;
+
+    } else if ((b0 & 0xe0) == 0xc0) {
+      // 0x110xxxxx
+      codePoint = b0 & 0x1f;
+      byteCount = 2; // 11 bits (5 + 6).
+      min = 0x80;
+
+    } else if ((b0 & 0xf0) == 0xe0) {
+      // 0x1110xxxx
+      codePoint = b0 & 0x0f;
+      byteCount = 3; // 16 bits (4 + 6 + 6).
+      min = 0x800;
+
+    } else if ((b0 & 0xf8) == 0xf0) {
+      // 0x11110xxx
+      codePoint = b0 & 0x07;
+      byteCount = 4; // 21 bits (3 + 6 + 6 + 6).
+      min = 0x10000;
+
+    } else {
+      // We expected the first byte of a code point but got something else.
+      skip(1);
+      return REPLACEMENT_CHARACTER;
+    }
+
+    if (size < byteCount) {
+      throw new EOFException("size < " + byteCount + ": " + size
+          + " (to read code point prefixed 0x" + Integer.toHexString(b0) + ")");
+    }
+
+    // Read the continuation bytes. If we encounter a non-continuation byte, the sequence consumed
+    // thus far is truncated and is decoded as the replacement character. That non-continuation byte
+    // is left in the stream for processing by the next call to readUtf8CodePoint().
+    for (int i = 1; i < byteCount; i++) {
+      byte b = getByte(i);
+      if ((b & 0xc0) == 0x80) {
+        // 0x10xxxxxx
+        codePoint <<= 6;
+        codePoint |= b & 0x3f;
+      } else {
+        skip(i);
+        return REPLACEMENT_CHARACTER;
+      }
+    }
+
+    skip(byteCount);
+
+    if (codePoint > 0x10ffff) {
+      return REPLACEMENT_CHARACTER; // Reject code points larger than the Unicode maximum.
+    }
+
+    if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
+      return REPLACEMENT_CHARACTER; // Reject partial surrogates.
+    }
+
+    if (codePoint < min) {
+      return REPLACEMENT_CHARACTER; // Reject overlong code points.
+    }
+
+    return codePoint;
+  }
+
   @Override public byte[] readByteArray() {
     try {
       return readByteArray(size);
@@ -793,6 +869,42 @@
     return this;
   }
 
+  @Override public Buffer writeUtf8CodePoint(int codePoint) {
+    if (codePoint < 0x80) {
+      // Emit a 7-bit code point with 1 byte.
+      writeByte(codePoint);
+
+    } else if (codePoint < 0x800) {
+      // Emit a 11-bit code point with 2 bytes.
+      writeByte(codePoint >>  6        | 0xc0); // 110xxxxx
+      writeByte(codePoint       & 0x3f | 0x80); // 10xxxxxx
+
+    } else if (codePoint < 0x10000) {
+      if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
+        throw new IllegalArgumentException(
+            "Unexpected code point: " + Integer.toHexString(codePoint));
+      }
+
+      // Emit a 16-bit code point with 3 bytes.
+      writeByte(codePoint >> 12        | 0xe0); // 1110xxxx
+      writeByte(codePoint >>  6 & 0x3f | 0x80); // 10xxxxxx
+      writeByte(codePoint       & 0x3f | 0x80); // 10xxxxxx
+
+    } else if (codePoint <= 0x10ffff) {
+      // Emit a 21-bit code point with 4 bytes.
+      writeByte(codePoint >> 18        | 0xf0); // 11110xxx
+      writeByte(codePoint >> 12 & 0x3f | 0x80); // 10xxxxxx
+      writeByte(codePoint >>  6 & 0x3f | 0x80); // 10xxxxxx
+      writeByte(codePoint       & 0x3f | 0x80); // 10xxxxxx
+
+    } else {
+      throw new IllegalArgumentException(
+          "Unexpected code point: " + Integer.toHexString(codePoint));
+    }
+
+    return this;
+  }
+
   @Override public Buffer writeString(String string, Charset charset) {
     return writeString(string, 0, string.length(), charset);
   }
diff --git a/okio/okio/src/main/java/okio/BufferedSink.java b/okio/okio/src/main/java/okio/BufferedSink.java
index 7a08b7f..93b303b 100644
--- a/okio/okio/src/main/java/okio/BufferedSink.java
+++ b/okio/okio/src/main/java/okio/BufferedSink.java
@@ -59,6 +59,9 @@
    */
   BufferedSink writeUtf8(String string, int beginIndex, int endIndex) throws IOException;
 
+  /** Encodes {@code codePoint} in UTF-8 and writes it to this sink. */
+  BufferedSink writeUtf8CodePoint(int codePoint) throws IOException;
+
   /** Encodes {@code string} in {@code charset} and writes it to this sink. */
   BufferedSink writeString(String string, Charset charset) throws IOException;
 
diff --git a/okio/okio/src/main/java/okio/BufferedSource.java b/okio/okio/src/main/java/okio/BufferedSource.java
index fd178ab..ba4545f 100644
--- a/okio/okio/src/main/java/okio/BufferedSource.java
+++ b/okio/okio/src/main/java/okio/BufferedSource.java
@@ -173,6 +173,20 @@
   String readUtf8LineStrict() throws IOException;
 
   /**
+   * Removes and returns a single UTF-8 code point, reading between 1 and 4 bytes as necessary.
+   *
+   * <p>If this source is exhausted before a complete code point can be read, this throws an {@link
+   * java.io.EOFException} and consumes no input.
+   *
+   * <p>If this source doesn't start with a properly-encoded UTF-8 code point, this method will
+   * remove 1 or more non-UTF-8 bytes and return the replacement character ({@code U+FFFD}). This
+   * covers encoding problems (the input is not properly-encoded UTF-8), characters out of range
+   * (beyond the 0x10ffff limit of Unicode), code points for UTF-16 surrogates (U+d800..U+dfff) and
+   * overlong encodings (such as {@code 0xc080} for the NUL character in modified UTF-8).
+   */
+  int readUtf8CodePoint() throws IOException;
+
+  /**
    * Removes all bytes from this, decodes them as {@code charset}, and returns
    * the string.
    */
diff --git a/okio/okio/src/main/java/okio/RealBufferedSink.java b/okio/okio/src/main/java/okio/RealBufferedSink.java
index 60a57c1..6317e97 100644
--- a/okio/okio/src/main/java/okio/RealBufferedSink.java
+++ b/okio/okio/src/main/java/okio/RealBufferedSink.java
@@ -65,6 +65,12 @@
     return emitCompleteSegments();
   }
 
+  @Override public BufferedSink writeUtf8CodePoint(int codePoint) throws IOException {
+    if (closed) throw new IllegalStateException("closed");
+    buffer.writeUtf8CodePoint(codePoint);
+    return emitCompleteSegments();
+  }
+
   @Override public BufferedSink writeString(String string, Charset charset) throws IOException {
     if (closed) throw new IllegalStateException("closed");
     buffer.writeString(string, charset);
diff --git a/okio/okio/src/main/java/okio/RealBufferedSource.java b/okio/okio/src/main/java/okio/RealBufferedSource.java
index 5ebdfbe..f1f0ad4 100644
--- a/okio/okio/src/main/java/okio/RealBufferedSource.java
+++ b/okio/okio/src/main/java/okio/RealBufferedSource.java
@@ -109,7 +109,7 @@
       // The underlying source is exhausted. Copy the bytes we got before rethrowing.
       int offset = 0;
       while (buffer.size > 0) {
-        int read = buffer.read(sink, offset, (int) buffer.size - offset);
+        int read = buffer.read(sink, offset, (int) buffer.size);
         if (read == -1) throw new AssertionError();
         offset += read;
       }
@@ -203,6 +203,21 @@
     return buffer.readUtf8Line(newline);
   }
 
+  @Override public int readUtf8CodePoint() throws IOException {
+    require(1);
+
+    byte b0 = buffer.getByte(0);
+    if ((b0 & 0xe0) == 0xc0) {
+      require(2);
+    } else if ((b0 & 0xf0) == 0xe0) {
+      require(3);
+    } else if ((b0 & 0xf8) == 0xf0) {
+      require(4);
+    }
+
+    return buffer.readUtf8CodePoint();
+  }
+
   @Override public short readShort() throws IOException {
     require(2);
     return buffer.readShort();
@@ -234,40 +249,36 @@
   }
 
   @Override public long readDecimalLong() throws IOException {
-    int pos = 0;
-    while (true) {
-      if (!request(pos + 1)) {
-        break; // No more data.
-      }
+    require(1);
+
+    for (int pos = 0; request(pos + 1); pos++) {
       byte b = buffer.getByte(pos);
       if ((b < '0' || b > '9') && (pos != 0 || b != '-')) {
-        break; // Non-digit, or non-leading negative sign.
+        // Non-digit, or non-leading negative sign.
+        if (pos == 0) {
+          throw new NumberFormatException(String.format(
+              "Expected leading [0-9] or '-' character but was %#x", b));
+        }
+        break;
       }
-      pos++;
-    }
-    if (pos == 0) {
-      throw new NumberFormatException("Expected leading [0-9] or '-' character but was 0x"
-          + Integer.toHexString(buffer.getByte(0)));
     }
 
     return buffer.readDecimalLong();
   }
 
   @Override public long readHexadecimalUnsignedLong() throws IOException {
-    int pos = 0;
-    while (true) {
-      if (!request(pos + 1)) {
-        break; // No more data.
-      }
+    require(1);
+
+    for (int pos = 0; request(pos + 1); pos++) {
       byte b = buffer.getByte(pos);
       if ((b < '0' || b > '9') && (b < 'a' || b > 'f') && (b < 'A' || b > 'F')) {
-        break; // Non-digit, or non-leading negative sign.
+        // Non-digit, or non-leading negative sign.
+        if (pos == 0) {
+          throw new NumberFormatException(String.format(
+              "Expected leading [0-9a-fA-F] character but was %#x", b));
+        }
+        break;
       }
-      pos += 1;
-    }
-    if (pos == 0) {
-      throw new NumberFormatException("Expected leading [0-9a-fA-F] character but was 0x"
-          + Integer.toHexString(buffer.getByte(0)));
     }
 
     return buffer.readHexadecimalUnsignedLong();
diff --git a/okio/okio/src/test/java/okio/BufferedSourceTest.java b/okio/okio/src/test/java/okio/BufferedSourceTest.java
index 85c9dab..5a11a46 100644
--- a/okio/okio/src/test/java/okio/BufferedSourceTest.java
+++ b/okio/okio/src/test/java/okio/BufferedSourceTest.java
@@ -37,8 +37,59 @@
 
 @RunWith(Parameterized.class)
 public class BufferedSourceTest {
+  private static final Factory BUFFER_FACTORY = new Factory() {
+    @Override public Pipe pipe() {
+      Buffer buffer = new Buffer();
+      Pipe result = new Pipe();
+      result.sink = buffer;
+      result.source = buffer;
+      return result;
+    }
+
+    @Override public String toString() {
+      return "Buffer";
+    }
+  };
+
+  private static final Factory REAL_BUFFERED_SOURCE_FACTORY = new Factory() {
+    @Override public Pipe pipe() {
+      Buffer buffer = new Buffer();
+      Pipe result = new Pipe();
+      result.sink = buffer;
+      result.source = new RealBufferedSource(buffer);
+      return result;
+    }
+
+    @Override public String toString() {
+      return "RealBufferedSource";
+    }
+  };
+
+  private static final Factory ONE_BYTE_AT_A_TIME_FACTORY = new Factory() {
+    @Override public Pipe pipe() {
+      Buffer buffer = new Buffer();
+      Pipe result = new Pipe();
+      result.sink = buffer;
+      result.source = new RealBufferedSource(new ForwardingSource(buffer) {
+        @Override public long read(Buffer sink, long byteCount) throws IOException {
+          return super.read(sink, Math.min(byteCount, 1L));
+        }
+      });
+      return result;
+    }
+
+    @Override public String toString() {
+      return "OneByteAtATime";
+    }
+  };
+
   private interface Factory {
-    BufferedSource create(Buffer data);
+    Pipe pipe();
+  }
+
+  private static class Pipe {
+    BufferedSink sink;
+    BufferedSource source;
   }
 
   // ANDROID-BEGIN
@@ -46,151 +97,133 @@
   // ANDROID-END
   public static List<Object[]> parameters() {
     return Arrays.asList(
-        new Object[] { new Factory() {
-          @Override public BufferedSource create(Buffer data) {
-            return data;
-          }
-
-          @Override public String toString() {
-            return "Buffer";
-          }
-        }},
-        new Object[] { new Factory() {
-          @Override public BufferedSource create(Buffer data) {
-            return new RealBufferedSource(data);
-          }
-
-          @Override public String toString() {
-            return "RealBufferedSource";
-          }
-        }}
-    );
+        new Object[] { BUFFER_FACTORY },
+        new Object[] { REAL_BUFFERED_SOURCE_FACTORY },
+        new Object[] { ONE_BYTE_AT_A_TIME_FACTORY });
   }
 
   // ANDROID-BEGIN
   // @Parameterized.Parameter
   public Factory factory = (Factory) (parameters().get(0))[0];
   // ANDROID-END
-
-  private Buffer data;
+  private BufferedSink sink;
   private BufferedSource source;
 
   @Before public void setUp() {
-    data = new Buffer();
-    source = factory.create(data);
+    Pipe pipe = factory.pipe();
+    sink = pipe.sink;
+    source = pipe.source;
   }
 
   @Test public void readBytes() throws Exception {
-    data.write(new byte[] { (byte) 0xab, (byte) 0xcd });
+    sink.write(new byte[] { (byte) 0xab, (byte) 0xcd });
     assertEquals(0xab, source.readByte() & 0xff);
     assertEquals(0xcd, source.readByte() & 0xff);
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readShort() throws Exception {
-    data.write(new byte[] {
+    sink.write(new byte[] {
         (byte) 0xab, (byte) 0xcd, (byte) 0xef, (byte) 0x01
     });
     assertEquals((short) 0xabcd, source.readShort());
     assertEquals((short) 0xef01, source.readShort());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readShortLe() throws Exception {
-    data.write(new byte[] {
+    sink.write(new byte[] {
         (byte) 0xab, (byte) 0xcd, (byte) 0xef, (byte) 0x10
     });
     assertEquals((short) 0xcdab, source.readShortLe());
     assertEquals((short) 0x10ef, source.readShortLe());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readShortSplitAcrossMultipleSegments() throws Exception {
-    data.writeUtf8(repeat('a', Segment.SIZE - 1));
-    data.write(new byte[] { (byte) 0xab, (byte) 0xcd });
+    sink.writeUtf8(repeat('a', Segment.SIZE - 1));
+    sink.write(new byte[] { (byte) 0xab, (byte) 0xcd });
     source.skip(Segment.SIZE - 1);
     assertEquals((short) 0xabcd, source.readShort());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readInt() throws Exception {
-    data.write(new byte[] {
+    sink.write(new byte[] {
         (byte) 0xab, (byte) 0xcd, (byte) 0xef, (byte) 0x01, (byte) 0x87, (byte) 0x65, (byte) 0x43,
         (byte) 0x21
     });
     assertEquals(0xabcdef01, source.readInt());
     assertEquals(0x87654321, source.readInt());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readIntLe() throws Exception {
-    data.write(new byte[] {
+    sink.write(new byte[] {
         (byte) 0xab, (byte) 0xcd, (byte) 0xef, (byte) 0x10, (byte) 0x87, (byte) 0x65, (byte) 0x43,
         (byte) 0x21
     });
     assertEquals(0x10efcdab, source.readIntLe());
     assertEquals(0x21436587, source.readIntLe());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readIntSplitAcrossMultipleSegments() throws Exception {
-    data.writeUtf8(repeat('a', Segment.SIZE - 3));
-    data.write(new byte[] {
+    sink.writeUtf8(repeat('a', Segment.SIZE - 3));
+    sink.write(new byte[] {
         (byte) 0xab, (byte) 0xcd, (byte) 0xef, (byte) 0x01
     });
     source.skip(Segment.SIZE - 3);
     assertEquals(0xabcdef01, source.readInt());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readLong() throws Exception {
-    data.write(new byte[] {
+    sink.write(new byte[] {
         (byte) 0xab, (byte) 0xcd, (byte) 0xef, (byte) 0x10, (byte) 0x87, (byte) 0x65, (byte) 0x43,
         (byte) 0x21, (byte) 0x36, (byte) 0x47, (byte) 0x58, (byte) 0x69, (byte) 0x12, (byte) 0x23,
         (byte) 0x34, (byte) 0x45
     });
     assertEquals(0xabcdef1087654321L, source.readLong());
     assertEquals(0x3647586912233445L, source.readLong());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readLongLe() throws Exception {
-    data.write(new byte[]{
+    sink.write(new byte[] {
         (byte) 0xab, (byte) 0xcd, (byte) 0xef, (byte) 0x10, (byte) 0x87, (byte) 0x65, (byte) 0x43,
         (byte) 0x21, (byte) 0x36, (byte) 0x47, (byte) 0x58, (byte) 0x69, (byte) 0x12, (byte) 0x23,
         (byte) 0x34, (byte) 0x45
     });
     assertEquals(0x2143658710efcdabL, source.readLongLe());
     assertEquals(0x4534231269584736L, source.readLongLe());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readLongSplitAcrossMultipleSegments() throws Exception {
-    data.writeUtf8(repeat('a', Segment.SIZE - 7));
-    data.write(new byte[] {
+    sink.writeUtf8(repeat('a', Segment.SIZE - 7));
+    sink.write(new byte[] {
         (byte) 0xab, (byte) 0xcd, (byte) 0xef, (byte) 0x01, (byte) 0x87, (byte) 0x65, (byte) 0x43,
         (byte) 0x21,
     });
     source.skip(Segment.SIZE - 7);
     assertEquals(0xabcdef0187654321L, source.readLong());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readAll() throws IOException {
     source.buffer().writeUtf8("abc");
-    data.writeUtf8("def");
+    sink.writeUtf8("def");
 
     Buffer sink = new Buffer();
     assertEquals(6, source.readAll(sink));
     assertEquals("abcdef", sink.readUtf8());
-    assertTrue(data.exhausted());
     assertTrue(source.exhausted());
   }
 
   @Test public void readAllExhausted() throws IOException {
     MockSink mockSink = new MockSink();
     assertEquals(0, source.readAll(mockSink));
-    assertTrue(data.exhausted());
     assertTrue(source.exhausted());
     mockSink.assertLog();
   }
@@ -200,7 +233,7 @@
     sink.writeUtf8(repeat('a', 10));
     assertEquals(-1, source.read(sink, 10));
     assertEquals(10, sink.size());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readZeroBytesFromSource() throws Exception {
@@ -211,11 +244,11 @@
     // ByteArrayInputStream we return 0.
     assertEquals(-1, source.read(sink, 0));
     assertEquals(10, sink.size());
-    assertEquals(0, data.size());
+    assertTrue(source.exhausted());
   }
 
   @Test public void readFully() throws Exception {
-    data.writeUtf8(repeat('a', 10000));
+    sink.writeUtf8(repeat('a', 10000));
     Buffer sink = new Buffer();
     source.readFully(sink, 9999);
     assertEquals(repeat('a', 9999), sink.readUtf8());
@@ -223,7 +256,7 @@
   }
 
   @Test public void readFullyTooShortThrows() throws IOException {
-    data.writeUtf8("Hi");
+    sink.writeUtf8("Hi");
     Buffer sink = new Buffer();
     try {
       source.readFully(sink, 5);
@@ -236,8 +269,11 @@
   }
 
   @Test public void readFullyByteArray() throws IOException {
+    Buffer data = new Buffer();
     data.writeUtf8("Hello").writeUtf8(repeat('e', Segment.SIZE));
+
     byte[] expected = data.clone().readByteArray();
+    sink.write(data, data.size());
 
     byte[] sink = new byte[Segment.SIZE + 5];
     source.readFully(sink);
@@ -245,7 +281,7 @@
   }
 
   @Test public void readFullyByteArrayTooShortThrows() throws IOException {
-    data.writeUtf8("Hello");
+    sink.writeUtf8("Hello");
 
     byte[] sink = new byte[6];
     try {
@@ -259,95 +295,115 @@
   }
 
   @Test public void readIntoByteArray() throws IOException {
-    data.writeUtf8("abcd");
+    sink.writeUtf8("abcd");
 
     byte[] sink = new byte[3];
     int read = source.read(sink);
-    assertEquals(3, read);
-    byte[] expected = { 'a', 'b', 'c' };
-    assertByteArraysEquals(expected, sink);
+    if (factory == ONE_BYTE_AT_A_TIME_FACTORY) {
+      assertEquals(1, read);
+      byte[] expected = { 'a', 0, 0 };
+      assertByteArraysEquals(expected, sink);
+    } else {
+      assertEquals(3, read);
+      byte[] expected = { 'a', 'b', 'c' };
+      assertByteArraysEquals(expected, sink);
+    }
   }
 
   @Test public void readIntoByteArrayNotEnough() throws IOException {
-    data.writeUtf8("abcd");
+    sink.writeUtf8("abcd");
 
     byte[] sink = new byte[5];
     int read = source.read(sink);
-    assertEquals(4, read);
-    byte[] expected = { 'a', 'b', 'c', 'd', 0 };
-    assertByteArraysEquals(expected, sink);
+    if (factory == ONE_BYTE_AT_A_TIME_FACTORY) {
+      assertEquals(1, read);
+      byte[] expected = { 'a', 0, 0, 0, 0 };
+      assertByteArraysEquals(expected, sink);
+    } else {
+      assertEquals(4, read);
+      byte[] expected = { 'a', 'b', 'c', 'd', 0 };
+      assertByteArraysEquals(expected, sink);
+    }
   }
 
   @Test public void readIntoByteArrayOffsetAndCount() throws IOException {
-    data.writeUtf8("abcd");
+    sink.writeUtf8("abcd");
 
     byte[] sink = new byte[7];
     int read = source.read(sink, 2, 3);
-    assertEquals(3, read);
-    byte[] expected = { 0, 0, 'a', 'b', 'c', 0, 0 };
-    assertByteArraysEquals(expected, sink);
+    if (factory == ONE_BYTE_AT_A_TIME_FACTORY) {
+      assertEquals(1, read);
+      byte[] expected = { 0, 0, 'a', 0, 0, 0, 0 };
+      assertByteArraysEquals(expected, sink);
+    } else {
+      assertEquals(3, read);
+      byte[] expected = { 0, 0, 'a', 'b', 'c', 0, 0 };
+      assertByteArraysEquals(expected, sink);
+    }
   }
 
   @Test public void readByteArray() throws IOException {
     String string = "abcd" + repeat('e', Segment.SIZE);
-    data.writeUtf8(string);
+    sink.writeUtf8(string);
     assertByteArraysEquals(string.getBytes(UTF_8), source.readByteArray());
   }
 
   @Test public void readByteArrayPartial() throws IOException {
-    data.writeUtf8("abcd");
+    sink.writeUtf8("abcd");
     assertEquals("[97, 98, 99]", Arrays.toString(source.readByteArray(3)));
     assertEquals("d", source.readUtf8(1));
   }
 
   @Test public void readByteString() throws IOException {
-    data.writeUtf8("abcd").writeUtf8(repeat('e', Segment.SIZE));
+    sink.writeUtf8("abcd").writeUtf8(repeat('e', Segment.SIZE));
     assertEquals("abcd" + repeat('e', Segment.SIZE), source.readByteString().utf8());
   }
 
   @Test public void readByteStringPartial() throws IOException {
-    data.writeUtf8("abcd").writeUtf8(repeat('e', Segment.SIZE));
+    sink.writeUtf8("abcd").writeUtf8(repeat('e', Segment.SIZE));
     assertEquals("abc", source.readByteString(3).utf8());
     assertEquals("d", source.readUtf8(1));
   }
 
   @Test public void readSpecificCharsetPartial() throws Exception {
-    data.write(ByteString.decodeHex("0000007600000259000002c80000006c000000e40000007300000259"
-        + "000002cc000000720000006100000070000000740000025900000072"));
+    sink.write(
+        ByteString.decodeHex("0000007600000259000002c80000006c000000e40000007300000259"
+            + "000002cc000000720000006100000070000000740000025900000072"));
     assertEquals("vəˈläsə", source.readString(7 * 4, Charset.forName("utf-32")));
   }
 
   @Test public void readSpecificCharset() throws Exception {
-    data.write(ByteString.decodeHex("0000007600000259000002c80000006c000000e40000007300000259"
-        + "000002cc000000720000006100000070000000740000025900000072"));
+    sink.write(
+        ByteString.decodeHex("0000007600000259000002c80000006c000000e40000007300000259"
+            + "000002cc000000720000006100000070000000740000025900000072"));
     assertEquals("vəˈläsəˌraptər", source.readString(Charset.forName("utf-32")));
   }
 
   @Test public void readUtf8SpansSegments() throws Exception {
-    data.writeUtf8(repeat('a', Segment.SIZE * 2));
+    sink.writeUtf8(repeat('a', Segment.SIZE * 2));
     source.skip(Segment.SIZE - 1);
     assertEquals("aa", source.readUtf8(2));
   }
 
   @Test public void readUtf8Segment() throws Exception {
-    data.writeUtf8(repeat('a', Segment.SIZE));
+    sink.writeUtf8(repeat('a', Segment.SIZE));
     assertEquals(repeat('a', Segment.SIZE), source.readUtf8(Segment.SIZE));
   }
 
   @Test public void readUtf8PartialBuffer() throws Exception {
-    data.writeUtf8(repeat('a', Segment.SIZE + 20));
+    sink.writeUtf8(repeat('a', Segment.SIZE + 20));
     assertEquals(repeat('a', Segment.SIZE + 10), source.readUtf8(Segment.SIZE + 10));
   }
 
   @Test public void readUtf8EntireBuffer() throws Exception {
-    data.writeUtf8(repeat('a', Segment.SIZE * 2));
+    sink.writeUtf8(repeat('a', Segment.SIZE * 2));
     assertEquals(repeat('a', Segment.SIZE * 2), source.readUtf8());
   }
 
   @Test public void skip() throws Exception {
-    data.writeUtf8("a");
-    data.writeUtf8(repeat('b', Segment.SIZE));
-    data.writeUtf8("c");
+    sink.writeUtf8("a");
+    sink.writeUtf8(repeat('b', Segment.SIZE));
+    sink.writeUtf8("c");
     source.skip(1);
     assertEquals('b', source.readByte() & 0xff);
     source.skip(Segment.SIZE - 2);
@@ -357,7 +413,7 @@
   }
 
   @Test public void skipInsufficientData() throws Exception {
-    data.writeUtf8("a");
+    sink.writeUtf8("a");
 
     try {
       source.skip(2);
@@ -371,12 +427,12 @@
     assertEquals(-1, source.indexOf((byte) 'a'));
 
     // The segment has one value.
-    data.writeUtf8("a"); // a
+    sink.writeUtf8("a"); // a
     assertEquals(0, source.indexOf((byte) 'a'));
     assertEquals(-1, source.indexOf((byte) 'b'));
 
     // The segment has lots of data.
-    data.writeUtf8(repeat('b', Segment.SIZE - 2)); // ab...b
+    sink.writeUtf8(repeat('b', Segment.SIZE - 2)); // ab...b
     assertEquals(0, source.indexOf((byte) 'a'));
     assertEquals(1, source.indexOf((byte) 'b'));
     assertEquals(-1, source.indexOf((byte) 'c'));
@@ -388,7 +444,7 @@
     assertEquals(-1, source.indexOf((byte) 'c'));
 
     // The segment is full.
-    data.writeUtf8("c"); // b...bc
+    sink.writeUtf8("c"); // b...bc
     assertEquals(-1, source.indexOf((byte) 'a'));
     assertEquals(0, source.indexOf((byte) 'b'));
     assertEquals(Segment.SIZE - 3, source.indexOf((byte) 'c'));
@@ -400,19 +456,19 @@
     assertEquals(Segment.SIZE - 5, source.indexOf((byte) 'c'));
 
     // Two segments.
-    data.writeUtf8("d"); // b...bcd, d is in the 2nd segment.
+    sink.writeUtf8("d"); // b...bcd, d is in the 2nd segment.
     assertEquals(Segment.SIZE - 4, source.indexOf((byte) 'd'));
     assertEquals(-1, source.indexOf((byte) 'e'));
   }
 
   @Test public void indexOfWithOffset() throws IOException {
-    data.writeUtf8("a").writeUtf8(repeat('b', Segment.SIZE)).writeUtf8("c");
+    sink.writeUtf8("a").writeUtf8(repeat('b', Segment.SIZE)).writeUtf8("c");
     assertEquals(-1, source.indexOf((byte) 'a', 1));
     assertEquals(15, source.indexOf((byte) 'b', 15));
   }
 
   @Test public void indexOfElement() throws IOException {
-    data.writeUtf8("a").writeUtf8(repeat('b', Segment.SIZE)).writeUtf8("c");
+    sink.writeUtf8("a").writeUtf8(repeat('b', Segment.SIZE)).writeUtf8("c");
     assertEquals(0, source.indexOfElement(ByteString.encodeUtf8("DEFGaHIJK")));
     assertEquals(1, source.indexOfElement(ByteString.encodeUtf8("DEFGHIJKb")));
     assertEquals(Segment.SIZE + 1, source.indexOfElement(ByteString.encodeUtf8("cDEFGHIJK")));
@@ -422,19 +478,19 @@
   }
 
   @Test public void indexOfElementWithOffset() throws IOException {
-    data.writeUtf8("a").writeUtf8(repeat('b', Segment.SIZE)).writeUtf8("c");
+    sink.writeUtf8("a").writeUtf8(repeat('b', Segment.SIZE)).writeUtf8("c");
     assertEquals(-1, source.indexOfElement(ByteString.encodeUtf8("DEFGaHIJK"), 1));
     assertEquals(15, source.indexOfElement(ByteString.encodeUtf8("DEFGHIJKb"), 15));
   }
 
   @Test public void request() throws IOException {
-    data.writeUtf8("a").writeUtf8(repeat('b', Segment.SIZE)).writeUtf8("c");
+    sink.writeUtf8("a").writeUtf8(repeat('b', Segment.SIZE)).writeUtf8("c");
     assertTrue(source.request(Segment.SIZE + 2));
     assertFalse(source.request(Segment.SIZE + 3));
   }
 
   @Test public void require() throws IOException {
-    data.writeUtf8("a").writeUtf8(repeat('b', Segment.SIZE)).writeUtf8("c");
+    sink.writeUtf8("a").writeUtf8(repeat('b', Segment.SIZE)).writeUtf8("c");
     source.require(Segment.SIZE + 2);
     try {
       source.require(Segment.SIZE + 3);
@@ -444,33 +500,52 @@
   }
 
   @Test public void inputStream() throws Exception {
-    data.writeUtf8("abc");
+    sink.writeUtf8("abc");
     InputStream in = source.inputStream();
-    byte[] bytes = new byte[3];
+    byte[] bytes = { 'z', 'z', 'z' };
     int read = in.read(bytes);
-    assertEquals(3, read);
-    assertByteArrayEquals("abc", bytes);
+    if (factory == ONE_BYTE_AT_A_TIME_FACTORY) {
+      assertEquals(1, read);
+      assertByteArrayEquals("azz", bytes);
+
+      read = in.read(bytes);
+      assertEquals(1, read);
+      assertByteArrayEquals("bzz", bytes);
+
+      read = in.read(bytes);
+      assertEquals(1, read);
+      assertByteArrayEquals("czz", bytes);
+    } else {
+      assertEquals(3, read);
+      assertByteArrayEquals("abc", bytes);
+    }
+
     assertEquals(-1, in.read());
   }
 
   @Test public void inputStreamOffsetCount() throws Exception {
-    data.writeUtf8("abcde");
+    sink.writeUtf8("abcde");
     InputStream in = source.inputStream();
     byte[] bytes = { 'z', 'z', 'z', 'z', 'z' };
     int read = in.read(bytes, 1, 3);
-    assertEquals(3, read);
-    assertByteArrayEquals("zabcz", bytes);
+    if (factory == ONE_BYTE_AT_A_TIME_FACTORY) {
+      assertEquals(1, read);
+      assertByteArrayEquals("zazzz", bytes);
+    } else {
+      assertEquals(3, read);
+      assertByteArrayEquals("zabcz", bytes);
+    }
   }
 
   @Test public void inputStreamSkip() throws Exception {
-    data.writeUtf8("abcde");
+    sink.writeUtf8("abcde");
     InputStream in = source.inputStream();
     assertEquals(4, in.skip(4));
     assertEquals('e', in.read());
   }
 
   @Test public void inputStreamCharByChar() throws Exception {
-    data.writeUtf8("abc");
+    sink.writeUtf8("abc");
     InputStream in = source.inputStream();
     assertEquals('a', in.read());
     assertEquals('b', in.read());
@@ -479,7 +554,7 @@
   }
 
   @Test public void inputStreamBounds() throws IOException {
-    data.writeUtf8(repeat('a', 100));
+    sink.writeUtf8(repeat('a', 100));
     InputStream in = source.inputStream();
     try {
       in.read(new byte[100], 50, 51);
@@ -510,20 +585,20 @@
   }
 
   private void assertLongHexString(String s, long expected) throws IOException {
-    data.writeUtf8(s);
+    sink.writeUtf8(s);
     long actual = source.readHexadecimalUnsignedLong();
     assertEquals(s + " --> " + expected, expected, actual);
   }
 
   @Test public void longHexStringAcrossSegment() throws IOException {
-    data.writeUtf8(repeat('a', Segment.SIZE - 8)).writeUtf8("FFFFFFFFFFFFFFFF");
+    sink.writeUtf8(repeat('a', Segment.SIZE - 8)).writeUtf8("FFFFFFFFFFFFFFFF");
     source.skip(Segment.SIZE - 8);
     assertEquals(-1, source.readHexadecimalUnsignedLong());
   }
 
   @Test public void longHexStringTooLongThrows() throws IOException {
     try {
-      data.writeUtf8("fffffffffffffffff");
+      sink.writeUtf8("fffffffffffffffff");
       source.readHexadecimalUnsignedLong();
       fail();
     } catch (NumberFormatException e) {
@@ -533,7 +608,7 @@
 
   @Test public void longHexStringTooShortThrows() throws IOException {
     try {
-      data.writeUtf8(" ");
+      sink.writeUtf8(" ");
       source.readHexadecimalUnsignedLong();
       fail();
     } catch (NumberFormatException e) {
@@ -541,6 +616,15 @@
     }
   }
 
+  @Test public void longHexEmptySourceThrows() throws IOException {
+    try {
+      sink.writeUtf8("");
+      source.readHexadecimalUnsignedLong();
+      fail();
+    } catch (IllegalStateException | EOFException expected) {
+    }
+  }
+
   @Test public void longDecimalString() throws IOException {
     assertLongDecimalString("-9223372036854775808", -9223372036854775808L);
     assertLongDecimalString("-1", -1L);
@@ -553,16 +637,16 @@
   }
 
   private void assertLongDecimalString(String s, long expected) throws IOException {
-    data.writeUtf8(s);
-    data.writeUtf8("zzz");
+    sink.writeUtf8(s);
+    sink.writeUtf8("zzz");
     long actual = source.readDecimalLong();
     assertEquals(s + " --> " + expected, expected, actual);
     assertEquals("zzz", source.readUtf8());
   }
 
   @Test public void longDecimalStringAcrossSegment() throws IOException {
-    data.writeUtf8(repeat('a', Segment.SIZE - 8)).writeUtf8("1234567890123456");
-    data.writeUtf8("zzz");
+    sink.writeUtf8(repeat('a', Segment.SIZE - 8)).writeUtf8("1234567890123456");
+    sink.writeUtf8("zzz");
     source.skip(Segment.SIZE - 8);
     assertEquals(1234567890123456L, source.readDecimalLong());
     assertEquals("zzz", source.readUtf8());
@@ -570,7 +654,7 @@
 
   @Test public void longDecimalStringTooLongThrows() throws IOException {
     try {
-      data.writeUtf8("12345678901234567890"); // Too many digits.
+      sink.writeUtf8("12345678901234567890"); // Too many digits.
       source.readDecimalLong();
       fail();
     } catch (NumberFormatException e) {
@@ -580,7 +664,7 @@
 
   @Test public void longDecimalStringTooHighThrows() throws IOException {
     try {
-      data.writeUtf8("9223372036854775808"); // Right size but cannot fit.
+      sink.writeUtf8("9223372036854775808"); // Right size but cannot fit.
       source.readDecimalLong();
       fail();
     } catch (NumberFormatException e) {
@@ -590,7 +674,7 @@
 
   @Test public void longDecimalStringTooLowThrows() throws IOException {
     try {
-      data.writeUtf8("-9223372036854775809"); // Right size but cannot fit.
+      sink.writeUtf8("-9223372036854775809"); // Right size but cannot fit.
       source.readDecimalLong();
       fail();
     } catch (NumberFormatException e) {
@@ -600,7 +684,7 @@
 
   @Test public void longDecimalStringTooShortThrows() throws IOException {
     try {
-      data.writeUtf8(" ");
+      sink.writeUtf8(" ");
       source.readDecimalLong();
       fail();
     } catch (NumberFormatException e) {
@@ -608,6 +692,29 @@
     }
   }
 
+  @Test public void longDecimalEmptyThrows() throws IOException {
+    try {
+      sink.writeUtf8("");
+      source.readDecimalLong();
+      fail();
+    } catch (IllegalStateException | EOFException expected) {
+    }
+  }
+
+  @Test public void codePoints() throws IOException {
+    sink.write(ByteString.decodeHex("7f"));
+    assertEquals(0x7f, source.readUtf8CodePoint());
+
+    sink.write(ByteString.decodeHex("dfbf"));
+    assertEquals(0x07ff, source.readUtf8CodePoint());
+
+    sink.write(ByteString.decodeHex("efbfbf"));
+    assertEquals(0xffff, source.readUtf8CodePoint());
+
+    sink.write(ByteString.decodeHex("f48fbfbf"));
+    assertEquals(0x10ffff, source.readUtf8CodePoint());
+  }
+
   @Test public void decimalStringWithManyLeadingZeros() throws IOException {
     assertLongDecimalString("00000000000000001", 1);
     assertLongDecimalString("00000000000000009223372036854775807", 9223372036854775807L);
diff --git a/okio/okio/src/test/java/okio/Utf8Test.java b/okio/okio/src/test/java/okio/Utf8Test.java
index 5456607..f8458d2 100644
--- a/okio/okio/src/test/java/okio/Utf8Test.java
+++ b/okio/okio/src/test/java/okio/Utf8Test.java
@@ -15,9 +15,13 @@
  */
 package okio;
 
+import java.io.EOFException;
 import org.junit.Test;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 public final class Utf8Test {
   @Test public void oneByteCharacters() throws Exception {
@@ -53,16 +57,16 @@
   }
 
   @Test public void danglingHighSurrogate() throws Exception {
-    assertEncoded("3f", "\ud800"); // "?"
+    assertStringEncoded("3f", "\ud800"); // "?"
   }
 
   @Test public void lowSurrogateWithoutHighSurrogate() throws Exception {
-    assertEncoded("3f", "\udc00"); // "?"
+    assertStringEncoded("3f", "\udc00"); // "?"
   }
 
   @Test public void highSurrogateFollowedByNonSurrogate() throws Exception {
-    assertEncoded("3f61", "\ud800\u0061"); // "?a": Following character is too low.
-    assertEncoded("3fee8080", "\ud800\ue000"); // "?\ue000": Following character is too high.
+    assertStringEncoded("3f61", "\ud800\u0061"); // "?a": Following character is too low.
+    assertStringEncoded("3fee8080", "\ud800\ue000"); // "?\ue000": Following character is too high.
   }
 
   @Test public void multipleSegmentString() throws Exception {
@@ -83,11 +87,128 @@
     assertEquals(a + b + c, buffer.readUtf8());
   }
 
-  private void assertEncoded(String hex, int... codePoints) throws Exception {
-    assertEncoded(hex, new String(codePoints, 0, codePoints.length));
+  @Test public void readEmptyBufferThrowsEofException() throws Exception {
+    Buffer buffer = new Buffer();
+    try {
+      buffer.readUtf8CodePoint();
+      fail();
+    } catch (EOFException expected) {
+    }
   }
 
-  private void assertEncoded(String hex, String string) throws Exception {
+  @Test public void readLeadingContinuationByteReturnsReplacementCharacter() throws Exception {
+    Buffer buffer = new Buffer();
+    buffer.writeByte(0xbf);
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertTrue(buffer.exhausted());
+  }
+
+  @Test public void readMissingContinuationBytesThrowsEofException() throws Exception {
+    Buffer buffer = new Buffer();
+    buffer.writeByte(0xdf);
+    try {
+      buffer.readUtf8CodePoint();
+      fail();
+    } catch (EOFException expected) {
+    }
+    assertFalse(buffer.exhausted()); // Prefix byte wasn't consumed.
+  }
+
+  @Test public void readTooLargeCodepointReturnsReplacementCharacter() throws Exception {
+    // 5-byte and 6-byte code points are not supported.
+    Buffer buffer = new Buffer();
+    buffer.write(ByteString.decodeHex("f888808080"));
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertTrue(buffer.exhausted());
+  }
+
+  @Test public void readNonContinuationBytesReturnsReplacementCharacter() throws Exception {
+    // Use a non-continuation byte where a continuation byte is expected.
+    Buffer buffer = new Buffer();
+    buffer.write(ByteString.decodeHex("df20"));
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertEquals(0x20, buffer.readUtf8CodePoint()); // Non-continuation character not consumed.
+    assertTrue(buffer.exhausted());
+  }
+
+  @Test public void readCodePointBeyondUnicodeMaximum() throws Exception {
+    // A 4-byte encoding with data above the U+10ffff Unicode maximum.
+    Buffer buffer = new Buffer();
+    buffer.write(ByteString.decodeHex("f4908080"));
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertTrue(buffer.exhausted());
+  }
+
+  @Test public void readSurrogateCodePoint() throws Exception {
+    Buffer buffer = new Buffer();
+    buffer.write(ByteString.decodeHex("eda080"));
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertTrue(buffer.exhausted());
+    buffer.write(ByteString.decodeHex("edbfbf"));
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertTrue(buffer.exhausted());
+  }
+
+  @Test public void readOverlongCodePoint() throws Exception {
+    // Use 2 bytes to encode data that only needs 1 byte.
+    Buffer buffer = new Buffer();
+    buffer.write(ByteString.decodeHex("c080"));
+    assertEquals(Buffer.REPLACEMENT_CHARACTER, buffer.readUtf8CodePoint());
+    assertTrue(buffer.exhausted());
+  }
+
+  @Test public void writeSurrogateCodePoint() throws Exception {
+    Buffer buffer = new Buffer();
+    buffer.writeUtf8CodePoint(0xd7ff); // Below lowest surrogate is okay.
+    try {
+      buffer.writeUtf8CodePoint(0xd800); // Lowest surrogate throws.
+      fail();
+    } catch (IllegalArgumentException expected) {
+    }
+    try {
+      buffer.writeUtf8CodePoint(0xdfff); // Highest surrogate throws.
+      fail();
+    } catch (IllegalArgumentException expected) {
+    }
+    buffer.writeUtf8CodePoint(0xe000); // Above highest surrogate is okay.
+  }
+
+  @Test public void writeCodePointBeyondUnicodeMaximum() throws Exception {
+    Buffer buffer = new Buffer();
+    try {
+      buffer.writeUtf8CodePoint(0x110000);
+      fail();
+    } catch (IllegalArgumentException expected) {
+    }
+  }
+
+  private void assertEncoded(String hex, int... codePoints) throws Exception {
+    assertCodePointEncoded(hex, codePoints);
+    assertCodePointDecoded(hex, codePoints);
+    assertStringEncoded(hex, new String(codePoints, 0, codePoints.length));
+  }
+
+  private void assertCodePointEncoded(String hex, int... codePoints) throws Exception {
+    Buffer buffer = new Buffer();
+    for (int codePoint : codePoints) {
+      buffer.writeUtf8CodePoint(codePoint);
+    }
+    assertEquals(buffer.readByteString(), ByteString.decodeHex(hex));
+  }
+
+  private void assertCodePointDecoded(String hex, int... codePoints) throws Exception {
+    Buffer buffer = new Buffer().write(ByteString.decodeHex(hex));
+    for (int codePoint : codePoints) {
+      assertEquals(codePoint, buffer.readUtf8CodePoint());
+    }
+    assertTrue(buffer.exhausted());
+  }
+
+  private void assertStringEncoded(String hex, String string) throws Exception {
     ByteString expectedUtf8 = ByteString.decodeHex(hex);
 
     // Confirm our expectations are consistent with the platform.
diff --git a/pom.xml b/pom.xml
index 60c4f01..3c1435a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -25,6 +25,7 @@
     <module>okhttp-android-support</module>
 
     <module>okhttp-apache</module>
+    <module>okhttp-testing-support</module>
     <module>okhttp-urlconnection</module>
 
     <module>okhttp-ws</module>
@@ -41,7 +42,7 @@
 
     <!-- Compilation -->
     <java.version>1.7</java.version>
-    <okio.version>1.3.0</okio.version>
+    <okio.version>1.4.0-SNAPSHOT</okio.version>
     <!-- ALPN library targeted to Java 7 -->
     <alpn.jdk7.version>7.1.2.v20141202</alpn.jdk7.version>
     <!-- ALPN library targeted to Java 8 update 25. -->
@@ -132,6 +133,19 @@
           <groupId>org.apache.maven.plugins</groupId>
           <artifactId>maven-surefire-plugin</artifactId>
           <version>2.17</version>
+          <configuration>
+            <properties>
+              <!--
+                Configure a listener for enforcing that no uncaught exceptions issue from OkHttp
+                tests. Every test must have a <scope>test</scope> dependency on
+                okhttp-testing-support.
+                -->
+              <property>
+                <name>listener</name>
+                <value>com.squareup.okhttp.testing.InstallUncaughtExceptionHandlerListener</value>
+              </property>
+            </properties>
+          </configuration>
           <dependencies>
             <dependency>
               <groupId>org.apache.maven.surefire</groupId>