Skip to content

Instantly share code, notes, and snippets.

@NightlyNexus
Last active November 30, 2017 05:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save NightlyNexus/2e880c86668815690cba8b61501c9e14 to your computer and use it in GitHub Desktop.
Save NightlyNexus/2e880c86668815690cba8b61501c9e14 to your computer and use it in GitHub Desktop.
A Retrofit 2 Call Adapter Factory for logging. This mostly exists because Interceptors do not know if the Call was only canceled when the request failed (https://github.com/square/okhttp/issues/3039). It is the responsibility of the Logger to check for canceled failed calls. The AnalyticsNetworkLogger is only a sample implementation of the logge…
/*
* Copyright (C) 2016 Eric Cochran
*
* 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.
*/
import android.support.annotation.VisibleForTesting;
import java.io.EOFException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.UnsupportedCharsetException;
import okhttp3.MediaType;
import okhttp3.Request;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.BufferedSource;
import retrofit2.Call;
import retrofit2.Response;
public final class AnalyticsNetworkLogger implements LoggingCallAdapterFactory.Logger {
private final Analytics analytics; // TODO: Analytics.
public AnalyticsNetworkLogger(Analytics analytics) {
this.analytics = analytics;
}
@Override public <T> void onResponse(Call<T> call, Response<T> response) {
if (response.isSuccessful()) {
return;
}
Request request = response.raw().request();
String errorMessage = errorMessage(response.errorBody());
analytics.httpFailure(code, errorMessage, request.url().toString(), request.method());
}
@Override public <T> void onFailure(Call<T> call, Throwable t) {
if (call.isCanceled()) {
return;
}
Request request = call.request();
analytics.networkFailure(t.getMessage(), request.url().toString(), request.method());
}
@VisibleForTesting static String errorMessage(ResponseBody errorBody) {
if (errorBody.contentLength() == 0) {
return "";
}
Charset charset;
MediaType contentType = errorBody.contentType();
if (contentType == null) {
charset = Charset.forName("UTF-8");
} else {
try {
charset = contentType.charset(Charset.forName("UTF-8"));
} catch (UnsupportedCharsetException e) {
// Charset is likely malformed.
return "Unsupported Content-Type: " + contentType;
}
}
BufferedSource source = errorBody.source();
try {
source.request(Long.MAX_VALUE); // Buffer the entire body.
} catch (IOException e) {
return "Error reading error body: " + e.getMessage();
}
Buffer buffer = source.buffer();
if (!isPlaintext(buffer)) {
return "Error body is not plain text.";
}
return buffer.clone().readString(charset);
}
/**
* Returns true if the body in question probably contains human readable text. Uses a small sample
* of code points to detect unicode control characters commonly used in binary file signatures.
*/
private static boolean isPlaintext(Buffer buffer) {
try {
Buffer prefix = new Buffer();
long byteCount = buffer.size() < 64 ? buffer.size() : 64;
buffer.copyTo(prefix, 0, byteCount);
for (int i = 0; i < 16; i++) {
if (prefix.exhausted()) {
break;
}
int codePoint = prefix.readUtf8CodePoint();
if (Character.isISOControl(codePoint) && !Character.isWhitespace(codePoint)) {
return false;
}
}
return true;
} catch (EOFException e) {
return false; // Truncated UTF-8 sequence.
}
}
}
/*
* Copyright (C) 2016 Eric Cochran
*
* 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.
*/
import java.io.IOException;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.Request;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.BufferedSource;
import org.junit.Test;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
public final class AnalyticsNetworkLoggerTest {
@Test public void doesNotConsumeErrorBody() {
MediaType mediaType = MediaType.parse("application/json; charset=UTF-8");
ResponseBody errorBody = ResponseBody.create(mediaType, "This request failed.");
BufferedSource source = errorBody.source();
String errorMessage = AnalyticsNetworkLogger.errorMessage(errorBody);
long size = source.buffer().size();
boolean exhausted;
try {
exhausted = source.exhausted();
} catch (IOException e) {
throw new AssertionError(e);
}
assertEquals("This request failed.", errorMessage);
assertEquals(20, size);
assertFalse(exhausted);
}
@Test public void logsHttpFailure() {
// TODO: Analytics.
}
@Test public void logsNetworkFailure() {
// TODO: Analytics.
}
@Test public void doesNotLogSuccess() {
// TODO: Analytics.
}
@Test public void doesNotLogCanceled() {
// TODO: Analytics.
}
private static final class EmptyResponseBody extends ResponseBody {
private final Buffer source = new Buffer();
EmptyResponseBody() {
}
@Override public MediaType contentType() {
return null;
}
@Override public long contentLength() {
return 0;
}
@Override public BufferedSource source() {
return source;
}
}
private static class BaseCall<T> implements Call<T> {
BaseCall() {
}
@Override public Response<T> execute() throws IOException {
throw new UnsupportedOperationException();
}
@Override public void enqueue(Callback<T> callback) {
throw new UnsupportedOperationException();
}
@Override public boolean isExecuted() {
throw new UnsupportedOperationException();
}
@Override public void cancel() {
throw new UnsupportedOperationException();
}
@Override public boolean isCanceled() {
throw new UnsupportedOperationException();
}
@SuppressWarnings("CloneDoesntCallSuperClone") @Override public Call<T> clone() {
throw new UnsupportedOperationException();
}
@Override public Request request() {
return new Request.Builder().url(
new HttpUrl.Builder().scheme("https").host("example.com").build()).build();
}
}
}
/*
* Copyright (C) 2016 Eric Cochran
*
* 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.
*/
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import okhttp3.Request;
import okio.Buffer;
import retrofit2.Call;
import retrofit2.CallAdapter;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.Retrofit;
public final class LoggingCallAdapterFactory extends CallAdapter.Factory {
public interface Logger {
<T> void onResponse(Call<T> call, Response<T> response);
<T> void onFailure(Call<T> call, Throwable t);
}
private final Logger logger;
public LoggingCallAdapterFactory(Logger logger) {
this.logger = logger;
}
@Override
public CallAdapter<?, ?> get(Type returnType, Annotation[] annotations, Retrofit retrofit) {
CallAdapter<?, ?> adapter = retrofit.nextCallAdapter(this, returnType, annotations);
return new Adapter<>(logger, adapter);
}
private static final class Adapter<R, T> implements CallAdapter<R, T> {
private final Logger logger;
private final CallAdapter<R, T> delegate;
Adapter(Logger logger, CallAdapter<R, T> delegate) {
this.logger = logger;
this.delegate = delegate;
}
@Override public Type responseType() {
return delegate.responseType();
}
@Override public T adapt(Call<R> call) {
return delegate.adapt(new LoggingCall<>(logger, call));
}
}
private static final class LoggingCall<R> implements Call<R> {
final Logger logger;
private final Call<R> delegate;
LoggingCall(Logger logger, Call<R> delegate) {
this.logger = logger;
this.delegate = delegate;
}
void logResponse(Response<R> response) {
if (response.isSuccessful()) {
logger.onResponse(this, response);
} else {
Buffer buffer = response.errorBody().source().buffer();
long bufferByteCount = buffer.size();
logger.onResponse(this, response);
if (bufferByteCount != buffer.size()) {
throw new IllegalStateException("Do not consume the error body. Bytes before: "
+ bufferByteCount
+ ". Bytes after: "
+ buffer.size()
+ ".");
}
}
}
@Override public void enqueue(final Callback<R> callback) {
delegate.enqueue(new Callback<R>() {
@Override public void onResponse(Call<R> call, Response<R> response) {
logResponse(response);
callback.onResponse(call, response);
}
@Override public void onFailure(Call<R> call, Throwable t) {
logger.onFailure(call, t);
callback.onFailure(call, t);
}
});
}
@Override public boolean isExecuted() {
return delegate.isExecuted();
}
@Override public Response<R> execute() throws IOException {
try {
Response<R> response = delegate.execute();
logResponse(response);
return response;
} catch (IOException e) {
logger.onFailure(this, e);
throw e;
}
}
@Override public void cancel() {
delegate.cancel();
}
@Override public boolean isCanceled() {
return delegate.isCanceled();
}
@SuppressWarnings("CloneDoesntCallSuperClone") // Performing deep clone.
@Override public Call<R> clone() {
return new LoggingCall<>(logger, delegate.clone());
}
@Override public Request request() {
return delegate.request();
}
}
}
/*
* Copyright (C) 2016 Eric Cochran
*
* 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.
*/
import java.io.IOException;
import okhttp3.ResponseBody;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.Test;
import retrofit2.Call;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.http.GET;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
public final class LoggingCallAdapterFactoryTest {
private interface Service {
@GET("/") Call<ResponseBody> simpleCall();
}
@Test public void disallowsConsumingErrorBody() throws IOException {
MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse().setResponseCode(400).setBody("This request failed."));
Retrofit retrofit = new Retrofit.Builder().baseUrl(server.url("/"))
.addCallAdapterFactory(
new LoggingCallAdapterFactory(new LoggingCallAdapterFactory.Logger() {
@Override public <T> void onResponse(Call<T> call, Response<T> response) {
try {
response.errorBody().source().readByte();
} catch (IOException e) {
fail();
}
}
@Override public <T> void onFailure(Call<T> call, Throwable t) {
throw new AssertionError();
}
}))
.build();
Service service = retrofit.create(Service.class);
try {
service.simpleCall().execute();
fail();
} catch (IllegalStateException expected) {
assertEquals("Do not consume the error body. Bytes before: 20. Bytes after: 19.",
expected.getMessage());
}
}
}
@NightlyNexus
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment