Initial support for PagedList loading state

Bug: 73246446
Bug: 112714476
Test: ./gradlew paging:paging-runtime:cC paging:paging-common:test

Add error/retry capability to PagedList start/end. Can be observed via
Adapter/Differ.

Still TODO:
 - refresh error/retry. Requires coordination with observables.
 - TiledPagedList state support
 - NW + DB extension for DataSources (using new error/retry)

Change-Id: I4f6e19301775b6973fbae0b9668954d37f61ab5e
diff --git a/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt b/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt
index e62d10c..051ba3c 100644
--- a/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt
@@ -48,7 +48,7 @@
     val MEDIAROUTER = Version("1.1.0-alpha01")
     val MEDIA_WIDGET = Version("1.0.0-alpha5")
     val NAVIGATION = Version("1.0.0-alpha07")
-    val PAGING = Version("2.1.0-alpha01")
+    val PAGING = Version("2.2.0-alpha01")
     val PALETTE = Version("1.1.0-alpha01")
     val PERSISTENCE = Version("2.0.0")
     val PREFERENCE = Version("1.1.0-alpha01")
diff --git a/paging/common/api/2.2.0-alpha01.txt b/paging/common/api/2.2.0-alpha01.txt
new file mode 100644
index 0000000..e5a1266
--- /dev/null
+++ b/paging/common/api/2.2.0-alpha01.txt
@@ -0,0 +1,213 @@
+// Signature format: 2.0
+package androidx.paging {
+
+  public abstract class DataSource<Key, Value> {
+    method @AnyThread public void addInvalidatedCallback(androidx.paging.DataSource.InvalidatedCallback);
+    method @AnyThread public void invalidate();
+    method @WorkerThread public boolean isInvalid();
+    method public abstract <ToValue> androidx.paging.DataSource<Key,ToValue> map(androidx.arch.core.util.Function<Value,ToValue>);
+    method public abstract <ToValue> androidx.paging.DataSource<Key,ToValue> mapByPage(androidx.arch.core.util.Function<java.util.List<Value>,java.util.List<ToValue>>);
+    method @AnyThread public void removeInvalidatedCallback(androidx.paging.DataSource.InvalidatedCallback);
+  }
+
+  public abstract static class DataSource.Factory<Key, Value> {
+    ctor public DataSource.Factory();
+    method public abstract androidx.paging.DataSource<Key,Value> create();
+    method public <ToValue> androidx.paging.DataSource.Factory<Key,ToValue> map(androidx.arch.core.util.Function<Value,ToValue>);
+    method public <ToValue> androidx.paging.DataSource.Factory<Key,ToValue> mapByPage(androidx.arch.core.util.Function<java.util.List<Value>,java.util.List<ToValue>>);
+  }
+
+  public static interface DataSource.InvalidatedCallback {
+    method @AnyThread public void onInvalidated();
+  }
+
+  public abstract class ItemKeyedDataSource<Key, Value> extends androidx.paging.DataSource<Key,Value> {
+    method public abstract Key getKey(Value);
+    method public abstract void loadAfter(androidx.paging.ItemKeyedDataSource.LoadParams<Key>, androidx.paging.ItemKeyedDataSource.LoadCallback<Value>);
+    method public abstract void loadBefore(androidx.paging.ItemKeyedDataSource.LoadParams<Key>, androidx.paging.ItemKeyedDataSource.LoadCallback<Value>);
+    method public abstract void loadInitial(androidx.paging.ItemKeyedDataSource.LoadInitialParams<Key>, androidx.paging.ItemKeyedDataSource.LoadInitialCallback<Value>);
+    method public final <ToValue> androidx.paging.ItemKeyedDataSource<Key,ToValue> map(androidx.arch.core.util.Function<Value,ToValue>);
+    method public final <ToValue> androidx.paging.ItemKeyedDataSource<Key,ToValue> mapByPage(androidx.arch.core.util.Function<java.util.List<Value>,java.util.List<ToValue>>);
+  }
+
+  public abstract static class ItemKeyedDataSource.LoadCallback<Value> {
+    ctor public ItemKeyedDataSource.LoadCallback();
+    method public void onError(Throwable);
+    method public abstract void onResult(java.util.List<Value>);
+    method public void onRetryableError(Throwable);
+  }
+
+  public abstract static class ItemKeyedDataSource.LoadInitialCallback<Value> extends androidx.paging.ItemKeyedDataSource.LoadCallback<Value> {
+    ctor public ItemKeyedDataSource.LoadInitialCallback();
+    method public abstract void onResult(java.util.List<Value>, int, int);
+  }
+
+  public static class ItemKeyedDataSource.LoadInitialParams<Key> {
+    ctor public ItemKeyedDataSource.LoadInitialParams(Key?, int, boolean);
+    field public final boolean placeholdersEnabled;
+    field public final Key? requestedInitialKey;
+    field public final int requestedLoadSize;
+  }
+
+  public static class ItemKeyedDataSource.LoadParams<Key> {
+    ctor public ItemKeyedDataSource.LoadParams(Key, int);
+    field public final Key key;
+    field public final int requestedLoadSize;
+  }
+
+  public abstract class PageKeyedDataSource<Key, Value> extends androidx.paging.DataSource<Key,Value> {
+    method public abstract void loadAfter(androidx.paging.PageKeyedDataSource.LoadParams<Key>, androidx.paging.PageKeyedDataSource.LoadCallback<Key,Value>);
+    method public abstract void loadBefore(androidx.paging.PageKeyedDataSource.LoadParams<Key>, androidx.paging.PageKeyedDataSource.LoadCallback<Key,Value>);
+    method public abstract void loadInitial(androidx.paging.PageKeyedDataSource.LoadInitialParams<Key>, androidx.paging.PageKeyedDataSource.LoadInitialCallback<Key,Value>);
+    method public final <ToValue> androidx.paging.PageKeyedDataSource<Key,ToValue> map(androidx.arch.core.util.Function<Value,ToValue>);
+    method public final <ToValue> androidx.paging.PageKeyedDataSource<Key,ToValue> mapByPage(androidx.arch.core.util.Function<java.util.List<Value>,java.util.List<ToValue>>);
+  }
+
+  public abstract static class PageKeyedDataSource.LoadCallback<Key, Value> {
+    ctor public PageKeyedDataSource.LoadCallback();
+    method public void onError(Throwable);
+    method public abstract void onResult(java.util.List<Value>, Key?);
+    method public void onRetryableError(Throwable);
+  }
+
+  public abstract static class PageKeyedDataSource.LoadInitialCallback<Key, Value> {
+    ctor public PageKeyedDataSource.LoadInitialCallback();
+    method public void onError(Throwable);
+    method public abstract void onResult(java.util.List<Value>, int, int, Key?, Key?);
+    method public abstract void onResult(java.util.List<Value>, Key?, Key?);
+    method public void onRetryableError(Throwable);
+  }
+
+  public static class PageKeyedDataSource.LoadInitialParams<Key> {
+    ctor public PageKeyedDataSource.LoadInitialParams(int, boolean);
+    field public final boolean placeholdersEnabled;
+    field public final int requestedLoadSize;
+  }
+
+  public static class PageKeyedDataSource.LoadParams<Key> {
+    ctor public PageKeyedDataSource.LoadParams(Key, int);
+    field public final Key key;
+    field public final int requestedLoadSize;
+  }
+
+  public abstract class PagedList<T> extends java.util.AbstractList<T> {
+    method public void addWeakCallback(java.util.List<T>?, androidx.paging.PagedList.Callback);
+    method public void addWeakLoadStateListener(androidx.paging.PagedList.LoadStateListener);
+    method public void detach();
+    method public T? get(int);
+    method public androidx.paging.PagedList.Config getConfig();
+    method public abstract androidx.paging.DataSource<?,T> getDataSource();
+    method public abstract Object? getLastKey();
+    method public int getLoadedCount();
+    method public int getPositionOffset();
+    method public boolean isDetached();
+    method public boolean isImmutable();
+    method public void loadAround(int);
+    method public void removeWeakCallback(androidx.paging.PagedList.Callback);
+    method public void removeWeakLoadStateListener(androidx.paging.PagedList.LoadStateListener);
+    method public void retry();
+    method public int size();
+    method public java.util.List<T> snapshot();
+  }
+
+  @MainThread public abstract static class PagedList.BoundaryCallback<T> {
+    ctor public PagedList.BoundaryCallback();
+    method public void onItemAtEndLoaded(T);
+    method public void onItemAtFrontLoaded(T);
+    method public void onZeroItemsLoaded();
+  }
+
+  public static final class PagedList.Builder<Key, Value> {
+    ctor public PagedList.Builder(androidx.paging.DataSource<Key,Value>, androidx.paging.PagedList.Config);
+    ctor public PagedList.Builder(androidx.paging.DataSource<Key,Value>, int);
+    method @WorkerThread public androidx.paging.PagedList<Value> build();
+    method public androidx.paging.PagedList.Builder<Key,Value> setBoundaryCallback(androidx.paging.PagedList.BoundaryCallback?);
+    method public androidx.paging.PagedList.Builder<Key,Value> setFetchExecutor(java.util.concurrent.Executor);
+    method public androidx.paging.PagedList.Builder<Key,Value> setInitialKey(Key?);
+    method public androidx.paging.PagedList.Builder<Key,Value> setNotifyExecutor(java.util.concurrent.Executor);
+  }
+
+  public abstract static class PagedList.Callback {
+    ctor public PagedList.Callback();
+    method public abstract void onChanged(int, int);
+    method public abstract void onInserted(int, int);
+    method public abstract void onRemoved(int, int);
+  }
+
+  public static class PagedList.Config {
+    field public static final int MAX_SIZE_UNBOUNDED = 2147483647; // 0x7fffffff
+    field public final boolean enablePlaceholders;
+    field public final int initialLoadSizeHint;
+    field public final int maxSize;
+    field public final int pageSize;
+    field public final int prefetchDistance;
+  }
+
+  public static final class PagedList.Config.Builder {
+    ctor public PagedList.Config.Builder();
+    method public androidx.paging.PagedList.Config build();
+    method public androidx.paging.PagedList.Config.Builder setEnablePlaceholders(boolean);
+    method public androidx.paging.PagedList.Config.Builder setInitialLoadSizeHint(@IntRange(from=1) int);
+    method public androidx.paging.PagedList.Config.Builder setMaxSize(@IntRange(from=2) int);
+    method public androidx.paging.PagedList.Config.Builder setPageSize(@IntRange(from=1) int);
+    method public androidx.paging.PagedList.Config.Builder setPrefetchDistance(@IntRange(from=0) int);
+  }
+
+  public static enum PagedList.LoadState {
+    enum_constant public static final androidx.paging.PagedList.LoadState DONE;
+    enum_constant public static final androidx.paging.PagedList.LoadState ERROR;
+    enum_constant public static final androidx.paging.PagedList.LoadState IDLE;
+    enum_constant public static final androidx.paging.PagedList.LoadState LOADING;
+    enum_constant public static final androidx.paging.PagedList.LoadState RETRYABLE_ERROR;
+  }
+
+  public static interface PagedList.LoadStateListener {
+    method public void onLoadStateChanged(androidx.paging.PagedList.LoadType, androidx.paging.PagedList.LoadState, Throwable?);
+  }
+
+  public static enum PagedList.LoadType {
+    enum_constant public static final androidx.paging.PagedList.LoadType END;
+    enum_constant public static final androidx.paging.PagedList.LoadType REFRESH;
+    enum_constant public static final androidx.paging.PagedList.LoadType START;
+  }
+
+  public abstract class PositionalDataSource<T> extends androidx.paging.DataSource<java.lang.Integer,T> {
+    method public static int computeInitialLoadPosition(androidx.paging.PositionalDataSource.LoadInitialParams, int);
+    method public static int computeInitialLoadSize(androidx.paging.PositionalDataSource.LoadInitialParams, int, int);
+    method @WorkerThread public abstract void loadInitial(androidx.paging.PositionalDataSource.LoadInitialParams, androidx.paging.PositionalDataSource.LoadInitialCallback<T>);
+    method @WorkerThread public abstract void loadRange(androidx.paging.PositionalDataSource.LoadRangeParams, androidx.paging.PositionalDataSource.LoadRangeCallback<T>);
+    method public final <V> androidx.paging.PositionalDataSource<V> map(androidx.arch.core.util.Function<T,V>);
+    method public final <V> androidx.paging.PositionalDataSource<V> mapByPage(androidx.arch.core.util.Function<java.util.List<T>,java.util.List<V>>);
+  }
+
+  public abstract static class PositionalDataSource.LoadInitialCallback<T> {
+    ctor public PositionalDataSource.LoadInitialCallback();
+    method public void onError(Throwable);
+    method public abstract void onResult(java.util.List<T>, int, int);
+    method public abstract void onResult(java.util.List<T>, int);
+    method public void onRetryableError(Throwable);
+  }
+
+  public static class PositionalDataSource.LoadInitialParams {
+    ctor public PositionalDataSource.LoadInitialParams(int, int, int, boolean);
+    field public final int pageSize;
+    field public final boolean placeholdersEnabled;
+    field public final int requestedLoadSize;
+    field public final int requestedStartPosition;
+  }
+
+  public abstract static class PositionalDataSource.LoadRangeCallback<T> {
+    ctor public PositionalDataSource.LoadRangeCallback();
+    method public void onError(Throwable);
+    method public abstract void onResult(java.util.List<T>);
+    method public void onRetryableError(Throwable);
+  }
+
+  public static class PositionalDataSource.LoadRangeParams {
+    ctor public PositionalDataSource.LoadRangeParams(int, int);
+    field public final int loadSize;
+    field public final int startPosition;
+  }
+
+}
+
diff --git a/paging/common/api/current.txt b/paging/common/api/current.txt
index c8e0be1..e5a1266 100644
--- a/paging/common/api/current.txt
+++ b/paging/common/api/current.txt
@@ -32,7 +32,9 @@
 
   public abstract static class ItemKeyedDataSource.LoadCallback<Value> {
     ctor public ItemKeyedDataSource.LoadCallback();
+    method public void onError(Throwable);
     method public abstract void onResult(java.util.List<Value>);
+    method public void onRetryableError(Throwable);
   }
 
   public abstract static class ItemKeyedDataSource.LoadInitialCallback<Value> extends androidx.paging.ItemKeyedDataSource.LoadCallback<Value> {
@@ -63,13 +65,17 @@
 
   public abstract static class PageKeyedDataSource.LoadCallback<Key, Value> {
     ctor public PageKeyedDataSource.LoadCallback();
+    method public void onError(Throwable);
     method public abstract void onResult(java.util.List<Value>, Key?);
+    method public void onRetryableError(Throwable);
   }
 
   public abstract static class PageKeyedDataSource.LoadInitialCallback<Key, Value> {
     ctor public PageKeyedDataSource.LoadInitialCallback();
+    method public void onError(Throwable);
     method public abstract void onResult(java.util.List<Value>, int, int, Key?, Key?);
     method public abstract void onResult(java.util.List<Value>, Key?, Key?);
+    method public void onRetryableError(Throwable);
   }
 
   public static class PageKeyedDataSource.LoadInitialParams<Key> {
@@ -86,6 +92,7 @@
 
   public abstract class PagedList<T> extends java.util.AbstractList<T> {
     method public void addWeakCallback(java.util.List<T>?, androidx.paging.PagedList.Callback);
+    method public void addWeakLoadStateListener(androidx.paging.PagedList.LoadStateListener);
     method public void detach();
     method public T? get(int);
     method public androidx.paging.PagedList.Config getConfig();
@@ -97,6 +104,8 @@
     method public boolean isImmutable();
     method public void loadAround(int);
     method public void removeWeakCallback(androidx.paging.PagedList.Callback);
+    method public void removeWeakLoadStateListener(androidx.paging.PagedList.LoadStateListener);
+    method public void retry();
     method public int size();
     method public java.util.List<T> snapshot();
   }
@@ -144,6 +153,24 @@
     method public androidx.paging.PagedList.Config.Builder setPrefetchDistance(@IntRange(from=0) int);
   }
 
+  public static enum PagedList.LoadState {
+    enum_constant public static final androidx.paging.PagedList.LoadState DONE;
+    enum_constant public static final androidx.paging.PagedList.LoadState ERROR;
+    enum_constant public static final androidx.paging.PagedList.LoadState IDLE;
+    enum_constant public static final androidx.paging.PagedList.LoadState LOADING;
+    enum_constant public static final androidx.paging.PagedList.LoadState RETRYABLE_ERROR;
+  }
+
+  public static interface PagedList.LoadStateListener {
+    method public void onLoadStateChanged(androidx.paging.PagedList.LoadType, androidx.paging.PagedList.LoadState, Throwable?);
+  }
+
+  public static enum PagedList.LoadType {
+    enum_constant public static final androidx.paging.PagedList.LoadType END;
+    enum_constant public static final androidx.paging.PagedList.LoadType REFRESH;
+    enum_constant public static final androidx.paging.PagedList.LoadType START;
+  }
+
   public abstract class PositionalDataSource<T> extends androidx.paging.DataSource<java.lang.Integer,T> {
     method public static int computeInitialLoadPosition(androidx.paging.PositionalDataSource.LoadInitialParams, int);
     method public static int computeInitialLoadSize(androidx.paging.PositionalDataSource.LoadInitialParams, int, int);
@@ -155,8 +182,10 @@
 
   public abstract static class PositionalDataSource.LoadInitialCallback<T> {
     ctor public PositionalDataSource.LoadInitialCallback();
+    method public void onError(Throwable);
     method public abstract void onResult(java.util.List<T>, int, int);
     method public abstract void onResult(java.util.List<T>, int);
+    method public void onRetryableError(Throwable);
   }
 
   public static class PositionalDataSource.LoadInitialParams {
@@ -169,7 +198,9 @@
 
   public abstract static class PositionalDataSource.LoadRangeCallback<T> {
     ctor public PositionalDataSource.LoadRangeCallback();
+    method public void onError(Throwable);
     method public abstract void onResult(java.util.List<T>);
+    method public void onRetryableError(Throwable);
   }
 
   public static class PositionalDataSource.LoadRangeParams {
diff --git a/paging/common/ktx/api/2.2.0-alpha01.txt b/paging/common/ktx/api/2.2.0-alpha01.txt
new file mode 100644
index 0000000..0f070c2
--- /dev/null
+++ b/paging/common/ktx/api/2.2.0-alpha01.txt
@@ -0,0 +1,15 @@
+// Signature format: 2.0
+package androidx.paging {
+
+  public final class PagedListConfigKt {
+    ctor public PagedListConfigKt();
+    method public static androidx.paging.PagedList.Config Config(int pageSize, int prefetchDistance = pageSize, boolean enablePlaceholders = true, int initialLoadSizeHint = pageSize * androidx.paging.PagedList.Config.Builder.DEFAULT_INITIAL_PAGE_MULTIPLIER, int maxSize = 2147483647);
+  }
+
+  public final class PagedListKt {
+    ctor public PagedListKt();
+    method public static <Key, Value> androidx.paging.PagedList<Value> PagedList(androidx.paging.DataSource<Key,Value> dataSource, androidx.paging.PagedList.Config config, java.util.concurrent.Executor notifyExecutor, java.util.concurrent.Executor fetchExecutor, androidx.paging.PagedList.BoundaryCallback<Value>? boundaryCallback = null, Key? initialKey = null);
+  }
+
+}
+
diff --git a/paging/common/src/main/java/androidx/paging/ContiguousPagedList.java b/paging/common/src/main/java/androidx/paging/ContiguousPagedList.java
index 9906067..7233b7e 100644
--- a/paging/common/src/main/java/androidx/paging/ContiguousPagedList.java
+++ b/paging/common/src/main/java/androidx/paging/ContiguousPagedList.java
@@ -16,15 +16,11 @@
 
 package androidx.paging;
 
-import static java.lang.annotation.RetentionPolicy.SOURCE;
-
 import androidx.annotation.AnyThread;
-import androidx.annotation.IntDef;
 import androidx.annotation.MainThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
-import java.lang.annotation.Retention;
 import java.util.List;
 import java.util.concurrent.Executor;
 
@@ -32,21 +28,6 @@
     @SuppressWarnings("WeakerAccess") /* synthetic access */
     final ContiguousDataSource<K, V> mDataSource;
 
-    @Retention(SOURCE)
-    @IntDef({READY_TO_FETCH, FETCHING, DONE_FETCHING})
-    @interface FetchState {}
-
-    private static final int READY_TO_FETCH = 0;
-    private static final int FETCHING = 1;
-    private static final int DONE_FETCHING = 2;
-
-    @FetchState
-    @SuppressWarnings("WeakerAccess") /* synthetic access */
-    int mPrependWorkerState = READY_TO_FETCH;
-    @FetchState
-    @SuppressWarnings("WeakerAccess") /* synthetic access */
-    int mAppendWorkerState = READY_TO_FETCH;
-
     @SuppressWarnings("WeakerAccess") /* synthetic access */
     int mPrependItemsRequested = 0;
     @SuppressWarnings("WeakerAccess") /* synthetic access */
@@ -80,6 +61,8 @@
             if (resultType == PageResult.INIT) {
                 mStorage.init(pageResult.leadingNulls, page, pageResult.trailingNulls,
                         pageResult.positionOffset, ContiguousPagedList.this);
+                // TODO: signal that this list is ready to be dispatched to observer
+
                 if (mLastLoad == LAST_LOAD_UNSPECIFIED) {
                     // Because the ContiguousPagedList wasn't initialized with a last load position,
                     // initialize it to the middle of the initial load
@@ -99,7 +82,7 @@
                     if (skipNewPage && !trimFromFront) {
                         // don't append this data, drop it
                         mAppendItemsRequested = 0;
-                        mAppendWorkerState = READY_TO_FETCH;
+                        mLoadStateManager.setState(LoadType.END, LoadState.IDLE, null);
                     } else {
                         mStorage.appendPage(page, ContiguousPagedList.this);
                     }
@@ -107,7 +90,7 @@
                     if (skipNewPage && trimFromFront) {
                         // don't append this data, drop it
                         mPrependItemsRequested = 0;
-                        mPrependWorkerState = READY_TO_FETCH;
+                        mLoadStateManager.setState(LoadType.START, LoadState.IDLE, null);
                     } else {
                         mStorage.prependPage(page, ContiguousPagedList.this);
                     }
@@ -116,25 +99,28 @@
                 }
 
                 if (mShouldTrim) {
+                    // Try and trim, but only if the side being trimmed isn't actually fetching.
+                    // For simplicity (both of impl here, and contract w/ DataSource) we don't want
+                    // simultaneous fetches in same direction.
                     if (trimFromFront) {
-                        if (mPrependWorkerState != FETCHING) {
+                        if (mLoadStateManager.getStart() != LoadState.LOADING) {
                             if (mStorage.trimFromFront(
                                     mReplacePagesWithNulls,
                                     mConfig.maxSize,
                                     mRequiredRemainder,
                                     ContiguousPagedList.this)) {
                                 // trimmed from front, ensure we can fetch in that dir
-                                mPrependWorkerState = READY_TO_FETCH;
+                                mLoadStateManager.setState(LoadType.START, LoadState.IDLE, null);
                             }
                         }
                     } else {
-                        if (mAppendWorkerState != FETCHING) {
+                        if (mLoadStateManager.getEnd() != LoadState.LOADING) {
                             if (mStorage.trimFromEnd(
                                     mReplacePagesWithNulls,
                                     mConfig.maxSize,
                                     mRequiredRemainder,
                                     ContiguousPagedList.this)) {
-                                mAppendWorkerState = READY_TO_FETCH;
+                                mLoadStateManager.setState(LoadType.END, LoadState.IDLE, null);
                             }
                         }
                     }
@@ -152,8 +138,34 @@
                 deferBoundaryCallbacks(deferEmpty, deferBegin, deferEnd);
             }
         }
+
+        @Override
+        public void onPageError(@PageResult.ResultType int resultType,
+                @NonNull Throwable error, boolean retryable) {
+            LoadState errorState = retryable ? LoadState.RETRYABLE_ERROR : LoadState.ERROR;
+
+            if (resultType == PageResult.PREPEND) {
+                mLoadStateManager.setState(LoadType.START, errorState, error);
+            } else if (resultType == PageResult.APPEND) {
+                mLoadStateManager.setState(LoadType.END, errorState, error);
+            } else {
+                // TODO: pass init signal through to *previous* list
+                throw new IllegalStateException("TODO");
+            }
+        }
     };
 
+    @Override
+    public void retry() {
+        super.retry();
+        if (mLoadStateManager.getStart() == LoadState.RETRYABLE_ERROR) {
+            schedulePrepend();
+        }
+        if (mLoadStateManager.getEnd() == LoadState.RETRYABLE_ERROR) {
+            scheduleAppend();
+        }
+    }
+
     static final int LAST_LOAD_UNSPECIFIED = -1;
 
     ContiguousPagedList(
@@ -162,7 +174,7 @@
             @NonNull Executor backgroundThreadExecutor,
             @Nullable BoundaryCallback<V> boundaryCallback,
             @NonNull Config config,
-            final @Nullable K key,
+            @Nullable K key,
             int lastLoad) {
         super(new PagedStorage<V>(), mainThreadExecutor, backgroundThreadExecutor,
                 boundaryCallback, config);
@@ -251,22 +263,19 @@
                 mStorage.getLeadingNullCount() + mStorage.getStorageCount());
 
         mPrependItemsRequested = Math.max(prependItems, mPrependItemsRequested);
-        if (mPrependItemsRequested > 0) {
+        if (mPrependItemsRequested > 0 && mLoadStateManager.getStart() == LoadState.IDLE) {
             schedulePrepend();
         }
 
         mAppendItemsRequested = Math.max(appendItems, mAppendItemsRequested);
-        if (mAppendItemsRequested > 0) {
+        if (mAppendItemsRequested > 0 && mLoadStateManager.getEnd() == LoadState.IDLE) {
             scheduleAppend();
         }
     }
 
     @MainThread
     private void schedulePrepend() {
-        if (mPrependWorkerState != READY_TO_FETCH) {
-            return;
-        }
-        mPrependWorkerState = FETCHING;
+        mLoadStateManager.setState(LoadType.START, LoadState.LOADING, null);
 
         final int position = mStorage.getLeadingNullCount() + mStorage.getPositionOffset();
 
@@ -290,10 +299,7 @@
 
     @MainThread
     private void scheduleAppend() {
-        if (mAppendWorkerState != READY_TO_FETCH) {
-            return;
-        }
-        mAppendWorkerState = FETCHING;
+        mLoadStateManager.setState(LoadType.END, LoadState.LOADING, null);
 
         final int position = mStorage.getLeadingNullCount()
                 + mStorage.getStorageCount() - 1 + mStorage.getPositionOffset();
@@ -347,10 +353,11 @@
     public void onPagePrepended(int leadingNulls, int changedCount, int addedCount) {
         // consider whether to post more work, now that a page is fully prepended
         mPrependItemsRequested = mPrependItemsRequested - changedCount - addedCount;
-        mPrependWorkerState = READY_TO_FETCH;
         if (mPrependItemsRequested > 0) {
             // not done prepending, keep going
             schedulePrepend();
+        } else {
+            mLoadStateManager.setState(LoadType.START, LoadState.IDLE, null);
         }
 
         // finally dispatch callbacks, after prepend may have already been scheduled
@@ -363,7 +370,7 @@
     @MainThread
     @Override
     public void onEmptyPrepend() {
-        mPrependWorkerState = DONE_FETCHING;
+        mLoadStateManager.setState(LoadType.START, LoadState.DONE, null);
     }
 
     @MainThread
@@ -371,10 +378,11 @@
     public void onPageAppended(int endPosition, int changedCount, int addedCount) {
         // consider whether to post more work, now that a page is fully appended
         mAppendItemsRequested = mAppendItemsRequested - changedCount - addedCount;
-        mAppendWorkerState = READY_TO_FETCH;
         if (mAppendItemsRequested > 0) {
             // not done appending, keep going
             scheduleAppend();
+        } else {
+            mLoadStateManager.setState(LoadType.END, LoadState.IDLE, null);
         }
 
         // finally dispatch callbacks, after append may have already been scheduled
@@ -385,7 +393,7 @@
     @MainThread
     @Override
     public void onEmptyAppend() {
-        mAppendWorkerState = DONE_FETCHING;
+        mLoadStateManager.setState(LoadType.END, LoadState.DONE, null);
     }
 
     @MainThread
diff --git a/paging/common/src/main/java/androidx/paging/DataSource.java b/paging/common/src/main/java/androidx/paging/DataSource.java
index 60f3cfe..fe4f126 100644
--- a/paging/common/src/main/java/androidx/paging/DataSource.java
+++ b/paging/common/src/main/java/androidx/paging/DataSource.java
@@ -17,6 +17,7 @@
 package androidx.paging;
 
 import androidx.annotation.AnyThread;
+import androidx.annotation.GuardedBy;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
@@ -277,6 +278,8 @@
         // mSignalLock protects mPostExecutor, and mHasSignalled
         private final Object mSignalLock = new Object();
         private Executor mPostExecutor = null;
+
+        @GuardedBy("mSignalLock")
         private boolean mHasSignalled = false;
 
         LoadCallbackHelper(@NonNull DataSource dataSource, @PageResult.ResultType int resultType,
@@ -298,6 +301,7 @@
          *
          * @return true if DataSource was invalid, and invalid result dispatched
          */
+        @SuppressWarnings("BooleanMethodIsAlwaysInverted")
         boolean dispatchInvalidResultIfInvalid() {
             if (mDataSource.isInvalid()) {
                 dispatchResultToReceiver(PageResult.<T>getInvalidResult());
@@ -306,12 +310,21 @@
             return false;
         }
 
-        void dispatchResultToReceiver(final @NonNull PageResult<T> result) {
+        void dispatchResultToReceiver(@NonNull PageResult<T> result) {
+            dispatchToReceiver(result, null, false);
+        }
+
+        void dispatchErrorToReceiver(@NonNull Throwable error, boolean retryable) {
+            dispatchToReceiver(null, error, retryable);
+        }
+
+        private void dispatchToReceiver(final @Nullable PageResult<T> result,
+                final @Nullable Throwable error, final boolean retryable) {
             Executor executor;
             synchronized (mSignalLock) {
                 if (mHasSignalled) {
                     throw new IllegalStateException(
-                            "callback.onResult already called, cannot call again.");
+                            "callback.onResult/onError already called, cannot call again.");
                 }
                 mHasSignalled = true;
                 executor = mPostExecutor;
@@ -321,11 +334,21 @@
                 executor.execute(new Runnable() {
                     @Override
                     public void run() {
-                        mReceiver.onPageResult(mResultType, result);
+                        dispatchOnCurrentThread(result, error, retryable);
                     }
                 });
             } else {
+                dispatchOnCurrentThread(result, error, retryable);
+            }
+        }
+
+        @SuppressWarnings("ConstantConditions")
+        void dispatchOnCurrentThread(@Nullable PageResult<T> result,
+                @Nullable Throwable error, boolean retryable) {
+            if (result != null) {
                 mReceiver.onPageResult(mResultType, result);
+            } else {
+                mReceiver.onPageError(mResultType, error, retryable);
             }
         }
     }
diff --git a/paging/common/src/main/java/androidx/paging/ItemKeyedDataSource.java b/paging/common/src/main/java/androidx/paging/ItemKeyedDataSource.java
index 2e89ba6..493aba7 100644
--- a/paging/common/src/main/java/androidx/paging/ItemKeyedDataSource.java
+++ b/paging/common/src/main/java/androidx/paging/ItemKeyedDataSource.java
@@ -153,7 +153,6 @@
         public abstract void onResult(@NonNull List<Value> data, int position, int totalCount);
     }
 
-
     /**
      * Callback for ItemKeyedDataSource {@link #loadBefore(LoadParams, LoadCallback)}
      * and {@link #loadAfter(LoadParams, LoadCallback)} to return data.
@@ -183,6 +182,38 @@
          * @param data List of items loaded from the ItemKeyedDataSource.
          */
         public abstract void onResult(@NonNull List<Value> data);
+
+        /**
+         * Called to report a non-retryable error from a DataSource.
+         * <p>
+         * Call this method to report a non-retryable error from
+         * {@link #loadInitial(LoadInitialParams, LoadInitialCallback)},
+         * {@link #loadBefore(LoadParams, LoadCallback)}, or
+         * {@link #loadAfter(LoadParams, LoadCallback)} methods.
+         *
+         * @param error The error that occurred during loading.
+         */
+        public void onError(@NonNull Throwable error) {
+            // TODO: remove default implementation in 3.0
+            throw new IllegalStateException(
+                    "You must implement onError if implementing your own load callback");
+        }
+
+        /**
+         * Called to report a retryable error from a DataSource.
+         * <p>
+         * Call this method to report a retryable error from
+         * {@link #loadInitial(LoadInitialParams, LoadInitialCallback)},
+         * {@link #loadBefore(LoadParams, LoadCallback)}, or
+         * {@link #loadAfter(LoadParams, LoadCallback)} methods.
+         *
+         * @param error The error that occurred during loading.
+         */
+        public void onRetryableError(@NonNull Throwable error) {
+            // TODO: remove default implementation in 3.0
+            throw new IllegalStateException(
+                    "You must implement onRetryableError if implementing your own load callback");
+        }
     }
 
     static class LoadInitialCallbackImpl<Value> extends LoadInitialCallback<Value> {
@@ -215,6 +246,16 @@
                 mCallbackHelper.dispatchResultToReceiver(new PageResult<>(data, 0, 0, 0));
             }
         }
+
+        @Override
+        public void onError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, false);
+        }
+
+        @Override
+        public void onRetryableError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, true);
+        }
     }
 
     static class LoadCallbackImpl<Value> extends LoadCallback<Value> {
@@ -233,6 +274,16 @@
                 mCallbackHelper.dispatchResultToReceiver(new PageResult<>(data, 0, 0, 0));
             }
         }
+
+        @Override
+        public void onError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, false);
+        }
+
+        @Override
+        public void onRetryableError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, true);
+        }
     }
 
     @Nullable
