blob: 2ab564dcf64dd2c9f5f514b9d2bddea00e42b128 [file] [log] [blame]
/*
* Copyright (C) 2011 The Android Open Source Project
*
* 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.ResponseSource;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
import static com.squareup.okhttp.internal.Util.equal;
/** Parsed HTTP response headers. */
public final class ResponseHeaders {
/** HTTP header name for the local time when the request was sent. */
private static final String SENT_MILLIS = "X-Android-Sent-Millis";
/** HTTP header name for the local time when the response was received. */
private static final String RECEIVED_MILLIS = "X-Android-Received-Millis";
/** HTTP synthetic header with the response source. */
static final String RESPONSE_SOURCE = "X-Android-Response-Source";
private final URI uri;
private final RawHeaders headers;
/** The server's time when this response was served, if known. */
private Date servedDate;
/** The last modified date of the response, if known. */
private Date lastModified;
/**
* The expiration date of the response, if known. If both this field and the
* max age are set, the max age is preferred.
*/
private Date expires;
/**
* Extension header set by HttpURLConnectionImpl specifying the timestamp
* when the HTTP request was first initiated.
*/
private long sentRequestMillis;
/**
* Extension header set by HttpURLConnectionImpl specifying the timestamp
* when the HTTP response was first received.
*/
private long receivedResponseMillis;
/**
* In the response, this field's name "no-cache" is misleading. It doesn't
* prevent us from caching the response; it only means we have to validate
* the response with the origin server before returning it. We can do this
* with a conditional get.
*/
private boolean noCache;
/** If true, this response should not be cached. */
private boolean noStore;
/**
* The duration past the response's served date that it can be served
* without validation.
*/
private int maxAgeSeconds = -1;
/**
* The "s-maxage" directive is the max age for shared caches. Not to be
* confused with "max-age" for non-shared caches, As in Firefox and Chrome,
* this directive is not honored by this cache.
*/
private int sMaxAgeSeconds = -1;
/**
* This request header field's name "only-if-cached" is misleading. It
* actually means "do not use the network". It is set by a client who only
* wants to make a request if it can be fully satisfied by the cache.
* Cached responses that would require validation (ie. conditional gets) are
* not permitted if this header is set.
*/
private boolean isPublic;
private boolean mustRevalidate;
private String etag;
private int ageSeconds = -1;
/** Case-insensitive set of field names. */
private Set<String> varyFields = Collections.emptySet();
private String contentEncoding;
private String transferEncoding;
private int contentLength = -1;
private String connection;
public ResponseHeaders(URI uri, RawHeaders headers) {
this.uri = uri;
this.headers = headers;
HeaderParser.CacheControlHandler handler = new HeaderParser.CacheControlHandler() {
@Override public void handle(String directive, String parameter) {
if ("no-cache".equalsIgnoreCase(directive)) {
noCache = true;
} else if ("no-store".equalsIgnoreCase(directive)) {
noStore = true;
} else if ("max-age".equalsIgnoreCase(directive)) {
maxAgeSeconds = HeaderParser.parseSeconds(parameter);
} else if ("s-maxage".equalsIgnoreCase(directive)) {
sMaxAgeSeconds = HeaderParser.parseSeconds(parameter);
} else if ("public".equalsIgnoreCase(directive)) {
isPublic = true;
} else if ("must-revalidate".equalsIgnoreCase(directive)) {
mustRevalidate = true;
}
}
};
for (int i = 0; i < headers.length(); i++) {
String fieldName = headers.getFieldName(i);
String value = headers.getValue(i);
if ("Cache-Control".equalsIgnoreCase(fieldName)) {
HeaderParser.parseCacheControl(value, handler);
} else if ("Date".equalsIgnoreCase(fieldName)) {
servedDate = HttpDate.parse(value);
} else if ("Expires".equalsIgnoreCase(fieldName)) {
expires = HttpDate.parse(value);
} else if ("Last-Modified".equalsIgnoreCase(fieldName)) {
lastModified = HttpDate.parse(value);
} else if ("ETag".equalsIgnoreCase(fieldName)) {
etag = value;
} else if ("Pragma".equalsIgnoreCase(fieldName)) {
if ("no-cache".equalsIgnoreCase(value)) {
noCache = true;
}
} else if ("Age".equalsIgnoreCase(fieldName)) {
ageSeconds = HeaderParser.parseSeconds(value);
} else if ("Vary".equalsIgnoreCase(fieldName)) {
// Replace the immutable empty set with something we can mutate.
if (varyFields.isEmpty()) {
varyFields = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
}
for (String varyField : value.split(",")) {
varyFields.add(varyField.trim());
}
} else if ("Content-Encoding".equalsIgnoreCase(fieldName)) {
contentEncoding = value;
} else if ("Transfer-Encoding".equalsIgnoreCase(fieldName)) {
transferEncoding = value;
} else if ("Content-Length".equalsIgnoreCase(fieldName)) {
try {
contentLength = Integer.parseInt(value);
} catch (NumberFormatException ignored) {
}
} else if ("Connection".equalsIgnoreCase(fieldName)) {
connection = value;
} else if (SENT_MILLIS.equalsIgnoreCase(fieldName)) {
sentRequestMillis = Long.parseLong(value);
} else if (RECEIVED_MILLIS.equalsIgnoreCase(fieldName)) {
receivedResponseMillis = Long.parseLong(value);
}
}
}
public boolean isContentEncodingGzip() {
return "gzip".equalsIgnoreCase(contentEncoding);
}
public void stripContentEncoding() {
contentEncoding = null;
headers.removeAll("Content-Encoding");
}
public void stripContentLength() {
contentLength = -1;
headers.removeAll("Content-Length");
}
public boolean isChunked() {
return "chunked".equalsIgnoreCase(transferEncoding);
}
public boolean hasConnectionClose() {
return "close".equalsIgnoreCase(connection);
}
public URI getUri() {
return uri;
}
public RawHeaders getHeaders() {
return headers;
}
public Date getServedDate() {
return servedDate;
}
public Date getLastModified() {
return lastModified;
}
public Date getExpires() {
return expires;
}
public boolean isNoCache() {
return noCache;
}
public boolean isNoStore() {
return noStore;
}
public int getMaxAgeSeconds() {
return maxAgeSeconds;
}
public int getSMaxAgeSeconds() {
return sMaxAgeSeconds;
}
public boolean isPublic() {
return isPublic;
}
public boolean isMustRevalidate() {
return mustRevalidate;
}
public String getEtag() {
return etag;
}
public Set<String> getVaryFields() {
return varyFields;
}
public String getContentEncoding() {
return contentEncoding;
}
public int getContentLength() {
return contentLength;
}
public String getConnection() {
return connection;
}
public void setLocalTimestamps(long sentRequestMillis, long receivedResponseMillis) {
this.sentRequestMillis = sentRequestMillis;
headers.add(SENT_MILLIS, Long.toString(sentRequestMillis));
this.receivedResponseMillis = receivedResponseMillis;
headers.add(RECEIVED_MILLIS, Long.toString(receivedResponseMillis));
}
public void setResponseSource(ResponseSource responseSource) {
headers.set(RESPONSE_SOURCE, responseSource.toString() + " " + headers.getResponseCode());
}
/**
* Returns the current age of the response, in milliseconds. The calculation
* is specified by RFC 2616, 13.2.3 Age Calculations.
*/
private long computeAge(long nowMillis) {
long apparentReceivedAge =
servedDate != null ? Math.max(0, receivedResponseMillis - servedDate.getTime()) : 0;
long receivedAge =
ageSeconds != -1 ? Math.max(apparentReceivedAge, TimeUnit.SECONDS.toMillis(ageSeconds))
: apparentReceivedAge;
long responseDuration = receivedResponseMillis - sentRequestMillis;
long residentDuration = nowMillis - receivedResponseMillis;
return receivedAge + responseDuration + residentDuration;
}
/**
* Returns the number of milliseconds that the response was fresh for,
* starting from the served date.
*/
private long computeFreshnessLifetime() {
if (maxAgeSeconds != -1) {
return TimeUnit.SECONDS.toMillis(maxAgeSeconds);
} else if (expires != null) {
long servedMillis = servedDate != null ? servedDate.getTime() : receivedResponseMillis;
long delta = expires.getTime() - servedMillis;
return delta > 0 ? delta : 0;
} else if (lastModified != null && uri.getRawQuery() == null) {
// As recommended by the HTTP RFC and implemented in Firefox, the
// max age of a document should be defaulted to 10% of the
// document's age at the time it was served. Default expiration
// dates aren't used for URIs containing a query.
long servedMillis = servedDate != null ? servedDate.getTime() : sentRequestMillis;
long delta = servedMillis - lastModified.getTime();
return delta > 0 ? (delta / 10) : 0;
}
return 0;
}
/**
* Returns true if computeFreshnessLifetime used a heuristic. If we used a
* heuristic to serve a cached response older than 24 hours, we are required
* to attach a warning.
*/
private boolean isFreshnessLifetimeHeuristic() {
return maxAgeSeconds == -1 && expires == null;
}
/**
* Returns true if this response can be stored to later serve another
* request.
*/
public boolean isCacheable(RequestHeaders request) {
// Always go to network for uncacheable response codes (RFC 2616, 13.4),
// This implementation doesn't support caching partial content.
int responseCode = headers.getResponseCode();
if (responseCode != HttpURLConnection.HTTP_OK
&& responseCode != HttpURLConnection.HTTP_NOT_AUTHORITATIVE
&& responseCode != HttpURLConnection.HTTP_MULT_CHOICE
&& responseCode != HttpURLConnection.HTTP_MOVED_PERM
&& responseCode != HttpURLConnection.HTTP_GONE) {
return false;
}
// Responses to authorized requests aren't cacheable unless they include
// a 'public', 'must-revalidate' or 's-maxage' directive.
if (request.hasAuthorization() && !isPublic && !mustRevalidate && sMaxAgeSeconds == -1) {
return false;
}
if (noStore) {
return false;
}
return true;
}
/**
* Returns true if a Vary header contains an asterisk. Such responses cannot
* be cached.
*/
public boolean hasVaryAll() {
return varyFields.contains("*");
}
/**
* Returns true if none of the Vary headers on this response have changed
* between {@code cachedRequest} and {@code newRequest}.
*/
public boolean varyMatches(Map<String, List<String>> cachedRequest,
Map<String, List<String>> newRequest) {
for (String field : varyFields) {
if (!equal(cachedRequest.get(field), newRequest.get(field))) {
return false;
}
}
return true;
}
/** Returns the source to satisfy {@code request} given this cached response. */
public ResponseSource chooseResponseSource(long nowMillis, RequestHeaders request) {
// If this response shouldn't have been stored, it should never be used
// as a response source. This check should be redundant as long as the
// persistence store is well-behaved and the rules are constant.
if (!isCacheable(request)) {
return ResponseSource.NETWORK;
}
if (request.isNoCache() || request.hasConditions()) {
return ResponseSource.NETWORK;
}
long ageMillis = computeAge(nowMillis);
long freshMillis = computeFreshnessLifetime();
if (request.getMaxAgeSeconds() != -1) {
freshMillis = Math.min(freshMillis, TimeUnit.SECONDS.toMillis(request.getMaxAgeSeconds()));
}
long minFreshMillis = 0;
if (request.getMinFreshSeconds() != -1) {
minFreshMillis = TimeUnit.SECONDS.toMillis(request.getMinFreshSeconds());
}
long maxStaleMillis = 0;
if (!mustRevalidate && request.getMaxStaleSeconds() != -1) {
maxStaleMillis = TimeUnit.SECONDS.toMillis(request.getMaxStaleSeconds());
}
if (!noCache && ageMillis + minFreshMillis < freshMillis + maxStaleMillis) {
if (ageMillis + minFreshMillis >= freshMillis) {
headers.add("Warning", "110 HttpURLConnection \"Response is stale\"");
}
long oneDayMillis = 24 * 60 * 60 * 1000L;
if (ageMillis > oneDayMillis && isFreshnessLifetimeHeuristic()) {
headers.add("Warning", "113 HttpURLConnection \"Heuristic expiration\"");
}
return ResponseSource.CACHE;
}
if (lastModified != null) {
request.setIfModifiedSince(lastModified);
} else if (servedDate != null) {
request.setIfModifiedSince(servedDate);
}
if (etag != null) {
request.setIfNoneMatch(etag);
}
return request.hasConditions() ? ResponseSource.CONDITIONAL_CACHE : ResponseSource.NETWORK;
}
/**
* Returns true if this cached response should be used; false if the
* network response should be used.
*/
public boolean validate(ResponseHeaders networkResponse) {
if (networkResponse.headers.getResponseCode() == HttpURLConnection.HTTP_NOT_MODIFIED) {
return true;
}
// The HTTP spec says that if the network's response is older than our
// cached response, we may return the cache's response. Like Chrome (but
// unlike Firefox), this client prefers to return the newer response.
if (lastModified != null
&& networkResponse.lastModified != null
&& networkResponse.lastModified.getTime() < lastModified.getTime()) {
return true;
}
return false;
}
/**
* Combines this cached header with a network header as defined by RFC 2616,
* 13.5.3.
*/
public ResponseHeaders combine(ResponseHeaders network) throws IOException {
RawHeaders result = new RawHeaders();
result.setStatusLine(headers.getStatusLine());
for (int i = 0; i < headers.length(); i++) {
String fieldName = headers.getFieldName(i);
String value = headers.getValue(i);
if ("Warning".equals(fieldName) && value.startsWith("1")) {
continue; // drop 100-level freshness warnings
}
if (!isEndToEnd(fieldName) || network.headers.get(fieldName) == null) {
result.add(fieldName, value);
}
}
for (int i = 0; i < network.headers.length(); i++) {
String fieldName = network.headers.getFieldName(i);
if (isEndToEnd(fieldName)) {
result.add(fieldName, network.headers.getValue(i));
}
}
return new ResponseHeaders(uri, result);
}
/**
* Returns true if {@code fieldName} is an end-to-end HTTP header, as
* defined by RFC 2616, 13.5.1.
*/
private static boolean isEndToEnd(String fieldName) {
return !"Connection".equalsIgnoreCase(fieldName)
&& !"Keep-Alive".equalsIgnoreCase(fieldName)
&& !"Proxy-Authenticate".equalsIgnoreCase(fieldName)
&& !"Proxy-Authorization".equalsIgnoreCase(fieldName)
&& !"TE".equalsIgnoreCase(fieldName)
&& !"Trailers".equalsIgnoreCase(fieldName)
&& !"Transfer-Encoding".equalsIgnoreCase(fieldName)
&& !"Upgrade".equalsIgnoreCase(fieldName);
}
}