diff --git a/paging/common/src/main/java/androidx/paging/PageKeyedDataSource.java b/paging/common/src/main/java/androidx/paging/PageKeyedDataSource.java
index 076d791..81e8626 100644
--- a/paging/common/src/main/java/androidx/paging/PageKeyedDataSource.java
+++ b/paging/common/src/main/java/androidx/paging/PageKeyedDataSource.java
@@ -205,6 +205,34 @@
          */
         public abstract void onResult(@NonNull List<Value> data, @Nullable Key previousPageKey,
                 @Nullable Key nextPageKey);
+
+        /**
+         * Called to report a non-retryable error from a DataSource.
+         * <p>
+         * Call this method to report a non-retryable error from
+         * {@link #loadInitial(LoadInitialParams, LoadInitialCallback)}.
+         *
+         * @param error The error that occurred during loading.
+         */
+        public void onError(@NonNull Throwable error) {
+            // TODO: remove default implementation in 3.0
+            throw new IllegalStateException(
+                    "You must implement onError if implementing your own load callback");
+        }
+
+        /**
+         * Called to report a retryable error from a DataSource.
+         * <p>
+         * Call this method to report an error from
+         * {@link #loadInitial(LoadInitialParams, LoadInitialCallback)}.
+         *
+         * @param error The error that occurred during loading.
+         */
+        public void onRetryableError(@NonNull Throwable error) {
+            // TODO: remove default implementation in 3.0
+            throw new IllegalStateException(
+                    "You must implement onRetryableError if implementing your own load callback");
+        }
     }
 
     /**
@@ -244,6 +272,36 @@
          *                        no more pages to load in the current load direction.
          */
         public abstract void onResult(@NonNull List<Value> data, @Nullable Key adjacentPageKey);
+
+        /**
+         * Called to report a non-retryable error from a DataSource.
+         * <p>
+         * Call this method to report a non-retryable error from your PageKeyedDataSource's
+         * {@link #loadBefore(LoadParams, LoadCallback)} and
+         * {@link #loadAfter(LoadParams, LoadCallback)} methods.
+         *
+         * @param error The error that occurred during loading.
+         */
+        public void onError(@NonNull Throwable error) {
+            // TODO: remove default implementation in 3.0
+            throw new IllegalStateException(
+                    "You must implement onError if implementing your own load callback");
+        }
+
+        /**
+         * Called to report a retryable error from a DataSource.
+         * <p>
+         * Call this method to report an error from your PageKeyedDataSource's
+         * {@link #loadBefore(LoadParams, LoadCallback)} and
+         * {@link #loadAfter(LoadParams, LoadCallback)} methods.
+         *
+         * @param error The error that occurred during loading.
+         */
+        public void onRetryableError(@NonNull Throwable error) {
+            // TODO: remove default implementation in 3.0
+            throw new IllegalStateException(
+                    "You must implement onRetryableError if implementing your own load callback");
+        }
     }
 
     static class LoadInitialCallbackImpl<Key, Value> extends LoadInitialCallback<Key, Value> {
@@ -285,6 +343,16 @@
                 mCallbackHelper.dispatchResultToReceiver(new PageResult<>(data, 0, 0, 0));
             }
         }
+
+        @Override
+        public void onError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, false);
+        }
+
+        @Override
+        public void onRetryableError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, true);
+        }
     }
 
     static class LoadCallbackImpl<Key, Value> extends LoadCallback<Key, Value> {
@@ -309,6 +377,16 @@
                 mCallbackHelper.dispatchResultToReceiver(new PageResult<>(data, 0, 0, 0));
             }
         }
+
+        @Override
+        public void onError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, false);
+        }
+
+        @Override
+        public void onRetryableError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, true);
+        }
     }
 
     @Nullable
diff --git a/paging/common/src/main/java/androidx/paging/PageResult.java b/paging/common/src/main/java/androidx/paging/PageResult.java
index e654a73..d231d2b 100644
--- a/paging/common/src/main/java/androidx/paging/PageResult.java
+++ b/paging/common/src/main/java/androidx/paging/PageResult.java
@@ -101,5 +101,9 @@
     abstract static class Receiver<T> {
         @MainThread
         public abstract void onPageResult(@ResultType int type, @NonNull PageResult<T> pageResult);
+
+        @MainThread
+        public abstract void onPageError(@ResultType int type,
+                @NonNull Throwable error, boolean retryable);
     }
 }
diff --git a/paging/common/src/main/java/androidx/paging/PagedList.java b/paging/common/src/main/java/androidx/paging/PagedList.java
index 1d4a184..9070301 100644
--- a/paging/common/src/main/java/androidx/paging/PagedList.java
+++ b/paging/common/src/main/java/androidx/paging/PagedList.java
@@ -113,6 +113,204 @@
  */
 public abstract class PagedList<T> extends AbstractList<T> {
 
+    /**
+     * Type of load a PagedList can perform.
+     * <p>
+     * You can use a {@link LoadStateListener} to observe {@link LoadState} of
+     * any {@link LoadType}. For UI purposes (swipe refresh, loading spinner, retry button), this
+     * is typically done by registering a Listener with the {@code PagedListAdapter} or
+     * {@code AsyncPagedListDiffer}.
+     *
+     * @see LoadState
+     */
+    public enum LoadType {
+        /**
+         * PagedList content being reloaded, may contain content updates.
+         */
+        REFRESH,
+
+        /**
+         * Load at the start of the PagedList.
+         */
+        START,
+
+        /**
+         * Load at the end of the PagedList.
+         */
+        END
+    }
+
+    /**
+     * State of a PagedList load - associated with a {@code LoadType}
+     * <p>
+     * You can use a {@link LoadStateListener} to observe {@link LoadState} of
+     * any {@link LoadType}. For UI purposes (swipe refresh, loading spinner, retry button), this
+     * is typically done by registering a Listener with the {@code PagedListAdapter} or
+     * {@code AsyncPagedListDiffer}.
+     */
+    public enum LoadState {
+        /**
+         * Indicates the PagedList is not currently loading, and no error currently observed.
+         */
+        IDLE,
+
+        /**
+         * Loading is in progress.
+         */
+        LOADING,
+
+        /**
+         * Loading is complete.
+         */
+        DONE,
+
+        /**
+         * Loading hit a non-retryable error.
+         */
+        ERROR,
+
+        /**
+         * Loading hit a retryable error.
+         *
+         * @see #retry()
+         */
+        RETRYABLE_ERROR,
+    }
+
+
+
+
+    /**
+     * Listener for changes to loading state - whether the refresh, prepend, or append is idle,
+     * loading, or has an error.
+     * <p>
+     * Can be used to observe the {@link LoadState} of any {@link LoadType} (REFRESH/START/END).
+     * For UI purposes (swipe refresh, loading spinner, retry button), this is typically done by
+     * registering a Listener with the {@code PagedListAdapter} or {@code AsyncPagedListDiffer}.
+     * <p>
+     * These calls will be dispatched on the executor defined by
+     * {@link Builder#setNotifyExecutor(Executor)}, which is generally the main/UI thread.
+     *
+     * @see LoadType
+     * @see LoadState
+     */
+    public interface LoadStateListener {
+        /**
+         * Called when the LoadState has changed - whether the refresh, prepend, or append is
+         * idle, loading, or has an error.
+         * <p>
+         * REFRESH events can be used to drive a {@code SwipeRefreshLayout}, or START/END events
+         * can be used to drive loading spinner items in your {@code RecyclerView}.
+         *
+         * @param type Type of load - START, END, or REFRESH.
+         * @param state State of load - IDLE, LOADING, DONE, ERROR, or RETRYABLE_ERROR
+         * @param error Error, if in an error state, null otherwise.
+         *
+         * @see #retry()
+         */
+        void onLoadStateChanged(@NonNull LoadType type,
+                @NonNull LoadState state, @Nullable Throwable error);
+    }
+
+    @SuppressWarnings("WeakerAccess") /* synthetic access */
+    static boolean equalsHelper(@Nullable Object a, @Nullable Object b) {
+        // Because Objects.equals() is API 19+
+        return a == b || (a != null && a.equals(b));
+    }
+
+    abstract static class LoadStateManager {
+        @NonNull
+        private LoadState mRefresh = LoadState.IDLE;
+        @Nullable
+        private Throwable mRefreshError = null;
+        @NonNull
+        private LoadState mStart = LoadState.IDLE;
+        @Nullable
+        private Throwable mStartError = null;
+        @NonNull
+        private LoadState mEnd = LoadState.IDLE;
+        @Nullable
+        private Throwable mEndError = null;
+
+        @NonNull
+        public LoadState getRefresh() {
+            return mRefresh;
+        }
+
+        @NonNull
+        public LoadState getStart() {
+            return mStart;
+        }
+
+        @NonNull
+        public LoadState getEnd() {
+            return mEnd;
+        }
+
+        @Nullable
+        public Throwable getRefreshError() {
+            return mRefreshError;
+        }
+
+        @Nullable
+        public Throwable getStartError() {
+            return mStartError;
+        }
+
+        @Nullable
+        public Throwable getEndError() {
+            return mEndError;
+        }
+
+        void setState(@NonNull LoadType type, @NonNull LoadState state, @Nullable Throwable error) {
+            boolean expectError = state == LoadState.RETRYABLE_ERROR || state == LoadState.ERROR;
+            boolean hasError = error != null;
+            if (expectError != hasError) {
+                throw new IllegalArgumentException(
+                        "Error states must be accompanied by a throwable, other states must not");
+            }
+
+            // deduplicate signals
+            switch (type) {
+                case REFRESH:
+                    if (mRefresh.equals(state) && equalsHelper(mRefreshError, error)) return;
+                    mRefresh = state;
+                    mRefreshError = error;
+                    break;
+                case START:
+                    if (mStart.equals(state) && equalsHelper(mStartError, error)) return;
+                    mStart = state;
+                    mStartError = error;
+                    break;
+                case END:
+                    if (mEnd.equals(state) && equalsHelper(mEndError, error)) return;
+                    mEnd = state;
+                    mEndError = error;
+                    break;
+            }
+            onStateChanged(type, state, error);
+        }
+
+        protected abstract void onStateChanged(@NonNull LoadType type,
+                @NonNull LoadState state, @Nullable Throwable error);
+    }
+
+    /**
+     * Retry any retryable errors associated with this PagedList.
+     * <p>
+     * If for example a network DataSource append timed out, calling this method will retry the
+     * failed append load. Note that your DataSource will need to pass {@code true} to
+     * {@code onError()} to signify the error as retryable.
+     * <p>
+     * You can observe loading state via {@link #addWeakLoadStateListener(LoadStateListener)},
+     * though generally this is done through the {@link PagedListAdapter} or
+     * {@link AsyncPagedListDiffer}.
+     *
+     * @see #addWeakLoadStateListener(LoadStateListener)
+     * @see #removeWeakLoadStateListener(LoadStateListener)
+     */
+    public void retry() {}
+
     // Notes on threading:
     //
     // PagedList and its subclasses are passed and accessed on multiple threads, but are always
@@ -164,6 +362,31 @@
 
     private final ArrayList<WeakReference<Callback>> mCallbacks = new ArrayList<>();
 
+    @SuppressWarnings("WeakerAccess") /* synthetic access */
+    final ArrayList<WeakReference<LoadStateListener>> mListeners = new ArrayList<>();
+
+    final LoadStateManager mLoadStateManager = new LoadStateManager() {
+        @Override
+        protected void onStateChanged(@NonNull final LoadType type, @NonNull final LoadState state,
+                @Nullable final Throwable error) {
+            // new state, dispatch to listeners
+            // Post, since UI will want to react immediately
+            mMainThreadExecutor.execute(new Runnable() {
+                @Override
+                public void run() {
+                    for (int i = mListeners.size() - 1; i >= 0; i--) {
+                        final LoadStateListener currentListener = mListeners.get(i).get();
+                        if (currentListener == null) {
+                            mListeners.remove(i);
+                        } else {
+                            currentListener.onLoadStateChanged(type, state, error);
+                        }
+                    }
+                }
+            });
+        }
+    };
+
     PagedList(@NonNull PagedStorage<T> storage,
             @NonNull Executor mainThreadExecutor,
             @NonNull Executor backgroundThreadExecutor,
@@ -680,6 +903,49 @@
         return mStorage.getPositionOffset();
     }
 
+
+    /**
+     * Add a LoadStateListener to observe the loading state of the PagedList.
+     *
+     * @param listener Listener to receive updates.
+     *
+     * @see #removeWeakLoadStateListener(LoadStateListener)
+     */
+    public void addWeakLoadStateListener(@NonNull LoadStateListener listener) {
+        // first, clean up any empty weak refs
+        for (int i = mListeners.size() - 1; i >= 0; i--) {
+            final LoadStateListener currentListener = mListeners.get(i).get();
+            if (currentListener == null) {
+                mListeners.remove(i);
+            }
+        }
+
+        // then add the new one
+        mListeners.add(new WeakReference<>(listener));
+        listener.onLoadStateChanged(PagedList.LoadType.REFRESH, mLoadStateManager.getRefresh(),
+                mLoadStateManager.getRefreshError());
+        listener.onLoadStateChanged(PagedList.LoadType.START, mLoadStateManager.getStart(),
+                mLoadStateManager.getStartError());
+        listener.onLoadStateChanged(PagedList.LoadType.END, mLoadStateManager.getEnd(),
+                mLoadStateManager.getEndError());
+    }
+
+    /**
+     * Remove a previously registered LoadStateListener.
+     *
+     * @param listener Previously registered listener.
+     * @see #addWeakLoadStateListener(LoadStateListener)
+     */
+    public void removeWeakLoadStateListener(@NonNull LoadStateListener listener) {
+        for (int i = mListeners.size() - 1; i >= 0; i--) {
+            final LoadStateListener currentListener = mListeners.get(i).get();
+            if (currentListener == null || currentListener == listener) {
+                // found Listener, or empty weak ref
+                mListeners.remove(i);
+            }
+        }
+    }
+
     /**
      * Adds a callback, and issues updates since the previousSnapshot was created.
      * <p>
diff --git a/paging/common/src/main/java/androidx/paging/PositionalDataSource.java b/paging/common/src/main/java/androidx/paging/PositionalDataSource.java
index 158aa1e..0be0a0a 100644
--- a/paging/common/src/main/java/androidx/paging/PositionalDataSource.java
+++ b/paging/common/src/main/java/androidx/paging/PositionalDataSource.java
@@ -103,7 +103,7 @@
     @SuppressWarnings("WeakerAccess")
     public static class LoadRangeParams {
         /**
-         * Start position of data to load.
+         * START position of data to load.
          * <p>
          * Returned data must start at this position.
          */
@@ -173,6 +173,34 @@
          *                 pass {@code N}.
          */
         public abstract void onResult(@NonNull List<T> data, int position);
+
+        /**
+         * Called to report a non-retryable error from a DataSource.
+         * <p>
+         * Call this method to report a non-retryable error from
+         * {@link #loadInitial(LoadInitialParams, LoadInitialCallback)}.
+         *
+         * @param error The error that occurred during loading.
+         */
+        public void onError(@NonNull Throwable error) {
+            // TODO: remove default implementation in 3.0
+            throw new IllegalStateException(
+                    "You must implement onError if implementing your own load callback");
+        }
+
+        /**
+         * Called to report a retryable error from a DataSource.
+         * <p>
+         * Call this method to report a retryable error from
+         * {@link #loadInitial(LoadInitialParams, LoadInitialCallback)}.
+         *
+         * @param error The error that occurred during loading.
+         */
+        public void onRetryableError(@NonNull Throwable error) {
+            // TODO: remove default implementation in 3.0
+            throw new IllegalStateException(
+                    "You must implement onRetryableError if implementing your own load callback");
+        }
     }
 
     /**
@@ -195,6 +223,34 @@
          *             unless at end of list.
          */
         public abstract void onResult(@NonNull List<T> data);
+
+        /**
+         * Called to report a non-retryable error from a DataSource.
+         * <p>
+         * Call this method to report a non-retryable error from
+         * {@link #loadRange(LoadRangeParams, LoadRangeCallback)}.
+         *
+         * @param error The error that occurred during loading.
+         */
+        public void onError(@NonNull Throwable error) {
+            // TODO: remove default implementation in 3.0
+            throw new IllegalStateException(
+                    "You must implement onError if implementing your own load callback");
+        }
+
+        /**
+         * Called to report a retryable error from a DataSource.
+         * <p>
+         * Call this method to report a retryable error from
+         * {@link #loadRange(LoadRangeParams, LoadRangeCallback)}.
+         *
+         * @param error The error that occurred during loading.
+         */
+        public void onRetryableError(@NonNull Throwable error) {
+            // TODO: remove default implementation in 3.0
+            throw new IllegalStateException(
+                    "You must implement onRetryableError if implementing your own load callback");
+        }
     }
 
     static class LoadInitialCallbackImpl<T> extends LoadInitialCallback<T> {
@@ -253,6 +309,16 @@
                 mCallbackHelper.dispatchResultToReceiver(new PageResult<>(data, position));
             }
         }
+
+        @Override
+        public void onError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, false);
+        }
+
+        @Override
+        public void onRetryableError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, true);
+        }
     }
 
     static class LoadRangeCallbackImpl<T> extends LoadRangeCallback<T> {
@@ -273,6 +339,16 @@
                         data, 0, 0, mPositionOffset));
             }
         }
+
+        @Override
+        public void onError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, false);
+        }
+
+        @Override
+        public void onRetryableError(@NonNull Throwable error) {
+            mCallbackHelper.dispatchErrorToReceiver(error, true);
+        }
     }
 
     final void dispatchLoadInitial(boolean acceptCount,
diff --git a/paging/common/src/main/java/androidx/paging/TiledPagedList.java b/paging/common/src/main/java/androidx/paging/TiledPagedList.java
index e68869e..c01fa83 100644
--- a/paging/common/src/main/java/androidx/paging/TiledPagedList.java
+++ b/paging/common/src/main/java/androidx/paging/TiledPagedList.java
@@ -79,6 +79,11 @@
                 deferBoundaryCallbacks(deferEmpty, deferBegin, deferEnd);
             }
         }
+
+        @Override
+        public void onPageError(int type, @NonNull Throwable error, boolean retryable) {
+            throw new IllegalStateException("Tiled error handling not yet implemented");
+        }
     };
 
     @WorkerThread
diff --git a/paging/common/src/main/java/androidx/paging/WrapperItemKeyedDataSource.java b/paging/common/src/main/java/androidx/paging/WrapperItemKeyedDataSource.java
index dc9f908..dc9e3b7 100644
--- a/paging/common/src/main/java/androidx/paging/WrapperItemKeyedDataSource.java
+++ b/paging/common/src/main/java/androidx/paging/WrapperItemKeyedDataSource.java
@@ -81,6 +81,16 @@
             public void onResult(@NonNull List<A> data) {
                 callback.onResult(convertWithStashedKeys(data));
             }
+
+            @Override
+            public void onError(@NonNull Throwable error) {
+                callback.onError(error);
+            }
+
+            @Override
+            public void onRetryableError(@NonNull Throwable error) {
+                callback.onRetryableError(error);
+            }
         });
     }
 
@@ -92,6 +102,16 @@
             public void onResult(@NonNull List<A> data) {
                 callback.onResult(convertWithStashedKeys(data));
             }
+
+            @Override
+            public void onError(@NonNull Throwable error) {
+                callback.onError(error);
+            }
+
+            @Override
+            public void onRetryableError(@NonNull Throwable error) {
+                callback.onRetryableError(error);
+            }
         });
     }
 
@@ -103,6 +123,16 @@
             public void onResult(@NonNull List<A> data) {
                 callback.onResult(convertWithStashedKeys(data));
             }
+
+            @Override
+            public void onError(@NonNull Throwable error) {
+                callback.onError(error);
+            }
+
+            @Override
+            public void onRetryableError(@NonNull Throwable error) {
+                callback.onRetryableError(error);
+            }
         });
     }
 
diff --git a/paging/common/src/main/java/androidx/paging/WrapperPageKeyedDataSource.java b/paging/common/src/main/java/androidx/paging/WrapperPageKeyedDataSource.java
index 6658df1..0010df5 100644
--- a/paging/common/src/main/java/androidx/paging/WrapperPageKeyedDataSource.java
+++ b/paging/common/src/main/java/androidx/paging/WrapperPageKeyedDataSource.java
@@ -69,6 +69,16 @@
                     @Nullable K nextPageKey) {
                 callback.onResult(convert(mListFunction, data), previousPageKey, nextPageKey);
             }
+
+            @Override
+            public void onError(@NonNull Throwable error) {
+                callback.onError(error);
+            }
+
+            @Override
+            public void onRetryableError(@NonNull Throwable error) {
+                callback.onRetryableError(error);
+            }
         });
     }
 
@@ -80,6 +90,16 @@
             public void onResult(@NonNull List<A> data, @Nullable K adjacentPageKey) {
                 callback.onResult(convert(mListFunction, data), adjacentPageKey);
             }
+
+            @Override
+            public void onError(@NonNull Throwable error) {
+                callback.onError(error);
+            }
+
+            @Override
+            public void onRetryableError(@NonNull Throwable error) {
+                callback.onRetryableError(error);
+            }
         });
     }
 
@@ -91,6 +111,16 @@
             public void onResult(@NonNull List<A> data, @Nullable K adjacentPageKey) {
                 callback.onResult(convert(mListFunction, data), adjacentPageKey);
             }
+
+            @Override
+            public void onError(@NonNull Throwable error) {
+                callback.onError(error);
+            }
+
+            @Override
+            public void onRetryableError(@NonNull Throwable error) {
+                callback.onRetryableError(error);
+            }
         });
     }
 }
diff --git a/paging/common/src/main/java/androidx/paging/WrapperPositionalDataSource.java b/paging/common/src/main/java/androidx/paging/WrapperPositionalDataSource.java
index 3a265d6..012af75 100644
--- a/paging/common/src/main/java/androidx/paging/WrapperPositionalDataSource.java
+++ b/paging/common/src/main/java/androidx/paging/WrapperPositionalDataSource.java
@@ -65,6 +65,16 @@
             public void onResult(@NonNull List<A> data, int position) {
                 callback.onResult(convert(mListFunction, data), position);
             }
+
+            @Override
+            public void onError(@NonNull Throwable error) {
+                callback.onError(error);
+            }
+
+            @Override
+            public void onRetryableError(@NonNull Throwable error) {
+                callback.onRetryableError(error);
+            }
         });
     }
 
@@ -76,6 +86,16 @@
             public void onResult(@NonNull List<A> data) {
                 callback.onResult(convert(mListFunction, data));
             }
+
+            @Override
+            public void onError(@NonNull Throwable error) {
+                callback.onError(error);
+            }
+
+            @Override
+            public void onRetryableError(@NonNull Throwable error) {
+                callback.onRetryableError(error);
+            }
         });
     }
 }
diff --git a/paging/common/src/test/java/androidx/paging/ContiguousPagedListTest.kt b/paging/common/src/test/java/androidx/paging/ContiguousPagedListTest.kt
index d0d596c..5099839 100644
--- a/paging/common/src/test/java/androidx/paging/ContiguousPagedListTest.kt
+++ b/paging/common/src/test/java/androidx/paging/ContiguousPagedListTest.kt
@@ -17,6 +17,9 @@
 package androidx.paging
 
 import androidx.arch.core.util.Function
+import androidx.paging.PagedList.LoadState.LOADING
+import androidx.paging.PagedList.LoadState.IDLE
+import androidx.paging.PagedList.LoadState.RETRYABLE_ERROR
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
@@ -33,7 +36,7 @@
 import java.util.concurrent.Executor
 
 @RunWith(Parameterized::class)
-class ContiguousPagedListTest(private val mCounted: Boolean) {
+class ContiguousPagedListTest(private val placeholdersEnabled: Boolean) {
     private val mMainThread = TestExecutor()
     private val mBackgroundThread = TestExecutor()
 
@@ -58,15 +61,23 @@
             val convertPosition = key ?: 0
             val position = Math.max(0, (convertPosition - initialLoadSize / 2))
             val data = getClampedRange(position, position + initialLoadSize)
-            val trailingUnloadedCount = listData.size - position - data.size
+            if (data != null) {
+                val trailingUnloadedCount = listData.size - position - data.size
 
-            if (enablePlaceholders && mCounted) {
-                receiver.onPageResult(PageResult.INIT,
-                        PageResult(data, position, trailingUnloadedCount, 0))
+                if (enablePlaceholders && placeholdersEnabled) {
+                    receiver.onPageResult(
+                        PageResult.INIT,
+                        PageResult(data, position, trailingUnloadedCount, 0)
+                    )
+                } else {
+                    // still must pass offset, even if not counted
+                    receiver.onPageResult(
+                        PageResult.INIT,
+                        PageResult(data, position)
+                    )
+                }
             } else {
-                // still must pass offset, even if not counted
-                receiver.onPageResult(PageResult.INIT,
-                        PageResult(data, position))
+                receiver.onPageError(PageResult.INIT, Exception(), true)
             }
         }
 
@@ -81,7 +92,11 @@
             val data = getClampedRange(startIndex, startIndex + pageSize)
 
             mainThreadExecutor.execute {
-                receiver.onPageResult(PageResult.APPEND, PageResult(data, 0, 0, 0))
+                if (data != null) {
+                    receiver.onPageResult(PageResult.APPEND, PageResult(data, 0, 0, 0))
+                } else {
+                    receiver.onPageError(PageResult.APPEND, Exception(), true)
+                }
             }
         }
 
@@ -97,7 +112,11 @@
             val data = getClampedRange(startIndex - pageSize + 1, startIndex + 1)
 
             mainThreadExecutor.execute {
-                receiver.onPageResult(PageResult.PREPEND, PageResult(data, 0, 0, 0))
+                if (data != null) {
+                    receiver.onPageResult(PageResult.PREPEND, PageResult(data, 0, 0, 0))
+                } else {
+                    receiver.onPageError(PageResult.PREPEND, Exception(), true)
+                }
             }
         }
 
@@ -105,7 +124,13 @@
             return 0
         }
 
-        private fun getClampedRange(startInc: Int, endExc: Int): List<Item> {
+        private fun getClampedRange(startInc: Int, endExc: Int): List<Item>? {
+            val matching = errorIndices.filter { it in startInc..(endExc - 1) }
+            if (matching.isNotEmpty()) {
+                // found indices with errors enqueued - fail to load them
+                errorIndices.removeAll(matching)
+                return null
+            }
             return listData.subList(Math.max(0, startInc), Math.min(listData.size, endExc))
         }
 
@@ -118,10 +143,37 @@
                 DataSource<Int, ToValue> {
             throw UnsupportedOperationException()
         }
+
+        fun enqueueErrorForIndex(index: Int) {
+            errorIndices.add(index)
+        }
+
+        val errorIndices = mutableListOf<Int>()
+    }
+
+    private fun DataSource<*, Item>.enqueueErrorForIndex(index: Int) {
+        (this as TestSource).enqueueErrorForIndex(index)
+    }
+
+    private fun <E> MutableList<E>.getAllAndClear(): List<E> {
+        val data = this.toList()
+        this.clear()
+        return data
+    }
+
+    private fun <E> PagedList<E>.addLoadStateCapture(desiredType: PagedList.LoadType):
+            MutableList<PagedList.LoadState> {
+        val list = mutableListOf<PagedList.LoadState>()
+        this.addWeakLoadStateListener { type, state, _ ->
+            if (type == desiredType) {
+                list.add(state)
+            }
+        }
+        return list
     }
 
     private fun verifyRange(start: Int, count: Int, actual: PagedStorage<Item>) {
-        if (mCounted) {
+        if (placeholdersEnabled) {
             // assert nulls + content
             val expected = arrayOfNulls<Item>(ITEMS.size)
             System.arraycopy(ITEMS.toTypedArray(), start, expected, start, count)
@@ -160,15 +212,18 @@
         maxSize: Int = PagedList.Config.MAX_SIZE_UNBOUNDED
     ): ContiguousPagedList<Int, Item> {
         return ContiguousPagedList(
-                TestSource(listData), mMainThread, mBackgroundThread, boundaryCallback,
-                PagedList.Config.Builder()
-                        .setPageSize(pageSize)
-                        .setInitialLoadSizeHint(initLoadSize)
-                        .setPrefetchDistance(prefetchDistance)
-                        .setMaxSize(maxSize)
-                        .build(),
-                initialPosition,
-                lastLoad)
+            TestSource(listData),
+            mMainThread,
+            mBackgroundThread,
+            boundaryCallback,
+            PagedList.Config.Builder()
+                .setPageSize(pageSize)
+                .setInitialLoadSizeHint(initLoadSize)
+                .setPrefetchDistance(prefetchDistance)
+                .setMaxSize(maxSize)
+                .build(),
+            initialPosition,
+            lastLoad)
     }
 
     @Test
@@ -204,7 +259,7 @@
         countedPosition: Int,
         uncountedPosition: Int
     ) {
-        if (mCounted) {
+        if (placeholdersEnabled) {
             verify(callback).onChanged(countedPosition, 20)
         } else {
             verify(callback).onInserted(uncountedPosition, 20)
@@ -220,7 +275,7 @@
         countedPosition: Int,
         uncountedPosition: Int
     ) {
-        if (mCounted) {
+        if (placeholdersEnabled) {
             verify(callback).onChanged(countedPosition, 20)
         } else {
             verify(callback).onRemoved(uncountedPosition, 20)
@@ -255,7 +310,7 @@
         verifyRange(60, 40, pagedList)
         verifyZeroInteractions(callback)
 
-        pagedList.loadAround(if (mCounted) 65 else 5)
+        pagedList.loadAround(if (placeholdersEnabled) 65 else 5)
         drain()
 
         verifyRange(40, 60, pagedList)
@@ -271,14 +326,14 @@
         verifyRange(30, 40, pagedList)
         verifyZeroInteractions(callback)
 
-        pagedList.loadAround(if (mCounted) 65 else 35)
+        pagedList.loadAround(if (placeholdersEnabled) 65 else 35)
         drain()
 
         verifyRange(30, 60, pagedList)
         verifyCallback(callback, 70, 40)
         verifyNoMoreInteractions(callback)
 
-        pagedList.loadAround(if (mCounted) 35 else 5)
+        pagedList.loadAround(if (placeholdersEnabled) 35 else 5)
         drain()
 
         verifyRange(10, 80, pagedList)
@@ -312,12 +367,12 @@
         verifyRange(40, 20, pagedList)
 
         // access adjacent to front, shouldn't trigger prefetch
-        pagedList.loadAround(if (mCounted) 41 else 1)
+        pagedList.loadAround(if (placeholdersEnabled) 41 else 1)
         drain()
         verifyRange(40, 20, pagedList)
 
         // access front item, should trigger prefetch
-        pagedList.loadAround(if (mCounted) 40 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 40 else 0)
         drain()
         verifyRange(20, 40, pagedList)
     }
@@ -332,12 +387,12 @@
         verifyRange(40, 20, pagedList)
 
         // access adjacent from end, shouldn't trigger prefetch
-        pagedList.loadAround(if (mCounted) 58 else 18)
+        pagedList.loadAround(if (placeholdersEnabled) 58 else 18)
         drain()
         verifyRange(40, 20, pagedList)
 
         // access end item, should trigger prefetch
-        pagedList.loadAround(if (mCounted) 59 else 19)
+        pagedList.loadAround(if (placeholdersEnabled) 59 else 19)
         drain()
         verifyRange(40, 40, pagedList)
     }
@@ -392,7 +447,7 @@
         verifyZeroInteractions(callback)
 
         // load 4th page
-        pagedList.loadAround(if (mCounted) 80 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 80 else 0)
         drain()
         verifyRange(60, 40, pagedList)
         verifyCallback(callback, 60, 0)
@@ -400,7 +455,7 @@
         reset(callback)
 
         // load 3rd page
-        pagedList.loadAround(if (mCounted) 60 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 60 else 0)
         drain()
         verifyRange(40, 60, pagedList)
         verifyCallback(callback, 40, 0)
@@ -408,7 +463,7 @@
         reset(callback)
 
         // load 2nd page, drop 5th
-        pagedList.loadAround(if (mCounted) 40 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 40 else 0)
         drain()
         verifyRange(20, 60, pagedList)
         verifyCallback(callback, 20, 0)
@@ -427,27 +482,28 @@
                 maxSize = 3)
 
         // load 3 pages - 2nd, 3rd, 4th
-        pagedList.loadAround(if (mCounted) 2 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 2 else 0)
         drain()
+        verifyRange(1, 3, pagedList)
 
         val callback = mock(PagedList.Callback::class.java)
         pagedList.addWeakCallback(null, callback)
 
         // start a load at the beginning...
-        pagedList.loadAround(if (mCounted) 1 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 1 else 0)
 
         mBackgroundThread.executeAll()
 
         // but before page received, access near end of list
-        pagedList.loadAround(if (mCounted) 3 else 2)
+        pagedList.loadAround(if (placeholdersEnabled) 3 else 2)
         verifyZeroInteractions(callback)
         mMainThread.executeAll()
-        // and the load at the end is dropped without signaling callback
+        // and the load at the beginning is dropped without signaling callback
         verifyNoMoreInteractions(callback)
         verifyRange(1, 3, pagedList)
 
         drain()
-        if (mCounted) {
+        if (placeholdersEnabled) {
             verify(callback).onChanged(4, 1)
             verify(callback).onChanged(1, 1)
         } else {
@@ -468,19 +524,19 @@
                 maxSize = 3)
 
         // load 3 pages - 2nd, 3rd, 4th
-        pagedList.loadAround(if (mCounted) 2 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 2 else 0)
         drain()
 
         val callback = mock(PagedList.Callback::class.java)
         pagedList.addWeakCallback(null, callback)
 
         // start a load at the end...
-        pagedList.loadAround(if (mCounted) 3 else 2)
+        pagedList.loadAround(if (placeholdersEnabled) 3 else 2)
 
         mBackgroundThread.executeAll()
 
         // but before page received, access near front of list
-        pagedList.loadAround(if (mCounted) 1 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 1 else 0)
         verifyZeroInteractions(callback)
         mMainThread.executeAll()
         // and the load at the end is dropped without signaling callback
@@ -488,7 +544,7 @@
         verifyRange(1, 3, pagedList)
 
         drain()
-        if (mCounted) {
+        if (placeholdersEnabled) {
             verify(callback).onChanged(0, 1)
             verify(callback).onChanged(3, 1)
         } else {
@@ -499,6 +555,130 @@
     }
 
     @Test
+    fun loadingListenerAppend() {
+        val pagedList = createCountedPagedList(0)
+        val states = pagedList.addLoadStateCapture(PagedList.LoadType.END)
+
+        // No loading going on currently
+        assertEquals(listOf(IDLE), states.getAllAndClear())
+        verifyRange(0, 40, pagedList)
+
+        // trigger load
+        pagedList.loadAround(35)
+        mMainThread.executeAll()
+        assertEquals(listOf(LOADING), states.getAllAndClear())
+        verifyRange(0, 40, pagedList)
+
+        // load finishes
+        drain()
+        assertEquals(listOf(IDLE), states.getAllAndClear())
+        verifyRange(0, 60, pagedList)
+
+        pagedList.dataSource.enqueueErrorForIndex(65)
+
+        // trigger load which will error
+        pagedList.loadAround(55)
+        mMainThread.executeAll()
+        assertEquals(listOf(LOADING), states.getAllAndClear())
+        verifyRange(0, 60, pagedList)
+
+        // load now in error state
+        drain()
+        assertEquals(listOf(RETRYABLE_ERROR), states.getAllAndClear())
+        verifyRange(0, 60, pagedList)
+
+        // retry
+        pagedList.retry()
+        mMainThread.executeAll()
+        assertEquals(listOf(LOADING), states.getAllAndClear())
+
+        // load finishes
+        drain()
+        assertEquals(listOf(IDLE), states.getAllAndClear())
+        verifyRange(0, 80, pagedList)
+    }
+
+    @Test
+    fun pageDropCancelPrependError() {
+        // verify a prepend in error state can be dropped
+        val pagedList = createCountedPagedList(
+            initialPosition = 2,
+            pageSize = 1,
+            initLoadSize = 1,
+            prefetchDistance = 1,
+            maxSize = 3)
+        val states = pagedList.addLoadStateCapture(PagedList.LoadType.START)
+
+        // load 3 pages - 2nd, 3rd, 4th
+        pagedList.loadAround(if (placeholdersEnabled) 2 else 0)
+        drain()
+        verifyRange(1, 3, pagedList)
+        assertEquals(listOf(IDLE, LOADING, IDLE), states.getAllAndClear())
+
+        // start a load at the beginning, which will fail
+        pagedList.dataSource.enqueueErrorForIndex(0)
+        pagedList.loadAround(if (placeholdersEnabled) 1 else 0)
+        drain()
+        verifyRange(1, 3, pagedList)
+        assertEquals(listOf(LOADING, RETRYABLE_ERROR), states.getAllAndClear())
+
+        // but without that failure being retried, access near end of list, which drops the error
+        pagedList.loadAround(if (placeholdersEnabled) 3 else 2)
+        drain()
+        assertEquals(listOf(IDLE), states.getAllAndClear())
+        verifyRange(2, 3, pagedList)
+    }
+
+    @Test
+    fun pageDropCancelAppendError() {
+        // verify an append in error state can be dropped
+        val pagedList = createCountedPagedList(
+            initialPosition = 2,
+            pageSize = 1,
+            initLoadSize = 1,
+            prefetchDistance = 1,
+            maxSize = 3)
+        val states = pagedList.addLoadStateCapture(PagedList.LoadType.END)
+
+        // load 3 pages - 2nd, 3rd, 4th
+        pagedList.loadAround(if (placeholdersEnabled) 2 else 0)
+        drain()
+        verifyRange(1, 3, pagedList)
+        assertEquals(listOf(IDLE, LOADING, IDLE), states.getAllAndClear())
+
+        // start a load at the end, which will fail
+        pagedList.dataSource.enqueueErrorForIndex(4)
+        pagedList.loadAround(if (placeholdersEnabled) 3 else 2)
+        drain()
+        verifyRange(1, 3, pagedList)
+        assertEquals(listOf(LOADING, RETRYABLE_ERROR), states.getAllAndClear())
+
+        // but without that failure being retried, access near start of list, which drops the error
+        pagedList.loadAround(if (placeholdersEnabled) 1 else 0)
+        drain()
+        assertEquals(listOf(IDLE), states.getAllAndClear())
+        verifyRange(0, 3, pagedList)
+    }
+
+    @Test
+    fun errorIntoDrop() {
+        // have an error, move loading range, error goes away
+        val pagedList = createCountedPagedList(0)
+        val states = mutableListOf<PagedList.LoadState>()
+        pagedList.addWeakLoadStateListener { type, state, _ ->
+            if (type == PagedList.LoadType.END) {
+                states.add(state)
+            }
+        }
+
+        pagedList.dataSource.enqueueErrorForIndex(45)
+        pagedList.loadAround(35)
+        drain()
+        assertEquals(listOf(IDLE, LOADING, RETRYABLE_ERROR), states.getAllAndClear())
+        verifyRange(0, 40, pagedList)
+    }
+
+    @Test
     fun distantPrefetch() {
         val pagedList = createCountedPagedList(0,
                 initLoadSize = 10, pageSize = 10, prefetchDistance = 30)
@@ -550,7 +730,7 @@
         val pagedList = createCountedPagedList(80)
         verifyRange(60, 40, pagedList)
 
-        pagedList.loadAround(if (mCounted) 65 else 5)
+        pagedList.loadAround(if (placeholdersEnabled) 65 else 5)
         drain()
         verifyRange(40, 60, pagedList)
 
@@ -558,7 +738,7 @@
         val snapshot = pagedList.snapshot() as PagedList<Item>
         verifyRange(40, 60, snapshot)
 
-        pagedList.loadAround(if (mCounted) 45 else 5)
+        pagedList.loadAround(if (placeholdersEnabled) 45 else 5)
         drain()
         verifyRange(20, 80, pagedList)
         verifyRange(40, 60, snapshot)
@@ -707,24 +887,24 @@
         verifyZeroInteractions(boundaryCallback)
 
         // loading around last item causes onItemAtEndLoaded
-        pagedList.loadAround(if (mCounted) 99 else 19)
+        pagedList.loadAround(if (placeholdersEnabled) 99 else 19)
         drain()
         verifyRange(80, 20, pagedList)
         verify(boundaryCallback).onItemAtEndLoaded(ITEMS.last())
         verifyNoMoreInteractions(boundaryCallback)
 
         // prepending doesn't trigger callback...
-        pagedList.loadAround(if (mCounted) 80 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 80 else 0)
         drain()
         verifyRange(60, 40, pagedList)
         verifyZeroInteractions(boundaryCallback)
 
         // ...load rest of data, still no dispatch...
-        pagedList.loadAround(if (mCounted) 60 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 60 else 0)
         drain()
-        pagedList.loadAround(if (mCounted) 40 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 40 else 0)
         drain()
-        pagedList.loadAround(if (mCounted) 20 else 0)
+        pagedList.loadAround(if (placeholdersEnabled) 20 else 0)
         drain()
         verifyRange(0, 100, pagedList)
         verifyZeroInteractions(boundaryCallback)
diff --git a/paging/common/src/test/java/androidx/paging/ItemKeyedDataSourceTest.kt b/paging/common/src/test/java/androidx/paging/ItemKeyedDataSourceTest.kt
index bd03782..3f6efdbf 100644
--- a/paging/common/src/test/java/androidx/paging/ItemKeyedDataSourceTest.kt
+++ b/paging/common/src/test/java/androidx/paging/ItemKeyedDataSourceTest.kt
@@ -34,8 +34,12 @@
 
     // ----- STANDARD -----
 
-    private fun loadInitial(dataSource: ItemDataSource, key: Key?, initialLoadSize: Int,
-            enablePlaceholders: Boolean): PageResult<Item> {
+    private fun loadInitial(
+        dataSource: ItemDataSource,
+        key: Key?,
+        initialLoadSize: Int,
+        enablePlaceholders: Boolean
+    ): PageResult<Item> {
         @Suppress("UNCHECKED_CAST")
         val receiver = mock(PageResult.Receiver::class.java) as PageResult.Receiver<Item>
         @Suppress("UNCHECKED_CAST")
@@ -204,15 +208,28 @@
     internal data class Key(val name: String, val id: Int)
 
     internal data class Item(
-            val name: String, val id: Int, val balance: Double, val address: String)
+        val name: String,
+        val id: Int,
+        val balance: Double,
+        val address: String
+    )
 
-    internal class ItemDataSource(private val counted: Boolean = true,
-                                  private val items: List<Item> = ITEMS_BY_NAME_ID)
-            : ItemKeyedDataSource<Key, Item>() {
+    internal class ItemDataSource(
+        private val counted: Boolean = true,
+        private val items: List<Item> = ITEMS_BY_NAME_ID
+    ) : ItemKeyedDataSource<Key, Item>() {
+        private var error = false
 
         override fun loadInitial(
-                params: LoadInitialParams<Key>,
-                callback: LoadInitialCallback<Item>) {
+            params: LoadInitialParams<Key>,
+            callback: LoadInitialCallback<Item>
+        ) {
+            if (error) {
+                callback.onError(EXCEPTION)
+                error = false
+                return
+            }
+
             val key = params.requestedInitialKey ?: Key("", Integer.MAX_VALUE)
             val start = Math.max(0, findFirstIndexAfter(key) - params.requestedLoadSize / 2)
             val endExclusive = Math.min(start + params.requestedLoadSize, items.size)
@@ -225,6 +242,12 @@
         }
 
         override fun loadAfter(params: LoadParams<Key>, callback: LoadCallback<Item>) {
+            if (error) {
+                callback.onError(EXCEPTION)
+                error = false
+                return
+            }
+
             val start = findFirstIndexAfter(params.key)
             val endExclusive = Math.min(start + params.requestedLoadSize, items.size)
 
@@ -232,6 +255,12 @@
         }
 
         override fun loadBefore(params: LoadParams<Key>, callback: LoadCallback<Item>) {
+            if (error) {
+                callback.onError(EXCEPTION)
+                error = false
+                return
+            }
+
             val firstIndexBefore = findFirstIndexBefore(params.key)
             val endExclusive = Math.max(0, firstIndexBefore + 1)
             val start = Math.max(0, firstIndexBefore - params.requestedLoadSize + 1)
@@ -254,19 +283,25 @@
                 KEY_COMPARATOR.compare(key, getKey(items[it])) > 0
             } ?: -1
         }
+
+        fun enqueueError() {
+            error = true
+        }
     }
 
     private fun performLoadInitial(
-            invalidateDataSource: Boolean = false,
-            callbackInvoker: (callback: ItemKeyedDataSource.LoadInitialCallback<String>) -> Unit) {
+        invalidateDataSource: Boolean = false,
+        callbackInvoker: (callback: ItemKeyedDataSource.LoadInitialCallback<String>) -> Unit
+    ) {
         val dataSource = object : ItemKeyedDataSource<String, String>() {
             override fun getKey(item: String): String {
                 return ""
             }
 
             override fun loadInitial(
-                    params: LoadInitialParams<String>,
-                    callback: LoadInitialCallback<String>) {
+                params: LoadInitialParams<String>,
+                callback: LoadInitialCallback<String>
+            ) {
                 if (invalidateDataSource) {
                     // invalidate data source so it's invalid when onResult() called
                     invalidate()
@@ -301,7 +336,7 @@
     @Test
     fun loadInitialCallbackNotPageSizeMultiple() = performLoadInitial {
         // Keyed LoadInitialCallback *can* accept result that's not a multiple of page size
-        val elevenLetterList = List(11) { "" + 'a' + it }
+        val elevenLetterList = List(11) { index -> "" + ('a' + index) }
         it.onResult(elevenLetterList, 0, 12)
     }
 
@@ -362,6 +397,14 @@
                 override fun onResult(data: MutableList<A>) {
                     callback.onResult(convert(data))
                 }
+
+                override fun onError(error: Throwable) {
+                    callback.onError(error)
+                }
+
+                override fun onRetryableError(error: Throwable) {
+                    callback.onRetryableError(error)
+                }
             })
         }
 
@@ -370,6 +413,14 @@
                 override fun onResult(data: MutableList<A>) {
                     callback.onResult(convert(data))
                 }
+
+                override fun onError(error: Throwable) {
+                    callback.onError(error)
+                }
+
+                override fun onRetryableError(error: Throwable) {
+                    callback.onRetryableError(error)
+                }
             })
         }
 
@@ -378,6 +429,14 @@
                 override fun onResult(data: MutableList<A>) {
                     callback.onResult(convert(data))
                 }
+
+                override fun onError(error: Throwable) {
+                    callback.onError(error)
+                }
+
+                override fun onRetryableError(error: Throwable) {
+                    callback.onRetryableError(error)
+                }
             })
         }
 
@@ -397,36 +456,53 @@
         }
     }
 
-    private fun verifyWrappedDataSource(createWrapper:
-            (ItemKeyedDataSource<Key, Item>) -> ItemKeyedDataSource<Key, DecoratedItem>) {
+    private fun verifyWrappedDataSource(
+        createWrapper: (ItemKeyedDataSource<Key, Item>) -> ItemKeyedDataSource<Key, DecoratedItem>
+    ) {
         // verify that it's possible to wrap an ItemKeyedDataSource, and add info to its data
 
         val orig = ItemDataSource(items = ITEMS_BY_NAME_ID)
         val wrapper = createWrapper(orig)
 
-        // load initial
+        // load initial - success
         @Suppress("UNCHECKED_CAST")
         val loadInitialCallback = mock(ItemKeyedDataSource.LoadInitialCallback::class.java)
                 as ItemKeyedDataSource.LoadInitialCallback<DecoratedItem>
         val initKey = orig.getKey(ITEMS_BY_NAME_ID.first())
-        wrapper.loadInitial(ItemKeyedDataSource.LoadInitialParams(initKey, 10, false),
+        val initParams = ItemKeyedDataSource.LoadInitialParams(initKey, 10, false)
+        wrapper.loadInitial(initParams,
                 loadInitialCallback)
         verify(loadInitialCallback).onResult(
                 ITEMS_BY_NAME_ID.subList(0, 10).map { DecoratedItem(it) })
+        //     error
+        orig.enqueueError()
+        wrapper.loadInitial(initParams, loadInitialCallback)
+        verify(loadInitialCallback).onError(EXCEPTION)
         verifyNoMoreInteractions(loadInitialCallback)
 
-        @Suppress("UNCHECKED_CAST")
-        val loadCallback = mock(ItemKeyedDataSource.LoadCallback::class.java)
-                as ItemKeyedDataSource.LoadCallback<DecoratedItem>
         val key = orig.getKey(ITEMS_BY_NAME_ID[20])
+        @Suppress("UNCHECKED_CAST")
+        var loadCallback = mock(ItemKeyedDataSource.LoadCallback::class.java)
+                as ItemKeyedDataSource.LoadCallback<DecoratedItem>
         // load after
         wrapper.loadAfter(ItemKeyedDataSource.LoadParams(key, 10), loadCallback)
         verify(loadCallback).onResult(ITEMS_BY_NAME_ID.subList(21, 31).map { DecoratedItem(it) })
+        // load after - error
+        orig.enqueueError()
+        wrapper.loadAfter(ItemKeyedDataSource.LoadParams(key, 10), loadCallback)
+        verify(loadCallback).onError(EXCEPTION)
         verifyNoMoreInteractions(loadCallback)
 
         // load before
+        @Suppress("UNCHECKED_CAST")
+        loadCallback = mock(ItemKeyedDataSource.LoadCallback::class.java)
+                as ItemKeyedDataSource.LoadCallback<DecoratedItem>
         wrapper.loadBefore(ItemKeyedDataSource.LoadParams(key, 10), loadCallback)
         verify(loadCallback).onResult(ITEMS_BY_NAME_ID.subList(10, 20).map { DecoratedItem(it) })
+        // load before - error
+        orig.enqueueError()
+        wrapper.loadBefore(ItemKeyedDataSource.LoadParams(key, 10), loadCallback)
+        verify(loadCallback).onError(EXCEPTION)
         verifyNoMoreInteractions(loadCallback)
 
         // verify invalidation
@@ -440,13 +516,13 @@
     }
 
     @Test
-    fun testListConverterWrappedDataSource() = verifyWrappedDataSource {
-        it.mapByPage { it.map { DecoratedItem(it) } }
+    fun testListConverterWrappedDataSource() = verifyWrappedDataSource { dataSource ->
+        dataSource.mapByPage { page -> page.map { DecoratedItem(it) } }
     }
 
     @Test
-    fun testItemConverterWrappedDataSource() = verifyWrappedDataSource {
-        it.map { DecoratedItem(it) }
+    fun testItemConverterWrappedDataSource() = verifyWrappedDataSource { dataSource ->
+        dataSource.map { DecoratedItem(it) }
     }
 
     @Test
@@ -468,15 +544,17 @@
     }
 
     companion object {
-        private val ITEM_COMPARATOR = compareBy<Item>({ it.name }).thenByDescending({ it.id })
-        private val KEY_COMPARATOR = compareBy<Key>({ it.name }).thenByDescending({ it.id })
+        private val ITEM_COMPARATOR = compareBy<Item> { it.name }.thenByDescending { it.id }
+        private val KEY_COMPARATOR = compareBy<Key> { it.name }.thenByDescending { it.id }
 
         private val ITEMS_BY_NAME_ID = List(100) {
-            val names = Array(10) { "f" + ('a' + it) }
+            val names = Array(10) { index -> "f" + ('a' + index) }
             Item(names[it % 10],
                     it,
                     Math.random() * 1000,
                     (Math.random() * 200).toInt().toString() + " fake st.")
         }.sortedWith(ITEM_COMPARATOR)
+
+        private val EXCEPTION = Exception()
     }
 }
diff --git a/paging/common/src/test/java/androidx/paging/PageKeyedDataSourceTest.kt b/paging/common/src/test/java/androidx/paging/PageKeyedDataSourceTest.kt
index e6f0caa5..b7cafe7 100644
--- a/paging/common/src/test/java/androidx/paging/PageKeyedDataSourceTest.kt
+++ b/paging/common/src/test/java/androidx/paging/PageKeyedDataSourceTest.kt
@@ -39,6 +39,7 @@
 
     internal class ItemDataSource(val data: Map<String, Page> = PAGE_MAP)
             : PageKeyedDataSource<String, Item>() {
+        private var error = false
 
         private fun getPage(key: String): Page = data[key]!!
 
@@ -46,19 +47,41 @@
             params: LoadInitialParams<String>,
             callback: LoadInitialCallback<String, Item>
         ) {
+            if (error) {
+                callback.onRetryableError(EXCEPTION)
+                error = false
+                return
+            }
+
             val page = getPage(INIT_KEY)
             callback.onResult(page.data, page.prev, page.next)
         }
 
         override fun loadBefore(params: LoadParams<String>, callback: LoadCallback<String, Item>) {
+            if (error) {
+                callback.onRetryableError(EXCEPTION)
+                error = false
+                return
+            }
+
             val page = getPage(params.key)
             callback.onResult(page.data, page.prev)
         }
 
         override fun loadAfter(params: LoadParams<String>, callback: LoadCallback<String, Item>) {
+            if (error) {
+                callback.onRetryableError(EXCEPTION)
+                error = false
+                return
+            }
+
             val page = getPage(params.key)
             callback.onResult(page.data, page.next)
         }
+
+        fun enqueueError() {
+            error = true
+        }
     }
 
     @Test
@@ -133,7 +156,7 @@
     @Test
     fun loadInitialCallbackNotPageSizeMultiple() = performLoadInitial {
         // Keyed LoadInitialCallback *can* accept result that's not a multiple of page size
-        val elevenLetterList = List(11) { "" + 'a' + it }
+        val elevenLetterList = List(11) { index -> "" + ('a' + index) }
         it.onResult(elevenLetterList, 0, 12, null, null)
     }
 
@@ -172,6 +195,7 @@
         assertFalse(ItemDataSource().supportsPageDropping())
     }
 
+    @Test
     fun testBoundaryCallback() {
         val dataSource = object : PageKeyedDataSource<String, String>() {
             override fun loadInitial(
@@ -313,6 +337,14 @@
                 override fun onResult(data: MutableList<A>, previousPageKey: K?, nextPageKey: K?) {
                     callback.onResult(convert(data), previousPageKey, nextPageKey)
                 }
+
+                override fun onError(error: Throwable) {
+                    callback.onError(error)
+                }
+
+                override fun onRetryableError(error: Throwable) {
+                    callback.onRetryableError(error)
+                }
             })
         }
 
@@ -321,6 +353,14 @@
                 override fun onResult(data: List<A>, adjacentPageKey: K?) {
                     callback.onResult(convert(data), adjacentPageKey)
                 }
+
+                override fun onError(error: Throwable) {
+                    callback.onError(error)
+                }
+
+                override fun onRetryableError(error: Throwable) {
+                    callback.onRetryableError(error)
+                }
             })
         }
 
@@ -329,6 +369,14 @@
                 override fun onResult(data: List<A>, adjacentPageKey: K?) {
                     callback.onResult(convert(data), adjacentPageKey)
                 }
+
+                override fun onError(error: Throwable) {
+                    callback.onError(error)
+                }
+
+                override fun onRetryableError(error: Throwable) {
+                    callback.onRetryableError(error)
+                }
             })
         }
 
@@ -354,28 +402,39 @@
         val loadInitialCallback = mock(PageKeyedDataSource.LoadInitialCallback::class.java)
                 as PageKeyedDataSource.LoadInitialCallback<String, String>
 
-        wrapper.loadInitial(PageKeyedDataSource.LoadInitialParams<String>(4, true),
-                loadInitialCallback)
-        val expectedInitial = PAGE_MAP.get(INIT_KEY)!!
+        val initParams = PageKeyedDataSource.LoadInitialParams<String>(4, true)
+        wrapper.loadInitial(initParams, loadInitialCallback)
+        val expectedInitial = PAGE_MAP[INIT_KEY]!!
         verify(loadInitialCallback).onResult(expectedInitial.data.map { it.toString() },
                 expectedInitial.prev, expectedInitial.next)
         verifyNoMoreInteractions(loadInitialCallback)
 
         @Suppress("UNCHECKED_CAST")
-        val loadCallback = mock(PageKeyedDataSource.LoadCallback::class.java)
-                as PageKeyedDataSource.LoadCallback<String, String>
         // load after
+        var loadCallback = mock(PageKeyedDataSource.LoadCallback::class.java)
+                as PageKeyedDataSource.LoadCallback<String, String>
         wrapper.loadAfter(PageKeyedDataSource.LoadParams(expectedInitial.next!!, 4), loadCallback)
-        val expectedAfter = PAGE_MAP.get(expectedInitial.next)!!
-        verify(loadCallback).onResult(expectedAfter.data.map { it.toString() },
-                expectedAfter.next)
+        val expectedAfter = PAGE_MAP[expectedInitial.next]!!
+        verify(loadCallback).onResult(expectedAfter.data.map { it.toString() }, expectedAfter.next)
+        // load after - error
+        orig.enqueueError()
+        wrapper.loadAfter(PageKeyedDataSource.LoadParams(expectedInitial.next, 4), loadCallback)
+        verify(loadCallback).onRetryableError(EXCEPTION)
         verifyNoMoreInteractions(loadCallback)
 
         // load before
+        @Suppress("UNCHECKED_CAST")
+        loadCallback = mock(PageKeyedDataSource.LoadCallback::class.java)
+                as PageKeyedDataSource.LoadCallback<String, String>
         wrapper.loadBefore(PageKeyedDataSource.LoadParams(expectedAfter.prev!!, 4), loadCallback)
         verify(loadCallback).onResult(expectedInitial.data.map { it.toString() },
                 expectedInitial.prev)
         verifyNoMoreInteractions(loadCallback)
+        // load before - error
+        orig.enqueueError()
+        wrapper.loadBefore(PageKeyedDataSource.LoadParams(expectedAfter.prev, 4), loadCallback)
+        verify(loadCallback).onRetryableError(EXCEPTION)
+        verifyNoMoreInteractions(loadCallback)
 
         // verify invalidation
         orig.invalidate()
@@ -388,13 +447,13 @@
     }
 
     @Test
-    fun testListConverterWrappedDataSource() = verifyWrappedDataSource {
-        it.mapByPage { it.map { it.toString() } }
+    fun testListConverterWrappedDataSource() = verifyWrappedDataSource { dataSource ->
+        dataSource.mapByPage { page -> page.map { it.toString() } }
     }
 
     @Test
-    fun testItemConverterWrappedDataSource() = verifyWrappedDataSource {
-        it.map { it.toString() }
+    fun testItemConverterWrappedDataSource() = verifyWrappedDataSource { dataSource ->
+        dataSource.map { it.toString() }
     }
 
     @Test
@@ -420,6 +479,7 @@
         private val INIT_KEY: String = "key 2"
         private val PAGE_MAP: Map<String, Page>
         private val ITEM_LIST: List<Item>
+        private val EXCEPTION = Exception()
 
         init {
             val map = HashMap<String, Page>()
@@ -432,7 +492,7 @@
                 val key = "key $i"
                 val prev = if (i > 1) ("key " + (i - 1)) else null
                 val next = if (i < pageCount) ("key " + (i + 1)) else null
-                map.put(key, Page(prev, data, next))
+                map[key] = Page(prev, data, next)
             }
             PAGE_MAP = map
             ITEM_LIST = list
diff --git a/paging/common/src/test/java/androidx/paging/PositionalDataSourceTest.kt b/paging/common/src/test/java/androidx/paging/PositionalDataSourceTest.kt
index f18b41a..308d378 100644
--- a/paging/common/src/test/java/androidx/paging/PositionalDataSourceTest.kt
+++ b/paging/common/src/test/java/androidx/paging/PositionalDataSourceTest.kt
@@ -29,12 +29,14 @@
 @RunWith(JUnit4::class)
 class PositionalDataSourceTest {
     private fun computeInitialLoadPos(
-            requestedStartPosition: Int,
-            requestedLoadSize: Int,
-            pageSize: Int,
-            totalCount: Int): Int {
+        requestedStartPosition: Int,
+        requestedLoadSize: Int,
+        pageSize: Int,
+        totalCount: Int
+    ): Int {
         val params = PositionalDataSource.LoadInitialParams(
-                requestedStartPosition, requestedLoadSize, pageSize, true)
+            requestedStartPosition, requestedLoadSize, pageSize, true
+        )
         return PositionalDataSource.computeInitialLoadPosition(params, totalCount)
     }
 
@@ -113,13 +115,15 @@
     }
 
     private fun performLoadInitial(
-            enablePlaceholders: Boolean = true,
-            invalidateDataSource: Boolean = false,
-            callbackInvoker: (callback: PositionalDataSource.LoadInitialCallback<String>) -> Unit) {
+        enablePlaceholders: Boolean = true,
+        invalidateDataSource: Boolean = false,
+        callbackInvoker: (callback: PositionalDataSource.LoadInitialCallback<String>) -> Unit
+    ) {
         val dataSource = object : PositionalDataSource<String>() {
             override fun loadInitial(
-                    params: LoadInitialParams,
-                    callback: LoadInitialCallback<String>) {
+                params: LoadInitialParams,
+                callback: LoadInitialCallback<String>
+            ) {
                 if (invalidateDataSource) {
                     // invalidate data source so it's invalid when onResult() called
                     invalidate()
@@ -154,7 +158,7 @@
     @Test(expected = IllegalArgumentException::class)
     fun initialLoadCallbackNotPageSizeMultiple() = performLoadInitial {
         // Positional LoadInitialCallback can't accept result that's not a multiple of page size
-        val elevenLetterList = List(11) { "" + 'a' + it }
+        val elevenLetterList = List(11) { index -> "" + ('a' + index) }
         it.onResult(elevenLetterList, 0, 12)
     }
 
@@ -245,6 +249,14 @@
                 override fun onResult(data: List<A>, position: Int) {
                     callback.onResult(convert(data), position)
                 }
+
+                override fun onError(error: Throwable) {
+                    callback.onError(error)
+                }
+
+                override fun onRetryableError(error: Throwable) {
+                    callback.onRetryableError(error)
+                }
             })
         }
 
@@ -253,6 +265,14 @@
                 override fun onResult(data: List<A>) {
                     callback.onResult(convert(data))
                 }
+
+                override fun onError(error: Throwable) {
+                    callback.onError(error)
+                }
+
+                override fun onRetryableError(error: Throwable) {
+                    callback.onRetryableError(error)
+                }
             })
         }
 
@@ -266,8 +286,54 @@
         }
     }
 
+    class ListDataSource<T>(val list: List<T>) : PositionalDataSource<T>() {
+        private var error = false
+
+        override fun loadInitial(
+            params: PositionalDataSource.LoadInitialParams,
+            callback: PositionalDataSource.LoadInitialCallback<T>
+        ) {
+            if (error) {
+                callback.onError(ERROR)
+                error = false
+                return
+            }
+            val totalCount = list.size
+
+            val position = PositionalDataSource.computeInitialLoadPosition(params, totalCount)
+            val loadSize = PositionalDataSource.computeInitialLoadSize(params, position, totalCount)
+
+            // for simplicity, we could return everything immediately,
+            // but we tile here since it's expected behavior
+            val sublist = list.subList(position, position + loadSize)
+            callback.onResult(sublist, position, totalCount)
+        }
+
+        override fun loadRange(
+            params: PositionalDataSource.LoadRangeParams,
+            callback: PositionalDataSource.LoadRangeCallback<T>
+        ) {
+            if (error) {
+                callback.onError(ERROR)
+                error = false
+                return
+            }
+            callback.onResult(
+                list.subList(
+                    params.startPosition,
+                    params.startPosition + params.loadSize
+                )
+            )
+        }
+
+        fun enqueueError() {
+            error = true
+        }
+    }
+
     private fun verifyWrappedDataSource(
-            createWrapper: (PositionalDataSource<Int>) -> PositionalDataSource<String>) {
+        createWrapper: (PositionalDataSource<Int>) -> PositionalDataSource<String>
+    ) {
         val orig = ListDataSource(listOf(0, 5, 4, 8, 12))
         val wrapper = createWrapper(orig)
 
@@ -275,19 +341,25 @@
         @Suppress("UNCHECKED_CAST")
         val loadInitialCallback = mock(PositionalDataSource.LoadInitialCallback::class.java)
                 as PositionalDataSource.LoadInitialCallback<String>
-
-        wrapper.loadInitial(PositionalDataSource.LoadInitialParams(0, 2, 1, true),
-                loadInitialCallback)
+        val initParams = PositionalDataSource.LoadInitialParams(0, 2, 1, true)
+        wrapper.loadInitial(initParams, loadInitialCallback)
         verify(loadInitialCallback).onResult(listOf("0", "5"), 0, 5)
+        // load initial - error
+        orig.enqueueError()
+        wrapper.loadInitial(initParams, loadInitialCallback)
+        verify(loadInitialCallback).onError(ERROR)
         verifyNoMoreInteractions(loadInitialCallback)
 
         // load range
         @Suppress("UNCHECKED_CAST")
         val loadRangeCallback = mock(PositionalDataSource.LoadRangeCallback::class.java)
                 as PositionalDataSource.LoadRangeCallback<String>
-
         wrapper.loadRange(PositionalDataSource.LoadRangeParams(2, 3), loadRangeCallback)
         verify(loadRangeCallback).onResult(listOf("4", "8", "12"))
+        // load range - error
+        orig.enqueueError()
+        wrapper.loadRange(PositionalDataSource.LoadRangeParams(2, 3), loadRangeCallback)
+        verify(loadRangeCallback).onError(ERROR)
         verifyNoMoreInteractions(loadRangeCallback)
 
         // check invalidation behavior
@@ -308,13 +380,13 @@
     }
 
     @Test
-    fun testListConverterWrappedDataSource() = verifyWrappedDataSource {
-        it.mapByPage { it.map { it.toString() } }
+    fun testListConverterWrappedDataSource() = verifyWrappedDataSource { dataSource ->
+        dataSource.mapByPage { page -> page.map { it.toString() } }
     }
 
     @Test
-    fun testItemConverterWrappedDataSource() = verifyWrappedDataSource {
-        it.map { it.toString() }
+    fun testItemConverterWrappedDataSource() = verifyWrappedDataSource { dataSource ->
+        dataSource.map { it.toString() }
     }
 
     @Test
@@ -352,4 +424,8 @@
         wrapper.invalidate()
         assertTrue(orig.isInvalid)
     }
+
+    companion object {
+        private val ERROR = Exception()
+    }
 }
diff --git a/paging/runtime/api/2.2.0-alpha01.txt b/paging/runtime/api/2.2.0-alpha01.txt
new file mode 100644
index 0000000..889c9bf
--- /dev/null
+++ b/paging/runtime/api/2.2.0-alpha01.txt
@@ -0,0 +1,47 @@
+// Signature format: 2.0
+package androidx.paging {
+
+  public class AsyncPagedListDiffer<T> {
+    ctor public AsyncPagedListDiffer(androidx.recyclerview.widget.RecyclerView.Adapter, androidx.recyclerview.widget.DiffUtil.ItemCallback<T>);
+    ctor public AsyncPagedListDiffer(androidx.recyclerview.widget.ListUpdateCallback, androidx.recyclerview.widget.AsyncDifferConfig<T>);
+    method public void addLoadStateListener(androidx.paging.PagedList.LoadStateListener);
+    method public void addPagedListListener(androidx.paging.AsyncPagedListDiffer.PagedListListener<T>);
+    method public androidx.paging.PagedList<T>? getCurrentList();
+    method public T? getItem(int);
+    method public int getItemCount();
+    method public void removeLoadStateListListener(androidx.paging.PagedList.LoadStateListener);
+    method public void removePagedListListener(androidx.paging.AsyncPagedListDiffer.PagedListListener<T>);
+    method public void submitList(androidx.paging.PagedList<T>?);
+    method public void submitList(androidx.paging.PagedList<T>?, Runnable?);
+  }
+
+  public static interface AsyncPagedListDiffer.PagedListListener<T> {
+    method public void onCurrentListChanged(androidx.paging.PagedList<T>?, androidx.paging.PagedList<T>?);
+  }
+
+  public final class LivePagedListBuilder<Key, Value> {
+    ctor public LivePagedListBuilder(androidx.paging.DataSource.Factory<Key,Value>, androidx.paging.PagedList.Config);
+    ctor public LivePagedListBuilder(androidx.paging.DataSource.Factory<Key,Value>, int);
+    method public androidx.lifecycle.LiveData<androidx.paging.PagedList<Value>> build();
+    method public androidx.paging.LivePagedListBuilder<Key,Value> setBoundaryCallback(androidx.paging.PagedList.BoundaryCallback<Value>?);
+    method public androidx.paging.LivePagedListBuilder<Key,Value> setFetchExecutor(java.util.concurrent.Executor);
+    method public androidx.paging.LivePagedListBuilder<Key,Value> setInitialLoadKey(Key?);
+  }
+
+  public abstract class PagedListAdapter<T, VH extends androidx.recyclerview.widget.RecyclerView.ViewHolder> extends androidx.recyclerview.widget.RecyclerView.Adapter<VH> {
+    ctor protected PagedListAdapter(androidx.recyclerview.widget.DiffUtil.ItemCallback<T>);
+    ctor protected PagedListAdapter(androidx.recyclerview.widget.AsyncDifferConfig<T>);
+    method public void addLoadStateListener(androidx.paging.PagedList.LoadStateListener!);
+    method public androidx.paging.PagedList<T>? getCurrentList();
+    method protected T? getItem(int);
+    method public int getItemCount();
+    method @Deprecated public void onCurrentListChanged(androidx.paging.PagedList<T>?);
+    method public void onCurrentListChanged(androidx.paging.PagedList<T>?, androidx.paging.PagedList<T>?);
+    method public void onLoadStateChanged(androidx.paging.PagedList.LoadType, androidx.paging.PagedList.LoadState, Throwable?);
+    method public void removeLoadStateListener(androidx.paging.PagedList.LoadStateListener!);
+    method public void submitList(androidx.paging.PagedList<T>?);
+    method public void submitList(androidx.paging.PagedList<T>?, Runnable?);
+  }
+
+}
+
diff --git a/paging/runtime/api/current.txt b/paging/runtime/api/current.txt
index 0f658df..889c9bf 100644
--- a/paging/runtime/api/current.txt
+++ b/paging/runtime/api/current.txt
@@ -4,10 +4,12 @@
   public class AsyncPagedListDiffer<T> {
     ctor public AsyncPagedListDiffer(androidx.recyclerview.widget.RecyclerView.Adapter, androidx.recyclerview.widget.DiffUtil.ItemCallback<T>);
     ctor public AsyncPagedListDiffer(androidx.recyclerview.widget.ListUpdateCallback, androidx.recyclerview.widget.AsyncDifferConfig<T>);
+    method public void addLoadStateListener(androidx.paging.PagedList.LoadStateListener);
     method public void addPagedListListener(androidx.paging.AsyncPagedListDiffer.PagedListListener<T>);
     method public androidx.paging.PagedList<T>? getCurrentList();
     method public T? getItem(int);
     method public int getItemCount();
+    method public void removeLoadStateListListener(androidx.paging.PagedList.LoadStateListener);
     method public void removePagedListListener(androidx.paging.AsyncPagedListDiffer.PagedListListener<T>);
     method public void submitList(androidx.paging.PagedList<T>?);
     method public void submitList(androidx.paging.PagedList<T>?, Runnable?);
@@ -29,11 +31,14 @@
   public abstract class PagedListAdapter<T, VH extends androidx.recyclerview.widget.RecyclerView.ViewHolder> extends androidx.recyclerview.widget.RecyclerView.Adapter<VH> {
     ctor protected PagedListAdapter(androidx.recyclerview.widget.DiffUtil.ItemCallback<T>);
     ctor protected PagedListAdapter(androidx.recyclerview.widget.AsyncDifferConfig<T>);
+    method public void addLoadStateListener(androidx.paging.PagedList.LoadStateListener!);
     method public androidx.paging.PagedList<T>? getCurrentList();
     method protected T? getItem(int);
     method public int getItemCount();
     method @Deprecated public void onCurrentListChanged(androidx.paging.PagedList<T>?);
     method public void onCurrentListChanged(androidx.paging.PagedList<T>?, androidx.paging.PagedList<T>?);
+    method public void onLoadStateChanged(androidx.paging.PagedList.LoadType, androidx.paging.PagedList.LoadState, Throwable?);
+    method public void removeLoadStateListener(androidx.paging.PagedList.LoadStateListener!);
     method public void submitList(androidx.paging.PagedList<T>?);
     method public void submitList(androidx.paging.PagedList<T>?, Runnable?);
   }
diff --git a/paging/runtime/ktx/api/2.2.0-alpha01.txt b/paging/runtime/ktx/api/2.2.0-alpha01.txt
new file mode 100644
index 0000000..b7750be
--- /dev/null
+++ b/paging/runtime/ktx/api/2.2.0-alpha01.txt
@@ -0,0 +1,11 @@
+// Signature format: 2.0
+package androidx.paging {
+
+  public final class LivePagedListKt {
+    ctor public LivePagedListKt();
+    method public static <Key, Value> androidx.lifecycle.LiveData<androidx.paging.PagedList<Value>> toLiveData(androidx.paging.DataSource.Factory<Key,Value>, androidx.paging.PagedList.Config config, Key? initialLoadKey = null, androidx.paging.PagedList.BoundaryCallback<Value>? boundaryCallback = null, java.util.concurrent.Executor fetchExecutor = ArchTaskExecutor.getIOThreadExecutor());
+    method public static <Key, Value> androidx.lifecycle.LiveData<androidx.paging.PagedList<Value>> toLiveData(androidx.paging.DataSource.Factory<Key,Value>, int pageSize, Key? initialLoadKey = null, androidx.paging.PagedList.BoundaryCallback<Value>? boundaryCallback = null, java.util.concurrent.Executor fetchExecutor = ArchTaskExecutor.getIOThreadExecutor());
+  }
+
+}
+
diff --git a/paging/runtime/src/main/java/androidx/paging/AsyncPagedListDiffer.java b/paging/runtime/src/main/java/androidx/paging/AsyncPagedListDiffer.java
index 6640dc0..c456965 100644
--- a/paging/runtime/src/main/java/androidx/paging/AsyncPagedListDiffer.java
+++ b/paging/runtime/src/main/java/androidx/paging/AsyncPagedListDiffer.java
@@ -153,6 +153,30 @@
     @SuppressWarnings("WeakerAccess") /* synthetic access */
     int mMaxScheduledGeneration;
 
+    @SuppressWarnings("WeakerAccess") /* synthetic access */
+    final PagedList.LoadStateManager mLoadStateManager = new PagedList.LoadStateManager() {
+        @Override
+        protected void onStateChanged(@NonNull PagedList.LoadType type,
+                @NonNull PagedList.LoadState state, @Nullable Throwable error) {
+            // Don't need to post - PagedList will already have done that
+            for (PagedList.LoadStateListener listener : mLoadStateListeners) {
+                listener.onLoadStateChanged(type, state, error);
+            }
+        }
+    };
+    @SuppressWarnings("WeakerAccess") // synthetic access
+    PagedList.LoadStateListener mLoadStateListener = new PagedList.LoadStateListener() {
+        @Override
+        public void onLoadStateChanged(@NonNull PagedList.LoadType type,
+                @NonNull PagedList.LoadState state, @Nullable Throwable error) {
+            mLoadStateManager.onStateChanged(type, state, error);
+        }
+    };
+
+    @SuppressWarnings("WeakerAccess") // synthetic access
+    final List<PagedList.LoadStateListener> mLoadStateListeners =
+            new CopyOnWriteArrayList<>();
+
     /**
      * Convenience for {@code AsyncPagedListDiffer(new AdapterListUpdateCallback(adapter),
      * new AsyncDifferConfig.Builder<T>(diffCallback).build();}
@@ -293,6 +317,7 @@
             int removedCount = getItemCount();
             if (mPagedList != null) {
                 mPagedList.removeWeakCallback(mPagedListCallback);
+                mPagedList.removeWeakLoadStateListener(mLoadStateListener);
                 mPagedList = null;
             } else if (mSnapshot != null) {
                 mSnapshot = null;
@@ -306,6 +331,7 @@
         if (mPagedList == null && mSnapshot == null) {
             // fast simple first insert
             mPagedList = pagedList;
+            mPagedList.addWeakLoadStateListener(mLoadStateListener);
             pagedList.addWeakCallback(null, mPagedListCallback);
 
             // dispatch update callback after updating mPagedList/mSnapshot
@@ -319,6 +345,8 @@
             // first update scheduled on this list, so capture mPages as a snapshot, removing
             // callbacks so we don't have resolve updates against a moving target
             mPagedList.removeWeakCallback(mPagedListCallback);
+            mPagedList.removeWeakLoadStateListener(mLoadStateListener);
+
             mSnapshot = (PagedList<T>) mPagedList.snapshot();
             mPagedList = null;
         }
@@ -364,6 +392,7 @@
 
         PagedList<T> previousSnapshot = mSnapshot;
         mPagedList = newList;
+        mPagedList.addWeakLoadStateListener(mLoadStateListener);
         mSnapshot = null;
 
         // dispatch update callback after updating mPagedList/mSnapshot
@@ -428,6 +457,44 @@
     }
 
     /**
+     * Add a LoadStateListener to observe the loading state of the current PagedList.
+     *
+     * As new PagedLists are submitted and displayed, the listener will be notified to reflect
+     * current REFRESH, START, and END states.
+     *
+     * @param listener Listener to receive updates.
+     *
+     * @see #removeLoadStateListListener(PagedList.LoadStateListener)
+     */
+    public void addLoadStateListener(@NonNull PagedList.LoadStateListener listener) {
+        if (mPagedList != null) {
+            mPagedList.addWeakLoadStateListener(listener);
+        } else {
+            listener.onLoadStateChanged(PagedList.LoadType.REFRESH, mLoadStateManager.getRefresh(),
+                    mLoadStateManager.getRefreshError());
+            listener.onLoadStateChanged(PagedList.LoadType.START, mLoadStateManager.getStart(),
+                    mLoadStateManager.getStartError());
+            listener.onLoadStateChanged(PagedList.LoadType.END, mLoadStateManager.getEnd(),
+                    mLoadStateManager.getEndError());
+        }
+        mLoadStateListeners.add(listener);
+    }
+
+    /**
+     * Remove a previously registered PagedListListener.
+     *
+     * @param listener Previously registered listener.
+     * @see #getCurrentList()
+     * @see #addPagedListListener(PagedListListener)
+     */
+    public void removeLoadStateListListener(@NonNull PagedList.LoadStateListener listener) {
+        mLoadStateListeners.remove(listener);
+        if (mPagedList != null) {
+            mPagedList.removeWeakLoadStateListener(listener);
+        }
+    }
+
+    /**
      * Returns the PagedList currently being displayed by the differ.
      * <p>
      * This is not necessarily the most recent list passed to {@link #submitList(PagedList)},
diff --git a/paging/runtime/src/main/java/androidx/paging/PagedListAdapter.java b/paging/runtime/src/main/java/androidx/paging/PagedListAdapter.java
index cfc8aa5..9f1d4dd 100644
--- a/paging/runtime/src/main/java/androidx/paging/PagedListAdapter.java
+++ b/paging/runtime/src/main/java/androidx/paging/PagedListAdapter.java
@@ -119,6 +119,14 @@
             PagedListAdapter.this.onCurrentListChanged(previousList, currentList);
         }
     };
+    private final PagedList.LoadStateListener mLoadStateListener =
+            new PagedList.LoadStateListener() {
+                @Override
+                public void onLoadStateChanged(@NonNull PagedList.LoadType type,
+                        @NonNull PagedList.LoadState state, @Nullable Throwable error) {
+                    PagedListAdapter.this.onLoadStateChanged(type, state, error);
+                }
+            };
 
     /**
      * Creates a PagedListAdapter with default threading and
@@ -133,11 +141,13 @@
     protected PagedListAdapter(@NonNull DiffUtil.ItemCallback<T> diffCallback) {
         mDiffer = new AsyncPagedListDiffer<>(this, diffCallback);
         mDiffer.addPagedListListener(mListener);
+        mDiffer.addLoadStateListener(mLoadStateListener);
     }
 
     protected PagedListAdapter(@NonNull AsyncDifferConfig<T> config) {
         mDiffer = new AsyncPagedListDiffer<>(new AdapterListUpdateCallback(this), config);
         mDiffer.addPagedListListener(mListener);
+        mDiffer.addLoadStateListener(mLoadStateListener);
     }
 
     /**
@@ -241,4 +251,44 @@
     public void onCurrentListChanged(
             @Nullable PagedList<T> previousList, @Nullable PagedList<T> currentList) {
     }
+
+
+    /**
+     * Called when the LoadState for a particular type of load (START, END, REFRESH) has
+     * changed.
+     * <p>
+     * REFRESH events can be used to drive a {@code SwipeRefreshLayout}, or START/END events
+     * can be used to drive loading spinner items in the Adapter.
+     *
+     * @param type Type of load - START, END, or REFRESH.
+     * @param state State of load - IDLE, LOADING, DONE, ERROR, or RETRYABLE_ERROR
+     * @param error Error, if in an error state, null otherwise.
+     */
+    public void onLoadStateChanged(@NonNull PagedList.LoadType type,
+            @NonNull PagedList.LoadState state, @Nullable Throwable error) {
+    }
+
+    /**
+     * Add a LoadStateListener to observe the loading state of the current PagedList.
+     *
+     * As new PagedLists are submitted and displayed, the listener will be notified to reflect
+     * current REFRESH, START, and END states.
+     *
+     * @param listener Listener to receive updates.
+     *
+     * @see #removeLoadStateListener(PagedList.LoadStateListener)
+     */
+    public void addLoadStateListener(PagedList.LoadStateListener listener) {
+        mDiffer.addLoadStateListener(listener);
+    }
+
+    /**
+     * Remove a previously registered LoadStateListener.
+     *
+     * @param listener Previously registered listener.
+     * @see #addLoadStateListener(PagedList.LoadStateListener)
+     */
+    public void removeLoadStateListener(PagedList.LoadStateListener listener) {
+        mDiffer.removeLoadStateListListener(listener);
+    }
 }
diff --git a/paging/rxjava2/api/2.2.0-alpha01.txt b/paging/rxjava2/api/2.2.0-alpha01.txt
new file mode 100644
index 0000000..067f422
--- /dev/null
+++ b/paging/rxjava2/api/2.2.0-alpha01.txt
@@ -0,0 +1,16 @@
+// Signature format: 2.0
+package androidx.paging {
+
+  public final class RxPagedListBuilder<Key, Value> {
+    ctor public RxPagedListBuilder(androidx.paging.DataSource.Factory<Key,Value>, androidx.paging.PagedList.Config);
+    ctor public RxPagedListBuilder(androidx.paging.DataSource.Factory<Key,Value>, int);
+    method public io.reactivex.Flowable<androidx.paging.PagedList<Value>> buildFlowable(io.reactivex.BackpressureStrategy);
+    method public io.reactivex.Observable<androidx.paging.PagedList<Value>> buildObservable();
+    method public androidx.paging.RxPagedListBuilder<Key,Value> setBoundaryCallback(androidx.paging.PagedList.BoundaryCallback<Value>?);
+    method public androidx.paging.RxPagedListBuilder<Key,Value> setFetchScheduler(io.reactivex.Scheduler);
+    method public androidx.paging.RxPagedListBuilder<Key,Value> setInitialLoadKey(Key?);
+    method public androidx.paging.RxPagedListBuilder<Key,Value> setNotifyScheduler(io.reactivex.Scheduler);
+  }
+
+}
+
diff --git a/paging/rxjava2/ktx/api/2.2.0-alpha01.txt b/paging/rxjava2/ktx/api/2.2.0-alpha01.txt
new file mode 100644
index 0000000..6b573b4
--- /dev/null
+++ b/paging/rxjava2/ktx/api/2.2.0-alpha01.txt
@@ -0,0 +1,13 @@
+// Signature format: 2.0
+package androidx.paging {
+
+  public final class RxPagedListKt {
+    ctor public RxPagedListKt();
+    method public static <Key, Value> io.reactivex.Flowable<androidx.paging.PagedList<Value>> toFlowable(androidx.paging.DataSource.Factory<Key,Value>, androidx.paging.PagedList.Config config, Key? initialLoadKey = null, androidx.paging.PagedList.BoundaryCallback<Value>? boundaryCallback = null, io.reactivex.Scheduler? fetchScheduler = null, io.reactivex.Scheduler? notifyScheduler = null, io.reactivex.BackpressureStrategy backpressureStrategy = io.reactivex.BackpressureStrategy.LATEST);
+    method public static <Key, Value> io.reactivex.Flowable<androidx.paging.PagedList<Value>> toFlowable(androidx.paging.DataSource.Factory<Key,Value>, int pageSize, Key? initialLoadKey = null, androidx.paging.PagedList.BoundaryCallback<Value>? boundaryCallback = null, io.reactivex.Scheduler? fetchScheduler = null, io.reactivex.Scheduler? notifyScheduler = null, io.reactivex.BackpressureStrategy backpressureStrategy = io.reactivex.BackpressureStrategy.LATEST);
+    method public static <Key, Value> io.reactivex.Observable<androidx.paging.PagedList<Value>> toObservable(androidx.paging.DataSource.Factory<Key,Value>, androidx.paging.PagedList.Config config, Key? initialLoadKey = null, androidx.paging.PagedList.BoundaryCallback<Value>? boundaryCallback = null, io.reactivex.Scheduler? fetchScheduler = null, io.reactivex.Scheduler? notifyScheduler = null);
+    method public static <Key, Value> io.reactivex.Observable<androidx.paging.PagedList<Value>> toObservable(androidx.paging.DataSource.Factory<Key,Value>, int pageSize, Key? initialLoadKey = null, androidx.paging.PagedList.BoundaryCallback<Value>? boundaryCallback = null, io.reactivex.Scheduler? fetchScheduler = null, io.reactivex.Scheduler? notifyScheduler = null);
+  }
+
+}
+