Public API throws more useful exceptions.

Public API methods throw a variety of exceptions, allowing the client
programmer to take appropriate action if necessary in response to the
exceptions.

A new `io.v.syncbase.exception` package contains these exceptions,
plus an `Exceptions` utility that has a number of overloaded
`chainThrow` methods that used the chained exception pattern, throwing
an appropriate higher-level exception corresponding to each
lower-level error from the Go code.

See included README.md for details of the exceptions.

The exceptions thrown from the API have a message that includes both a
high-level message related to the public API where it was called and a
low-level message from the underlying VError or VException.

Change-Id: I128cafb9c73e4e12909c3de62b3fe1a5ed5e0b13
diff --git a/syncbase/src/main/java/io/v/syncbase/BatchDatabase.java b/syncbase/src/main/java/io/v/syncbase/BatchDatabase.java
index 91e8892..81b7f1a 100644
--- a/syncbase/src/main/java/io/v/syncbase/BatchDatabase.java
+++ b/syncbase/src/main/java/io/v/syncbase/BatchDatabase.java
@@ -5,6 +5,9 @@
 package io.v.syncbase;
 
 import io.v.syncbase.core.VError;
+import io.v.syncbase.exception.SyncbaseException;
+
+import static io.v.syncbase.exception.Exceptions.chainThrow;
 
 /**
  * Provides a way to perform a set of operations atomically on a database. See
@@ -19,10 +22,10 @@
     }
 
     /**
-     * @throws IllegalArgumentException if opts.withoutSyncgroup not false
+     * @throws IllegalArgumentException if opts.withoutSyncgroup false
      */
     @Override
-    public Collection collection(String name, CollectionOptions opts) throws VError {
+    public Collection collection(String name, CollectionOptions opts) throws SyncbaseException {
         if (!opts.withoutSyncgroup) {
             throw new IllegalArgumentException("Cannot create syncgroup in a batch");
         }
@@ -35,9 +38,15 @@
      * Persists the pending changes to Syncbase. If the batch is read-only, {@code commit} will
      * throw {@code ConcurrentBatchException}; abort should be used instead.
      */
-    public void commit() throws VError {
-        // TODO(sadovsky): Throw ConcurrentBatchException where appropriate.
-        mCoreBatchDatabase.commit();
+    public void commit() throws SyncbaseException {
+        try {
+
+            // TODO(sadovsky): Throw ConcurrentBatchException where appropriate.
+            mCoreBatchDatabase.commit();
+
+        } catch (VError e) {
+            chainThrow("committing batch", e);
+        }
     }
 
     /**
@@ -45,7 +54,13 @@
      * strictly required, but may allow Syncbase to release locks or other resources sooner than if
      * {@code abort} was not called.
      */
-    public void abort() throws VError {
-        mCoreBatchDatabase.abort();
+    public void abort() throws SyncbaseException {
+        try {
+
+            mCoreBatchDatabase.abort();
+
+        } catch (VError e) {
+            chainThrow("aborting batch", e);
+        }
     }
 }
diff --git a/syncbase/src/main/java/io/v/syncbase/Collection.java b/syncbase/src/main/java/io/v/syncbase/Collection.java
index 002da0b..8742522 100644
--- a/syncbase/src/main/java/io/v/syncbase/Collection.java
+++ b/syncbase/src/main/java/io/v/syncbase/Collection.java
@@ -6,9 +6,12 @@
 
 import io.v.syncbase.core.Permissions;
 import io.v.syncbase.core.VError;
+import io.v.syncbase.exception.SyncbaseException;
 import io.v.v23.verror.VException;
 import io.v.v23.vom.VomUtil;
 
+import static io.v.syncbase.exception.Exceptions.chainThrow;
+
 /**
  * Represents an ordered set of key-value pairs.
  * To get a Collection handle, call {@code Database.collection}.
@@ -24,14 +27,14 @@
         mId = new Id(coreCollection.id());
     }
 
-    void createIfMissing() {
+    void createIfMissing() throws SyncbaseException {
         try {
             mCoreCollection.create(Syncbase.defaultCollectionPerms());
         } catch (VError vError) {
             if (vError.id.equals(VError.EXIST)) {
                 return;
             }
-            throw new RuntimeException("Failed to create collection", vError);
+            chainThrow("creating collection", vError);
         }
     }
 
@@ -65,68 +68,94 @@
     /**
      * Returns the value associated with {@code key}.
      */
-    public <T> T get(String key, Class<T> cls) throws VError {
+    public <T> T get(String key, Class<T> cls) throws SyncbaseException {
         try {
             return (T) VomUtil.decode(mCoreCollection.get(key), cls);
         } catch (VError vError) {
             if (vError.id.equals(VError.NO_EXIST)) {
                 return null;
             }
-            throw vError;
+            chainThrow("getting value from collection", mId, vError);
         } catch (VException e) {
-            throw new VError(e);
+            chainThrow("decoding value retrieved from collection", mId, e);
         }
+        throw new AssertionError("never happens");
     }
 
     /**
      * Returns true if there is a value associated with {@code key}.
      */
-    public boolean exists(String key) throws VError {
-        return mCoreCollection.row(key).exists();
+    public boolean exists(String key) throws SyncbaseException {
+        try {
+
+            return mCoreCollection.row(key).exists();
+
+        } catch (VError e) {
+            chainThrow("checking if value exists in collection", mId, e);
+            throw new AssertionError("never happens");
+        }
     }
 
     /**
      * Puts {@code value} for {@code key}, overwriting any existing value. Idempotent.
      */
-    public <T> void put(String key, T value) throws VError {
+    public <T> void put(String key, T value) throws SyncbaseException {
         try {
+
             mCoreCollection.put(key, VomUtil.encode(value, value.getClass()));
+
+        } catch (VError e) {
+            chainThrow("putting value into collection", mId, e);
         } catch (VException e) {
-            throw new VError(e);
+            chainThrow("putting value into collection", mId, e);
         }
     }
 
     /**
      * Deletes the value associated with {@code key}. Idempotent.
      */
-    public void delete(String key) throws VError {
-        mCoreCollection.delete(key);
+    public void delete(String key) throws SyncbaseException {
+        try {
+
+            mCoreCollection.delete(key);
+
+        } catch (VError e) {
+            chainThrow("deleting collection", mId, e);
+        }
     }
 
     /**
      * FOR ADVANCED USERS. Returns the {@code AccessList} for this collection. Users should
      * typically manipulate access lists via {@code collection.getSyncgroup()}.
      */
-    public AccessList getAccessList() throws VError {
-        return new AccessList(mCoreCollection.getPermissions());
+    public AccessList getAccessList() throws SyncbaseException {
+        try {
+
+            return new AccessList(mCoreCollection.getPermissions());
+
+        } catch (VError e) {
+            chainThrow("getting access list of collection", mId, e);
+            throw new AssertionError("never happens");
+        }
     }
 
     /**
      * FOR ADVANCED USERS. Updates the {@code AccessList} for this collection. Users should
      * typically manipulate access lists via {@code collection.getSyncgroup()}.
      */
-    public void updateAccessList(final AccessList delta) throws VError {
+    public void updateAccessList(final AccessList delta) throws SyncbaseException {
         final Id id = this.getId();
         Database.BatchOperation op = new Database.BatchOperation() {
             @Override
-            public void run(BatchDatabase db) {
-                io.v.syncbase.core.Collection coreCollection = db.getCollection(id).mCoreCollection;
+            public void run(BatchDatabase db) throws SyncbaseException {
+                io.v.syncbase.core.Collection coreCollection = db.getCollection(id)
+                        .mCoreCollection;
                 try {
                     Permissions newPermissions = AccessList.applyDeltaForCollection(
                             coreCollection.getPermissions(), delta);
                     coreCollection.setPermissions(newPermissions);
                 } catch (VError vError) {
-                    throw new RuntimeException("updateAccessList failed", vError);
+                    chainThrow("setting permissions in collection", id, vError);
                 }
             }
         };
diff --git a/syncbase/src/main/java/io/v/syncbase/Database.java b/syncbase/src/main/java/io/v/syncbase/Database.java
index 1fea773..8985189 100644
--- a/syncbase/src/main/java/io/v/syncbase/Database.java
+++ b/syncbase/src/main/java/io/v/syncbase/Database.java
@@ -17,6 +17,9 @@
 import io.v.syncbase.core.CollectionRowPattern;
 import io.v.syncbase.core.SyncgroupMemberInfo;
 import io.v.syncbase.core.VError;
+import io.v.syncbase.exception.SyncbaseException;
+
+import static io.v.syncbase.exception.Exceptions.chainThrow;
 
 /**
  * A set of collections and syncgroups.
@@ -35,19 +38,19 @@
         mCoreDatabase = coreDatabase;
     }
 
-    void createIfMissing() throws VError {
+    void createIfMissing() throws SyncbaseException {
         try {
             mCoreDatabase.create(Syncbase.defaultDatabasePerms());
         } catch (VError vError) {
             if (vError.id.equals(VError.EXIST)) {
                 return;
             }
-            throw vError;
+            chainThrow("creating database", vError);
         }
     }
 
     @Override
-    public Collection collection(String name, CollectionOptions opts) throws VError {
+    public Collection collection(String name, CollectionOptions opts) throws SyncbaseException {
         Collection res = getCollection(new Id(Syncbase.getPersonalBlessingString(), name));
         res.createIfMissing();
         // TODO(sadovsky): Unwind collection creation on syncgroup creation failure? It would be
@@ -77,7 +80,7 @@
      * @return the syncgroup
      */
     public Syncgroup syncgroup(String name, List<Collection> collections, SyncgroupOptions opts)
-            throws VError {
+            throws SyncbaseException {
         if (collections.isEmpty()) {
             throw new IllegalArgumentException("No collections specified");
         }
@@ -99,7 +102,7 @@
     /**
      * Calls {@code syncgroup(name, collections, opts)} with default {@code SyncgroupOptions}.
      */
-    public Syncgroup syncgroup(String name, List<Collection> collections) throws VError {
+    public Syncgroup syncgroup(String name, List<Collection> collections) throws SyncbaseException {
         return syncgroup(name, collections, new SyncgroupOptions());
     }
 
@@ -116,12 +119,19 @@
     /**
      * Returns an iterator over all syncgroups in the database.
      */
-    public Iterator<Syncgroup> getSyncgroups() throws VError {
-        ArrayList<Syncgroup> syncgroups = new ArrayList<>();
-        for (io.v.syncbase.core.Id id : mCoreDatabase.listSyncgroups()) {
-            syncgroups.add(getSyncgroup(new Id(id)));
+    public Iterator<Syncgroup> getSyncgroups() throws SyncbaseException {
+        try {
+
+            ArrayList<Syncgroup> syncgroups = new ArrayList<>();
+            for (io.v.syncbase.core.Id id : mCoreDatabase.listSyncgroups()) {
+                syncgroups.add(getSyncgroup(new Id(id)));
+            }
+            return syncgroups.iterator();
+
+        } catch (VError e) {
+            chainThrow("getting syncgroups of database", mCoreDatabase.id(), e);
+            throw new AssertionError("never happens");
         }
-        return syncgroups.iterator();
     }
 
     /**
@@ -294,7 +304,7 @@
      * Designed for use in {@code runInBatch}.
      */
     public interface BatchOperation {
-        void run(BatchDatabase db);
+        void run(BatchDatabase db) throws SyncbaseException;
     }
 
     /**
@@ -304,13 +314,23 @@
      * @param op   the operation to run
      * @param opts options for this batch
      */
-    public void runInBatch(final BatchOperation op, BatchOptions opts) throws VError {
-        mCoreDatabase.runInBatch(new io.v.syncbase.core.Database.BatchOperation() {
-            @Override
-            public void run(io.v.syncbase.core.BatchDatabase batchDatabase) {
-                op.run(new BatchDatabase(batchDatabase));
-            }
-        }, opts.toCore());
+    public void runInBatch(final BatchOperation op, BatchOptions opts) throws SyncbaseException {
+        try {
+
+            mCoreDatabase.runInBatch(new io.v.syncbase.core.Database.BatchOperation() {
+                @Override
+                public void run(io.v.syncbase.core.BatchDatabase batchDatabase) {
+                    try {
+                        op.run(new BatchDatabase(batchDatabase));
+                    } catch (SyncbaseException e) {
+                        e.printStackTrace();
+                    }
+                }
+            }, opts.toCore());
+
+        } catch (VError e) {
+            chainThrow("running batch operation in database", mCoreDatabase.id(), e);
+        }
     }
 
     /**
@@ -320,7 +340,7 @@
      *
      * @param op   the operation to run
      */
-    public void runInBatch(final BatchOperation op) throws VError {
+    public void runInBatch(final BatchOperation op) throws SyncbaseException {
         runInBatch(op, new BatchOptions());
     }
 
@@ -348,8 +368,15 @@
      * @param opts options for this batch
      * @return the batch handle
      */
-    public BatchDatabase beginBatch(BatchOptions opts) throws VError {
-        return new BatchDatabase(mCoreDatabase.beginBatch(opts.toCore()));
+    public BatchDatabase beginBatch(BatchOptions opts) throws SyncbaseException {
+        try {
+
+            return new BatchDatabase(mCoreDatabase.beginBatch(opts.toCore()));
+
+        } catch (VError e) {
+            chainThrow("creating batch in database", mCoreDatabase.id(), e);
+            throw new AssertionError("never happens");
+        }
     }
 
     /**
diff --git a/syncbase/src/main/java/io/v/syncbase/DatabaseHandle.java b/syncbase/src/main/java/io/v/syncbase/DatabaseHandle.java
index b12cec5..b31f96b 100644
--- a/syncbase/src/main/java/io/v/syncbase/DatabaseHandle.java
+++ b/syncbase/src/main/java/io/v/syncbase/DatabaseHandle.java
@@ -8,6 +8,9 @@
 import java.util.Iterator;
 
 import io.v.syncbase.core.VError;
+import io.v.syncbase.exception.SyncbaseException;
+
+import static io.v.syncbase.exception.Exceptions.chainThrow;
 
 
 /**
@@ -50,12 +53,13 @@
      * @param opts options for collection creation
      * @return the collection handle
      */
-    public abstract Collection collection(String name, CollectionOptions opts) throws VError;
+    public abstract Collection collection(String name, CollectionOptions opts)
+            throws SyncbaseException;
 
     /**
      * Calls {@code collection(name, opts)} with default {@code CollectionOptions}.
      */
-    public Collection collection(String name) throws VError {
+    public Collection collection(String name) throws SyncbaseException {
         return collection(name, new CollectionOptions());
     }
 
@@ -72,11 +76,18 @@
     /**
      * Returns an iterator over all collections in the database.
      */
-    public Iterator<Collection> getCollections() throws VError {
-        ArrayList<Collection> collections = new ArrayList<>();
-        for (io.v.syncbase.core.Id id : mCoreDatabaseHandle.listCollections()) {
-            collections.add(getCollection(new Id(id)));
+    public Iterator<Collection> getCollections() throws SyncbaseException {
+        try {
+
+            ArrayList<Collection> collections = new ArrayList<>();
+            for (io.v.syncbase.core.Id id : mCoreDatabaseHandle.listCollections()) {
+                collections.add(getCollection(new Id(id)));
+            }
+            return collections.iterator();
+
+        } catch (VError e) {
+            chainThrow("getting collections in database", mCoreDatabaseHandle.id(), e);
+            throw new AssertionError("never happens");
         }
-        return collections.iterator();
     }
 }
diff --git a/syncbase/src/main/java/io/v/syncbase/Id.java b/syncbase/src/main/java/io/v/syncbase/Id.java
index 149c107..f7716d5 100644
--- a/syncbase/src/main/java/io/v/syncbase/Id.java
+++ b/syncbase/src/main/java/io/v/syncbase/Id.java
@@ -27,7 +27,7 @@
     public static Id decode(String encodedId) {
         String[] parts = encodedId.split(SEPARATOR);
         if (parts.length != 2) {
-            throw new IllegalArgumentException("Invalid encoded id: " + encodedId);
+            throw new IllegalArgumentException("Invalid encoded ID: \"" + encodedId + "\"");
         }
         return new Id(parts[0], parts[1]);
     }
diff --git a/syncbase/src/main/java/io/v/syncbase/Syncbase.java b/syncbase/src/main/java/io/v/syncbase/Syncbase.java
index c0e93b7..d889060 100644
--- a/syncbase/src/main/java/io/v/syncbase/Syncbase.java
+++ b/syncbase/src/main/java/io/v/syncbase/Syncbase.java
@@ -27,9 +27,12 @@
 import io.v.syncbase.core.Permissions;
 import io.v.syncbase.core.Service;
 import io.v.syncbase.core.VError;
+import io.v.syncbase.exception.SyncbaseException;
 import io.v.syncbase.internal.Blessings;
 import io.v.syncbase.internal.Neighborhood;
 
+import static io.v.syncbase.exception.Exceptions.chainThrow;
+
 // FIXME(sadovsky): Currently, various methods throw RuntimeException on any error. We need to
 // decide which error types to surface to clients, and define specific Exception subclasses for
 // those.
@@ -44,6 +47,10 @@
 /**
  * Syncbase is a storage system for developers that makes it easy to synchronize app data between
  * devices. It works even when devices are not connected to the Internet.
+ *
+ * <p>Methods of classes in this package may throw an exception that is a subclass of
+ * SyncbaseException.  See details of those subclasses to determine whether there are conditions
+ * the calling code should handle.</p>
  */
 public class Syncbase {
     /**
@@ -113,7 +120,7 @@
             DB_NAME = "db",
             USERDATA_SYNCGROUP_NAME = "userdata__";
 
-    private static Map selfAndCloud() throws VError {
+    private static Map selfAndCloud() throws SyncbaseException {
         return ImmutableMap.of(Permissions.IN,
                 ImmutableList.of(getPersonalBlessingString(), sOpts.getCloudBlessingString()));
     }
@@ -123,28 +130,41 @@
      *
      * @param opts initial options
      */
-    public static void init(Options opts) throws VError {
-        System.loadLibrary("syncbase");
-        sOpts = opts;
-        io.v.syncbase.internal.Service.Init(sOpts.rootDir, sOpts.testLogin);
-        if (isLoggedIn()) {
-            io.v.syncbase.internal.Service.Serve();
+    public static void init(Options opts) throws SyncbaseException {
+        try {
+
+            System.loadLibrary("syncbase");
+            sOpts = opts;
+            io.v.syncbase.internal.Service.Init(sOpts.rootDir, sOpts.testLogin);
+            if (isLoggedIn()) {
+                io.v.syncbase.internal.Service.Serve();
+            }
+
+        } catch (VError e) {
+            chainThrow("initializing Syncbase", e);
         }
     }
 
     /**
      * Returns a Database object. Return null if the user is not currently logged in.
      */
-    public static Database database() throws VError {
-        if (!isLoggedIn()) {
-            return null;
-        }
-        if (sDatabase != null) {
-            // TODO(sadovsky): Check that opts matches original opts (sOpts)?
+    public static Database database() throws SyncbaseException {
+        try {
+
+            if (!isLoggedIn()) {
+                return null;
+            }
+            if (sDatabase != null) {
+                // TODO(sadovsky): Check that opts matches original opts (sOpts)?
+                return sDatabase;
+            }
+            sDatabase = new Database(Service.database(DB_NAME));
             return sDatabase;
+
+        } catch (VError e) {
+            chainThrow("getting the database", e);
+            throw new AssertionError("never happens");
         }
-        sDatabase = new Database(Service.database(DB_NAME));
-        return sDatabase;
     }
 
     /**
@@ -247,8 +267,8 @@
                             cb.onSuccess();
                         }
                     });
-                } catch (VError vError) {
-                    cb.onError(vError);
+                } catch (Throwable e) {
+                    cb.onError(e);
                 }
             }
         }).start();
@@ -398,11 +418,18 @@
         return parts[parts.length - 1];
     }
 
-    static String getPersonalBlessingString() throws VError {
-        return Blessings.UserBlessingFromContext();
+    static String getPersonalBlessingString() throws SyncbaseException {
+        try {
+
+            return Blessings.UserBlessingFromContext();
+
+        } catch(VError e) {
+            chainThrow("getting certificates from context", e);
+            throw new AssertionError("never happens");
+        }
     }
 
-    static Permissions defaultDatabasePerms() throws VError {
+    static Permissions defaultDatabasePerms() throws SyncbaseException {
         // TODO(sadovsky): Revisit these default perms, which were copied from the Todos app.
         Map anyone = ImmutableMap.of(Permissions.IN, ImmutableList.of("..."));
         Map selfAndCloud = selfAndCloud();
@@ -413,7 +440,7 @@
                 Permissions.Tags.ADMIN, selfAndCloud));
     }
 
-    static Permissions defaultCollectionPerms() throws VError {
+    static Permissions defaultCollectionPerms() throws SyncbaseException {
         // TODO(sadovsky): Revisit these default perms, which were copied from the Todos app.
         Map selfAndCloud = selfAndCloud();
         return new Permissions(ImmutableMap.of(
@@ -422,7 +449,7 @@
                 Permissions.Tags.ADMIN, selfAndCloud));
     }
 
-    static Permissions defaultSyncgroupPerms() throws VError {
+    static Permissions defaultSyncgroupPerms() throws SyncbaseException {
         // TODO(sadovsky): Revisit these default perms, which were copied from the Todos app.
         Map selfAndCloud = selfAndCloud();
         return new Permissions(ImmutableMap.of(
diff --git a/syncbase/src/main/java/io/v/syncbase/Syncgroup.java b/syncbase/src/main/java/io/v/syncbase/Syncgroup.java
index d764077..ef41110 100644
--- a/syncbase/src/main/java/io/v/syncbase/Syncgroup.java
+++ b/syncbase/src/main/java/io/v/syncbase/Syncgroup.java
@@ -14,6 +14,9 @@
 import io.v.syncbase.core.SyncgroupSpec;
 import io.v.syncbase.core.VError;
 import io.v.syncbase.core.VersionedSyncgroupSpec;
+import io.v.syncbase.exception.SyncbaseException;
+
+import static io.v.syncbase.exception.Exceptions.chainThrow;
 
 /**
  * Represents a set of collections, synced amongst a set of users.
@@ -28,7 +31,7 @@
         mDatabase = database;
     }
 
-    void createIfMissing(List<Collection> collections) throws VError {
+    void createIfMissing(List<Collection> collections) throws SyncbaseException {
         ArrayList<io.v.syncbase.core.Id> ids = new ArrayList<>();
         for (Collection cx : collections) {
             ids.add(cx.getId().toCoreId());
@@ -52,7 +55,7 @@
                 // configuration, e.g., the specified collections? instead of returning early.
                 return;
             }
-            throw vError;
+            chainThrow("creating syncgroup for collections", vError);
         }
     }
 
@@ -73,17 +76,24 @@
      * Returns the {@code AccessList} for this syncgroup.
      * Throws if the current user is not an admin of the syncgroup or its collection.
      */
-    public AccessList getAccessList() throws VError {
+    public AccessList getAccessList() throws SyncbaseException {
         // TODO(alexfandrianto): Rework for advanced users.
         // We will not ask for the syncgroup spec. Instead, we will rely on the collection of this
         // syncgroup to have the correct permissions. There is an issue with not being able to
         // determine READ vs READ_WRITE from just the syncgroup spec because the write tag is only
         // available on the collection. This workaround will assume only a single collection per
         // syncgroup, which is why it might not succeed for advanced users.
-        Id cId = new Id(mCoreSyncgroup.getSpec().syncgroupSpec.collections.get(0));
-        return mDatabase.getCollection(cId).getAccessList();
+        try {
 
-        // return new AccessList(mCoreSyncgroup.getSpec().syncgroupSpec.permissions);
+            Id cId = new Id(mCoreSyncgroup.getSpec().syncgroupSpec.collections.get(0));
+            return mDatabase.getCollection(cId).getAccessList();
+
+            // return new AccessList(mCoreSyncgroup.getSpec().syncgroupSpec.permissions);
+
+        } catch (VError e) {
+            chainThrow("getting access list of syncgroup", getId(), e);
+            throw new AssertionError("never happens");
+        }
     }
 
     /**
@@ -103,7 +113,7 @@
      * FOR ADVANCED USERS. Adds the given users to the syncgroup, with the specified access level.
      */
     public void inviteUsers(List<User> users, AccessList.AccessLevel level,
-                            UpdateAccessListOptions opts) throws VError {
+                            UpdateAccessListOptions opts) throws SyncbaseException {
         AccessList delta = new AccessList();
         for (User u : users) {
             delta.setAccessLevel(u, level);
@@ -114,21 +124,22 @@
     /**
      * Adds the given users to the syncgroup, with the specified access level.
      */
-    public void inviteUsers(List<User> users, AccessList.AccessLevel level) throws VError {
+    public void inviteUsers(List<User> users, AccessList.AccessLevel level)
+            throws SyncbaseException {
         inviteUsers(users, level, new UpdateAccessListOptions());
     }
 
     /**
      * Adds the given user to the syncgroup, with the specified access level.
      */
-    public void inviteUser(User user, AccessList.AccessLevel level) throws VError {
+    public void inviteUser(User user, AccessList.AccessLevel level) throws SyncbaseException {
         inviteUsers(Collections.singletonList(user), level);
     }
 
     /**
      * FOR ADVANCED USERS. Removes the given users from the syncgroup.
      */
-    public void ejectUsers(List<User> users, UpdateAccessListOptions opts) throws VError {
+    public void ejectUsers(List<User> users, UpdateAccessListOptions opts) throws SyncbaseException {
         AccessList delta = new AccessList();
         for (User u : users) {
             delta.removeAccessLevel(u);
@@ -139,14 +150,14 @@
     /**
      * Removes the given users from the syncgroup.
      */
-    public void ejectUsers(List<User> users) throws VError {
+    public void ejectUsers(List<User> users) throws SyncbaseException {
         ejectUsers(users, new UpdateAccessListOptions());
     }
 
     /**
      * Removes the given user from the syncgroup.
      */
-    public void ejectUser(User user) throws VError {
+    public void ejectUser(User user) throws SyncbaseException {
         ejectUsers(Collections.singletonList(user));
     }
 
@@ -154,32 +165,29 @@
      * FOR ADVANCED USERS. Applies {@code delta} to the {@code AccessList}.
      */
     public void updateAccessList(final AccessList delta, UpdateAccessListOptions opts)
-            throws VError {
-        // TODO(sadovsky): Make it so SyncgroupSpec can be updated as part of a batch?
-        VersionedSyncgroupSpec versionedSyncgroupSpec;
+            throws SyncbaseException {
         try {
-            versionedSyncgroupSpec = mCoreSyncgroup.getSpec();
-        } catch (VError vError) {
-            throw new RuntimeException("getSpec failed", vError);
-        }
-        versionedSyncgroupSpec.syncgroupSpec.permissions = AccessList.applyDeltaForSyncgroup(
-                versionedSyncgroupSpec.syncgroupSpec.permissions, delta);
-        mCoreSyncgroup.setSpec(versionedSyncgroupSpec);
-        // TODO(sadovsky): There's a race here - it's possible for a collection to get destroyed
-        // after getSpec() but before db.getCollection().
-        final List<io.v.syncbase.core.Id> collectionsIds =
-                versionedSyncgroupSpec.syncgroupSpec.collections;
-        mDatabase.runInBatch(new Database.BatchOperation() {
-            @Override
-            public void run(BatchDatabase db) {
-                for (io.v.syncbase.core.Id id : collectionsIds) {
-                    try {
+
+            // TODO(sadovsky): Make it so SyncgroupSpec can be updated as part of a batch?
+            VersionedSyncgroupSpec versionedSyncgroupSpec = mCoreSyncgroup.getSpec();
+            versionedSyncgroupSpec.syncgroupSpec.permissions = AccessList.applyDeltaForSyncgroup(
+                    versionedSyncgroupSpec.syncgroupSpec.permissions, delta);
+            mCoreSyncgroup.setSpec(versionedSyncgroupSpec);
+            // TODO(sadovsky): There's a race here - it's possible for a collection to get destroyed
+            // after getSpec() but before db.getCollection().
+            final List<io.v.syncbase.core.Id> collectionsIds =
+                    versionedSyncgroupSpec.syncgroupSpec.collections;
+            mDatabase.runInBatch(new Database.BatchOperation() {
+                @Override
+                public void run(BatchDatabase db) throws SyncbaseException {
+                    for (io.v.syncbase.core.Id id : collectionsIds) {
                         db.getCollection(new Id(id)).updateAccessList(delta);
-                    } catch (VError vError) {
-                        throw new RuntimeException("getCollection failed", vError);
                     }
                 }
-            }
-        });
+            });
+
+        } catch (VError e) {
+            chainThrow("updating access list of syncgroup", getId(), e);
+        }
     }
 }
diff --git a/syncbase/src/main/java/io/v/syncbase/WatchChange.java b/syncbase/src/main/java/io/v/syncbase/WatchChange.java
index 5160de1..b8ab288 100644
--- a/syncbase/src/main/java/io/v/syncbase/WatchChange.java
+++ b/syncbase/src/main/java/io/v/syncbase/WatchChange.java
@@ -4,10 +4,13 @@
 
 package io.v.syncbase;
 
-import io.v.syncbase.core.VError;
+import io.v.syncbase.exception.SyncbaseException;
+import io.v.syncbase.exception.SyncbaseInternalException;
 import io.v.v23.verror.VException;
 import io.v.v23.vom.VomUtil;
 
+import static io.v.syncbase.exception.Exceptions.chainThrow;
+
 /**
  * Describes a change to a database.
  */
@@ -32,21 +35,20 @@
     private final boolean mContinued;
 
     WatchChange(io.v.syncbase.core.WatchChange change) {
+        // TODO(eobrain): use switch statements below
         if (change.entityType == io.v.syncbase.core.WatchChange.EntityType.COLLECTION) {
             mEntityType = EntityType.COLLECTION;
         } else if (change.entityType == io.v.syncbase.core.WatchChange.EntityType.ROW) {
             mEntityType = EntityType.ROW;
         } else {
-            // TODO(razvanm): Throw an exception after https://v.io/c/23420 is submitted.
-            throw new RuntimeException("Unknown EntityType: " + change.entityType);
+            throw new SyncbaseInternalException("Unknown EntityType: " + change.entityType);
         }
         if (change.changeType == io.v.syncbase.core.WatchChange.ChangeType.PUT) {
             mChangeType = ChangeType.PUT;
         } else if (change.changeType == io.v.syncbase.core.WatchChange.ChangeType.DELETE) {
             mChangeType = ChangeType.DELETE;
         } else {
-            // TODO(razvanm): Throw an SyncbaseException after https://v.io/c/23420 is submitted.
-            throw new RuntimeException("Unknown ChangeType: " + change.changeType);
+            throw new SyncbaseInternalException("Unknown ChangeType: " + change.changeType);
         }
         mCollectionId = new Id(change.collection);
         mRowKey = change.row;
@@ -72,11 +74,12 @@
         return mRowKey;
     }
 
-    public <T> T getValue(Class<T> cls) throws VError {
+    public <T> T getValue(Class<T> cls) throws SyncbaseException {
         try {
             return (T) VomUtil.decode(mValue, cls);
         } catch (VException e) {
-            throw new VError(e);
+            chainThrow("getting value from a WatchChange of collection",  mCollectionId, e);
+            throw new AssertionError("never happens");
         }
     }
 
diff --git a/syncbase/src/main/java/io/v/syncbase/core/VError.java b/syncbase/src/main/java/io/v/syncbase/core/VError.java
index 37d6938..8020ee1 100644
--- a/syncbase/src/main/java/io/v/syncbase/core/VError.java
+++ b/syncbase/src/main/java/io/v/syncbase/core/VError.java
@@ -4,10 +4,6 @@
 
 package io.v.syncbase.core;
 
-import java.util.Arrays;
-
-import io.v.v23.verror.VException;
-
 public class VError extends Exception {
     public static final String EXIST = "v.io/v23/verror.Exist";
     public static final String NO_EXIST = "v.io/v23/verror.NoExist";
@@ -22,13 +18,6 @@
     private VError() {
     }
 
-    public VError(VException e) {
-        this.id = e.getID();
-        this.actionCode = e.getAction().getValue();
-        this.message = e.getMessage();
-        this.stack = Arrays.toString(e.getStackTrace());
-    }
-
     public String toString() {
         return String.format("{\n  id: \"%s\"\n  actionCode: %d\n  message: \"%s\"\n  stack: \"%s\"}",
                 id, actionCode, message, stack);
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/Exceptions.java b/syncbase/src/main/java/io/v/syncbase/exception/Exceptions.java
new file mode 100644
index 0000000..c01821c
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/Exceptions.java
@@ -0,0 +1,150 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+import java.util.NoSuchElementException;
+import java.util.concurrent.CancellationException;
+
+import io.v.syncbase.Id;
+import io.v.syncbase.core.VError;
+import io.v.v23.verror.VException;
+import io.v.v23.verror.VException.ActionCode;
+
+import static io.v.v23.verror.VException.ActionCode.fromValue;
+
+/**
+ * Utility for exception chaining.
+ */
+public final class Exceptions {
+
+    private Exceptions() {
+    }
+
+    private static String baseName(String v23ErrorId) {
+        String[] tokens = v23ErrorId.split("\\.");
+        int n = tokens.length;
+        if (n < 1) {
+            return v23ErrorId;
+        }
+        return tokens[n - 1];
+    }
+
+    private static void chainThrow(String message, String goErrorId, ActionCode action, Exception
+            cause)
+            throws SyncbaseException {
+        if (goErrorId == null) {
+            goErrorId = "null";
+        }
+        String fullMessage = message + ": " + baseName(goErrorId);
+        switch (goErrorId) {
+            case "v.io/v23/verror.NotImplemented":
+                throw new UnsupportedOperationException(fullMessage, cause);
+
+            case "v.io/v23/verror.EndOfFile":
+                throw new SyncbaseEndOfFileException(fullMessage, cause);
+
+            case "v.io/v23/verror.BadArg":
+            case "v.io/v23/services/syncbase.InvalidName":
+            case "v.io/v23/services/syncbase.NotBoundToBatch":
+            case "v.io/v23/services/syncbase.ReadOnlyBatch":
+                throw new IllegalArgumentException(fullMessage, cause);
+
+            case "v.io/v23/verror.Exist":
+            case "v.io/v23/services/syncbase.NotInDevMode":
+            case "v.io/v23/services/syncbase.BlobNotCommitted":
+            case "v.io/v23/services/syncbase.InvalidPermissionsChange":
+            case "v.io/v23/verror.Aborted":
+                throw new IllegalStateException(fullMessage, cause);
+
+            case "v.io/v23/verror.NoExist":
+                throw withCause(new NoSuchElementException(fullMessage), cause);
+
+            case "v.io/v23/verror.Unknown":
+            case "v.io/v23/verror.Internal":
+            case "v.io/v23/verror.BadState":
+            case "v.io/v23/verror.UnknownMethod":
+            case "v.io/v23/verror.UnknownSuffix":
+            case "v.io/v23/verror.BadProtocol":
+            case "v.io/v23/services/syncbase.BadExecStreamHeader":
+                throw new SyncbaseInternalException(fullMessage, cause);
+
+            case "v.io/v23/verror.NoServers":
+            case "v.io/v23/services/syncbase.SyncgroupJoinFailed":
+                throw new SyncbaseNoServersException(fullMessage, cause);
+
+            case "v.io/v23/verror.Canceled":
+                throw withCause(new CancellationException(fullMessage), cause);
+
+            case "v.io/v23/verror.Timeout":
+                throw new SyncbaseRetryBackoffException(fullMessage, cause);
+
+            case "v.io/v23/services/syncbase.CorruptDatabase":
+                throw new SyncbaseRestartException(fullMessage, cause);
+
+            case "v.io/v23/verror.BadVersion":
+            case "v.io/v23/services/syncbase.ConcurrentBatch":
+            case "v.io/v23/services/syncbase.UnknownBatch":
+                throw new SyncbaseRaceException(fullMessage, cause);
+
+            case "v.io/v23/verror.NoAccess":
+            case "v.io/v23/verror.NotTrusted":
+            case "v.io/v23/verror.NoExistOrNoAccess":
+            case "v.io/v23/services/syncbase.UnauthorizedCreateId":
+            case "v.io/v23/services/syncbase.InferAppBlessingFailed":
+            case "v.io/v23/services/syncbase.InferUserBlessingFailed":
+            case "v.io/v23/services/syncbase.InferDefaultPermsFailed":
+                throw new SyncbaseSecurityException(fullMessage, cause);
+
+            default:
+                String fullerMessage = fullMessage + " (unexpected error ID " + goErrorId + ")";
+                // See https://godoc.org/v.io/v23/verror#ActionCode
+                switch (action) {
+                    case RETRY_REFETCH:
+                        throw new SyncbaseRetryRefetchException(fullerMessage, cause);
+                    case RETRY_BACKOFF:
+                        throw new SyncbaseRetryBackoffException(fullerMessage, cause);
+                    case RETRY_CONNECTION:
+                        throw new SyncbaseRetryConnectionException(fullerMessage, cause);
+                    case NO_RETRY:
+                    default:
+                        throw new SyncbaseInternalException(fullerMessage, cause);
+                }
+        }
+    }
+
+    private static void chainThrow(String javaMessage, String goMessage, String v23ErrorId,
+                                   ActionCode action, Exception cause) throws SyncbaseException {
+        chainThrow("while " + javaMessage + " got error " + goMessage, v23ErrorId, action, cause);
+    }
+
+    public static void chainThrow(String javaMessage, VError cause) throws SyncbaseException {
+        ActionCode action = fromValue((int) cause.actionCode);
+        chainThrow(javaMessage, cause.message, cause.id, action, cause);
+    }
+
+    public static void chainThrow(String javaMessage, VException cause) throws SyncbaseException {
+        chainThrow(javaMessage, cause.getMessage(), cause.getID(), cause.getAction(),
+                cause);
+    }
+
+    public static void chainThrow(String doing, Id where, VError cause) throws SyncbaseException {
+        chainThrow(doing + " " + where.getName(), cause);
+    }
+
+    public static void chainThrow(String doing, Id where, VException cause) throws
+            SyncbaseException {
+        chainThrow(doing + " " + where.getName(), cause);
+    }
+
+    public static void chainThrow(String doing, io.v.syncbase.core.Id where, VError cause) throws
+            SyncbaseException {
+        chainThrow(doing + " " + where.name, cause);
+    }
+
+    private static <T extends Exception> T withCause(T e, Exception cause) {
+        e.initCause(cause);
+        return e;
+    }
+}
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/README.md b/syncbase/src/main/java/io/v/syncbase/exception/README.md
new file mode 100644
index 0000000..b47dc62
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/README.md
@@ -0,0 +1,49 @@
+# Syncbase Exceptions
+
+The methods in the `io.v.syncbase` package can throw the following exceptions:
+
+*   java.lang.Exception
+    *   *io.v.syncbase.exception.SyncbaseException*
+        *   *io.v.syncbase.exception.**SyncbaseEndOfFileException***
+        *   *io.v.syncbase.exception.**SyncbaseNoServersException***
+        *   *io.v.syncbase.exception.**SyncbaseRaceException***
+        *   *io.v.syncbase.exception.**SyncbaseRestartException***
+        *   *io.v.syncbase.exception.**SyncbaseRetryBackoffException***
+        *   *io.v.syncbase.exception.**SyncbaseRetryFetchException***
+        *   *io.v.syncbase.exception.**SyncbaseSecurityException***
+    *   java.lang.RuntimeException
+        *   *io.v.syncbase.exception.**SyncbaseInternalException***
+        *   java.lang.**IllegalArgumentException**
+        *   java.lang.**IllegalStateException**
+            *   java.util.concurrent.**CancellationException**
+        *   java.util.**NoSuchElementException**
+        *   java.lang.**UnsupportedOperationException**
+
+
+## Exceptions Detected in Java
+
+Some particular method in the `io.v.syncbase` package can throw these exceptions:
+
+| Method Thrown From | Exception |
+| ------------------ |-----------|
+| `Syncgroup.getAccessList` or `Collection.getAccessList` (various inconsistencies in permissions) or<br/>  `Syncbase.login` (unsupported authentication provider) | SyncbaseSecurityException |
+| `BatchDatabase.collection` (opts.withoutSyncgroup parameter is false),<br/> `Collection.getSyncgroup` (Collection is in a batch),<br/>  `Database.syncgroup` (no collections or collection without creator), or<br/>  `Id.decode` (invalid encoded ID) | IllegalArgumentException |
+
+## Exceptions Caused by Underlying Vanadium Errors
+
+Any method in the `io.v.syncbase` package can throw any of these exceptions:
+
+| Vanadium Error | Exception |
+| -------------- | --------- |
+| EndOfFile | SyncbaseEndOfFileException |
+| NoServers SyncgroupJoinFailed | SyncbaseNoServersException |
+| BadVersion ConcurrentBatch UnknownBatch | SyncbaseRaceException |
+| CorruptDatabase | SyncbaseRestartException |
+| Timeout | SyncbaseRetryBackoffException |
+| NoAccess NotTrusted NoExistOrNoAccess UnauthorizedCreateId InferAppBlessingFailed InferUserBlessingFailed InferDefaultPermsFailed | SyncbaseSecurityException |
+| Unknown Internal BadState UnknownMethod UnknownSuffix BadProtocol BadExecStreamHeader | SyncbaseInternalException |
+| BadArg InvalidName NotBoundToBatch ReadOnlyBatch | IllegalArgumentException |
+| Exist NotInDevMode BlobNotCommitted InvalidPermissionsChange Aborted | IllegalStateException |
+| Canceled | CancellationException |
+| NoExist | NoSuchElementException |
+| NotImplemented | UnsupportedOperationException |
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseEndOfFileException.java b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseEndOfFileException.java
new file mode 100644
index 0000000..10c474f
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseEndOfFileException.java
@@ -0,0 +1,14 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+/**
+ * Thrown in response to Vanadium error: EndOfFile.
+ */
+public class SyncbaseEndOfFileException extends SyncbaseException {
+    SyncbaseEndOfFileException(String message, Exception cause) {
+        super(message, cause);
+    }
+}
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseException.java b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseException.java
new file mode 100644
index 0000000..7a4038f
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseException.java
@@ -0,0 +1,14 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+/**
+ * A Syncbase error that the client code might want to handle, depending on subclass thrown.
+ */
+public abstract class SyncbaseException extends Exception {
+    SyncbaseException(String message, Exception cause) {
+        super(message, cause);
+    }
+}
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseInternalException.java b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseInternalException.java
new file mode 100644
index 0000000..af90546
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseInternalException.java
@@ -0,0 +1,18 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+/**
+ * An internal error within Syncbase.
+ */
+public class SyncbaseInternalException extends RuntimeException {
+    public SyncbaseInternalException(String message) {
+        super(message);
+    }
+
+    SyncbaseInternalException(String message, Exception cause) {
+        super(message, cause);
+    }
+}
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseNoServersException.java b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseNoServersException.java
new file mode 100644
index 0000000..f47b9ae
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseNoServersException.java
@@ -0,0 +1,14 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+/**
+ * Thrown in response to Vanadium errors: NoServers or SyncgroupJoinFailed.
+ */
+public class SyncbaseNoServersException extends SyncbaseException {
+    SyncbaseNoServersException(String message, Exception cause) {
+        super(message, cause);
+    }
+}
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRaceException.java b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRaceException.java
new file mode 100644
index 0000000..18b48d3
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRaceException.java
@@ -0,0 +1,15 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+/**
+ * Thrown in response to Vanadium errors: BadVersion, ConcurrentBatch, or UnknownBatch. The failing
+ * method may possibly succeed later if retried.
+ */
+public class SyncbaseRaceException extends SyncbaseException {
+    SyncbaseRaceException(String message, Exception cause) {
+        super(message, cause);
+    }
+}
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRestartException.java b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRestartException.java
new file mode 100644
index 0000000..ad672bf
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRestartException.java
@@ -0,0 +1,15 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+/**
+ * Thrown in response to Vanadium error: CorruptDatabase.  The client code may choose to
+ * re-initialize the local Syncbase, deleting local information.
+ */
+public class SyncbaseRestartException extends SyncbaseException {
+    SyncbaseRestartException(String message, Exception cause) {
+        super(message, cause);
+    }
+}
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRetryBackoffException.java b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRetryBackoffException.java
new file mode 100644
index 0000000..db1090f
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRetryBackoffException.java
@@ -0,0 +1,15 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+/**
+ * Thrown in response to Vanadium errors: Timeout, or some other errors that have the RetryBackoff
+ * action code. The failing method may possibly succeed if retried after a delay.
+ */
+public class SyncbaseRetryBackoffException extends SyncbaseException {
+    SyncbaseRetryBackoffException(String message, Exception cause) {
+        super(message, cause);
+    }
+}
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRetryConnectionException.java b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRetryConnectionException.java
new file mode 100644
index 0000000..ada78b8
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRetryConnectionException.java
@@ -0,0 +1,15 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+/**
+ * Thrown in response to some other errors that have the RetryConnection action code. The failing
+ * method may possibly succeed if retried after the connection is re-established.
+ */
+public class SyncbaseRetryConnectionException extends SyncbaseException {
+    SyncbaseRetryConnectionException(String message, Exception cause) {
+        super(message, cause);
+    }
+}
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRetryRefetchException.java b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRetryRefetchException.java
new file mode 100644
index 0000000..77cd961
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseRetryRefetchException.java
@@ -0,0 +1,15 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+/**
+ * Thrown in response to some other errors that have the RetryRefetch action code. The failing
+ * method may possibly succeed if retried after fetching update versions of the parameters.
+ */
+public class SyncbaseRetryRefetchException extends SyncbaseException {
+    SyncbaseRetryRefetchException(String message, Exception cause) {
+        super(message, cause);
+    }
+}
diff --git a/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseSecurityException.java b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseSecurityException.java
new file mode 100644
index 0000000..f4a0984
--- /dev/null
+++ b/syncbase/src/main/java/io/v/syncbase/exception/SyncbaseSecurityException.java
@@ -0,0 +1,16 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase.exception;
+
+/**
+ * Thrown in response to various inconsistencies in permissions, or unsupported authentication
+ * provider, or Vanadium errors: NoAccess, NotTrusted, NoExistOrNoAccess, UnauthorizedCreateId,
+ * InferAppBlessingFailed, InferUserBlessingFailed, or InferDefaultPermsFailed.
+ */
+public class SyncbaseSecurityException extends SyncbaseException {
+    SyncbaseSecurityException(String message, Exception cause) {
+        super(message, cause);
+    }
+}
diff --git a/syncbase/src/test/java/io/v/syncbase/BatchDatabaseTest.java b/syncbase/src/test/java/io/v/syncbase/BatchDatabaseTest.java
new file mode 100644
index 0000000..c598c1a
--- /dev/null
+++ b/syncbase/src/test/java/io/v/syncbase/BatchDatabaseTest.java
@@ -0,0 +1,50 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+
+import io.v.syncbase.DatabaseHandle.CollectionOptions;
+import io.v.syncbase.exception.SyncbaseException;
+
+import static io.v.syncbase.TestUtil.setUpDatabase;
+
+public class BatchDatabaseTest {
+    @Rule
+    public final TemporaryFolder folder = new TemporaryFolder();
+    @Rule
+    public ExpectedException thrown = ExpectedException.none();
+
+    @Before
+    public void setUp() throws IOException, InterruptedException, ExecutionException,
+            SyncbaseException {
+        setUpDatabase(folder.newFolder());
+    }
+
+    @After
+    public void tearDown() {
+        Syncbase.shutdown();
+    }
+
+    @Test
+    public void attemptCreateSyncgroupWithinBatch() throws SyncbaseException,
+            ExecutionException, InterruptedException {
+        BatchDatabase batch = Syncbase.database().beginBatch(new Database.BatchOptions());
+
+        thrown.expect(IllegalArgumentException.class);
+        thrown.expectMessage("Cannot create syncgroup in a batch");
+
+        batch.collection("aCollection", new CollectionOptions());
+    }
+
+}
diff --git a/syncbase/src/test/java/io/v/syncbase/CollectionTest.java b/syncbase/src/test/java/io/v/syncbase/CollectionTest.java
new file mode 100644
index 0000000..76312e7
--- /dev/null
+++ b/syncbase/src/test/java/io/v/syncbase/CollectionTest.java
@@ -0,0 +1,69 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+
+import io.v.syncbase.DatabaseHandle.CollectionOptions;
+import io.v.syncbase.exception.SyncbaseException;
+
+import static io.v.syncbase.TestUtil.setUpDatabase;
+
+public class CollectionTest {
+    @Rule
+    public final TemporaryFolder folder = new TemporaryFolder();
+    @Rule
+    public ExpectedException thrown = ExpectedException.none();
+
+    private static final Executor sameThreadExecutor = new Executor() {
+        public void execute(Runnable runnable) {
+            runnable.run();
+        }
+    };
+
+    @Before
+    public void setUp() throws IOException, InterruptedException, ExecutionException,
+            SyncbaseException {
+        setUpDatabase(folder.newFolder());
+    }
+
+    @After
+    public void tearDown() {
+        Syncbase.shutdown();
+    }
+
+    @Test
+    public void badName() throws SyncbaseException,
+            ExecutionException, InterruptedException {
+        thrown.expect(IllegalArgumentException.class);
+        thrown.expectMessage("invalid name");
+
+        // Create with invalid name
+        Syncbase.database().collection("name with spaces", new CollectionOptions());
+    }
+
+    @Test
+    public void attemptGetSyncgroupWithinBatch() throws SyncbaseException,
+            ExecutionException, InterruptedException {
+        BatchDatabase batch = Syncbase.database().beginBatch(new Database.BatchOptions());
+
+        // Create collection without a syncgroup
+        Collection collection = batch.collection("collectionName",
+                new CollectionOptions().setWithoutSyncgroup(true));
+
+        thrown.expect(IllegalArgumentException.class);
+        thrown.expectMessage("Must not call getSyncgroup within batch");
+        collection.getSyncgroup();
+    }
+}
diff --git a/syncbase/src/test/java/io/v/syncbase/DatabaseTest.java b/syncbase/src/test/java/io/v/syncbase/DatabaseTest.java
new file mode 100644
index 0000000..ef21baa
--- /dev/null
+++ b/syncbase/src/test/java/io/v/syncbase/DatabaseTest.java
@@ -0,0 +1,48 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.concurrent.ExecutionException;
+
+import io.v.syncbase.exception.SyncbaseException;
+
+import static io.v.syncbase.TestUtil.setUpDatabase;
+
+public class DatabaseTest {
+    @Rule
+    public final TemporaryFolder folder = new TemporaryFolder();
+    @Rule
+    public ExpectedException thrown = ExpectedException.none();
+
+    @Before
+    public void setUp() throws Exception {
+        setUpDatabase(folder.newFolder());
+    }
+
+    @After
+    public void tearDown() {
+        Syncbase.shutdown();
+    }
+
+    @Test
+    public void noCollections() throws IOException, SyncbaseException,
+            ExecutionException, InterruptedException {
+        thrown.expect(IllegalArgumentException.class);
+        thrown.expectMessage("No collections");
+
+        // Try to create a syncgroup with no collections
+        Syncbase.database().syncgroup("aSyncgroup", new ArrayList<Collection>());
+    }
+
+}
diff --git a/syncbase/src/test/java/io/v/syncbase/ExceptionsTest.java b/syncbase/src/test/java/io/v/syncbase/ExceptionsTest.java
new file mode 100644
index 0000000..44c4949
--- /dev/null
+++ b/syncbase/src/test/java/io/v/syncbase/ExceptionsTest.java
@@ -0,0 +1,164 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase;
+
+import junit.framework.Assert;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.NoSuchElementException;
+
+import io.v.syncbase.core.VError;
+import io.v.syncbase.exception.SyncbaseException;
+import io.v.syncbase.exception.SyncbaseRaceException;
+
+import static com.google.common.truth.Truth.assertThat;
+import static io.v.syncbase.exception.Exceptions.chainThrow;
+
+public class ExceptionsTest {
+    @Rule
+    public ExpectedException thrown = ExpectedException.none();
+
+    /**
+     * Make VError, using reflection to call private constructor.
+     */
+    private static VError newVError(String message, String goErrorId) throws
+            IllegalAccessException, InvocationTargetException,
+            InstantiationException, NoSuchMethodException {
+        Constructor<VError> constructor = VError.class.getDeclaredConstructor();
+        constructor.setAccessible(true);
+        VError e = constructor.newInstance();
+        e.message = message;
+        e.id = goErrorId;
+        return e;
+    }
+
+    @Test
+    public void exist() throws SyncbaseException, IllegalAccessException, InstantiationException,
+            NoSuchMethodException, InvocationTargetException {
+        VError cause = newVError("bar", VError.EXIST);
+
+        thrown.expect(IllegalStateException.class);
+        thrown.expectMessage("while fooing got error bar: Exist");
+
+        chainThrow("fooing", cause);
+    }
+
+    @Test
+    public void noExist() throws SyncbaseException, IllegalAccessException, InstantiationException,
+            NoSuchMethodException, InvocationTargetException {
+        VError cause = newVError("bar", VError.NO_EXIST);
+
+        thrown.expect(NoSuchElementException.class);
+        thrown.expectMessage("while fooing got error bar: NoExist");
+
+        chainThrow("fooing", cause);
+    }
+
+    @Test
+    public void concurrentBatch() throws SyncbaseException, IllegalAccessException,
+            InstantiationException,
+            NoSuchMethodException, InvocationTargetException {
+        VError cause = newVError("bar", VError.SYNCBASE_CONCURRENT_BATCH);
+
+        thrown.expect(SyncbaseRaceException.class);
+        thrown.expectMessage("while fooing got error bar: ConcurrentBatch");
+
+        chainThrow("fooing", cause);
+    }
+
+    @Test
+    public void allv23ErrorsFromGo() throws InvocationTargetException, NoSuchMethodException,
+            InstantiationException, IllegalAccessException {
+        // See https://godoc.org/v.io/v23/verror#pkg-variables
+        String[] v23Errors = {
+                "Unknown",
+                "Internal",
+                "NotImplemented",
+                "EndOfFile",
+                "BadArg",
+                "BadState",
+                "BadVersion",
+                "Exist",
+                "NoExist",
+                "UnknownMethod",
+                "UnknownSuffix",
+                "NoExistOrNoAccess",
+                "NoServers",
+                "NoAccess",
+                "NotTrusted",
+                "Aborted",
+                "BadProtocol",
+                "Canceled",
+                "Timeout",
+        };
+        for (String error : v23Errors) {
+            String errorId = "v.io/v23/verror." + error;
+
+            VError cause = newVError("bar", errorId);
+            try {
+                chainThrow("fooing", cause);
+                Assert.fail("Expected assertion to be thrown");
+            } catch (SyncbaseException e) {
+                assertThat(e.getCause()).isNotNull();
+                assertThat(e.getCause()).isInstanceOf(VError.class);
+                assertThat(e.getMessage().contains(": " + error));
+                assertThat(e.getMessage()).doesNotContain("unexpected error ID");
+            } catch (RuntimeException e) {
+                assertThat(e.getCause()).isNotNull();
+                assertThat(e.getCause()).isInstanceOf(VError.class);
+                assertThat(e.getMessage().contains(": " + error));
+                assertThat(e.getMessage()).doesNotContain("unexpected error ID");
+            }
+        }
+    }
+
+    @Test
+    public void allSyncbaseErrorsFromGo() throws InvocationTargetException, NoSuchMethodException,
+            InstantiationException, IllegalAccessException {
+        // See https://godoc.org/v.io/v23/verror#pkg-variables
+        String[] syncbaseErrors = {
+                "NotInDevMode",
+                "InvalidName",
+                "CorruptDatabase",
+                "UnknownBatch",
+                "NotBoundToBatch",
+                "ReadOnlyBatch",
+                "ConcurrentBatch",
+                "BlobNotCommitted",
+                "SyncgroupJoinFailed",
+                "BadExecStreamHeader",
+                "InvalidPermissionsChange",
+                "UnauthorizedCreateId",
+                "InferAppBlessingFailed",
+                "InferUserBlessingFailed",
+                "InferDefaultPermsFailed",
+        };
+        for (String error : syncbaseErrors) {
+            String errorId = "v.io/v23/services/syncbase." + error;
+
+            VError cause = newVError("bar", errorId);
+            try {
+                chainThrow("fooing", cause);
+                Assert.fail("Expected assertion to be thrown");
+            } catch (SyncbaseException e) {
+                assertThat(e.getCause()).isNotNull();
+                assertThat(e.getCause()).isInstanceOf(VError.class);
+                assertThat(e.getMessage().contains(": " + error));
+                assertThat(e.getMessage()).doesNotContain("unexpected error ID");
+            } catch (RuntimeException e) {
+                assertThat(e.getCause()).isNotNull();
+                assertThat(e.getCause()).isInstanceOf(VError.class);
+                assertThat(e.getMessage().contains(": " + error));
+                assertThat(e.getMessage()).doesNotContain("unexpected error ID");
+            }
+        }
+    }
+
+}
diff --git a/syncbase/src/test/java/io/v/syncbase/IdTest.java b/syncbase/src/test/java/io/v/syncbase/IdTest.java
new file mode 100644
index 0000000..f801aa1
--- /dev/null
+++ b/syncbase/src/test/java/io/v/syncbase/IdTest.java
@@ -0,0 +1,35 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import static com.google.common.truth.Truth.assertThat;
+
+public class IdTest {
+    @Rule
+    public ExpectedException thrown = ExpectedException.none();
+
+    @Test
+    public void goodEncoding() {
+        Id original = new Id("aBlessing", "aName");
+        String encoded = original.encode();
+
+        Id decoded = Id.decode(encoded);
+
+        assertThat(decoded.getBlessing()).isEqualTo("aBlessing");
+        assertThat(decoded.getName()).isEqualTo("aName");
+    }
+
+    @Test
+    public void invalidEncoding() {
+        thrown.expect(IllegalArgumentException.class);
+        thrown.expectMessage("Invalid encoded ID");
+
+        Id.decode("not valid");
+    }
+}
diff --git a/syncbase/src/test/java/io/v/syncbase/SyncbaseTest.java b/syncbase/src/test/java/io/v/syncbase/SyncbaseTest.java
index 72a2431..82f410e 100644
--- a/syncbase/src/test/java/io/v/syncbase/SyncbaseTest.java
+++ b/syncbase/src/test/java/io/v/syncbase/SyncbaseTest.java
@@ -11,6 +11,7 @@
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.ExpectedException;
 import org.junit.rules.TemporaryFolder;
 
 import java.util.ArrayList;
@@ -21,32 +22,29 @@
 
 import io.v.syncbase.core.Permissions;
 import io.v.syncbase.core.VError;
+import io.v.syncbase.exception.SyncbaseException;
 
 import static com.google.common.truth.Truth.assertThat;
+import static io.v.syncbase.TestUtil.createDatabase;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
 public class SyncbaseTest {
     @Rule
     public final TemporaryFolder folder = new TemporaryFolder();
+    @Rule
+    public ExpectedException thrown = ExpectedException.none();
 
     // To run these tests from Android Studio, add the following VM option to the default JUnit
     // build configuration, via Run > Edit Configurations... > Defaults > JUnit > VM options:
     // -Djava.library.path=/Users/sadovsky/vanadium/release/java/syncbase/build/libs
     @Before
     public void setUp() throws Exception {
-        Syncbase.Options opts = new Syncbase.Options();
-        opts.rootDir = folder.newFolder().getAbsolutePath();
-        opts.disableUserdataSyncgroup = true;
-        opts.disableSyncgroupPublishing = true;
-        opts.testLogin = true;
-        // Unlike Android apps, the test doesn't have a looper/handler, so use a different executor.
-        opts.callbackExecutor = Executors.newCachedThreadPool();
-        Syncbase.init(opts);
+        TestUtil.setUpSyncbase(folder.newFolder());
     }
 
     @After
@@ -54,26 +52,7 @@
         Syncbase.shutdown();
     }
 
-    private Database createDatabase() throws Exception {
-        final SettableFuture<Void> future = SettableFuture.create();
-
-        Syncbase.login("", "", new Syncbase.LoginCallback() {
-            @Override
-            public void onSuccess() {
-                future.set(null);
-            }
-
-            @Override
-            public void onError(Throwable e) {
-                future.setException(e);
-            }
-        });
-
-        future.get(5, TimeUnit.SECONDS);
-        return Syncbase.database();
-    }
-
-    private static Iterable<Id> getCollectionIds(Database db) throws VError {
+    private static Iterable<Id> getCollectionIds(Database db) throws SyncbaseException {
         List<Id> res = new ArrayList<>();
         for (Iterator<Collection> it = db.getCollections(); it.hasNext(); ) {
             res.add(it.next().getId());
@@ -81,7 +60,7 @@
         return res;
     }
 
-    private static Iterable<Id> getSyncgroupIds(Database db) throws VError {
+    private static Iterable<Id> getSyncgroupIds(Database db) throws SyncbaseException {
         List<Id> res = new ArrayList<>();
         for (Iterator<Syncgroup> it = db.getSyncgroups(); it.hasNext(); ) {
             res.add(it.next().getId());
@@ -252,9 +231,9 @@
                     DatabaseHandle.CollectionOptions opts = new DatabaseHandle.CollectionOptions()
                             .setWithoutSyncgroup(true);
                     db.collection("c", opts).put("foo", 10);
-                } catch (VError vError) {
-                    vError.printStackTrace();
-                    fail(vError.toString());
+                } catch (SyncbaseException e) {
+                    e.printStackTrace();
+                    fail(e.toString());
                 }
             }
         });
@@ -337,4 +316,20 @@
             // This is supposed to fail.
         }
     }
-}
\ No newline at end of file
+
+    @Test
+    public void unsupportedAuthenticationProvider() {
+        thrown.expect(IllegalArgumentException.class);
+        thrown.expectMessage("Unsupported provider: bogusProvider");
+
+        Syncbase.login("", "bogusProvider", new Syncbase.LoginCallback() {
+            @Override
+            public void onSuccess() {
+            }
+
+            @Override
+            public void onError(Throwable e) {
+            }
+        });
+    }
+}
diff --git a/syncbase/src/test/java/io/v/syncbase/TestUtil.java b/syncbase/src/test/java/io/v/syncbase/TestUtil.java
new file mode 100644
index 0000000..37ec0ce
--- /dev/null
+++ b/syncbase/src/test/java/io/v/syncbase/TestUtil.java
@@ -0,0 +1,58 @@
+// Copyright 2016 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.syncbase;
+
+import com.google.common.util.concurrent.SettableFuture;
+
+import java.io.File;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+
+import io.v.syncbase.exception.SyncbaseException;
+
+class TestUtil {
+    private static final Executor sameThreadExecutor = new Executor() {
+        public void execute(Runnable runnable) {
+            runnable.run();
+        }
+    };
+
+    static Database createDatabase() throws ExecutionException, InterruptedException,
+            SyncbaseException {
+        final SettableFuture<Void> future = SettableFuture.create();
+
+        Syncbase.login("", "", new Syncbase.LoginCallback() {
+            @Override
+            public void onSuccess() {
+                future.set(null);
+            }
+
+            @Override
+            public void onError(Throwable e) {
+                future.setException(e);
+            }
+        });
+
+        future.get();
+        return Syncbase.database();
+    }
+
+    static void setUpSyncbase(File folder) throws SyncbaseException, ExecutionException,
+            InterruptedException {
+        Syncbase.Options opts = new Syncbase.Options();
+        opts.rootDir = folder.getAbsolutePath();
+        opts.disableUserdataSyncgroup = true;
+        opts.disableSyncgroupPublishing = true;
+        opts.testLogin = true;
+        opts.callbackExecutor = sameThreadExecutor;
+        Syncbase.init(opts);
+    }
+
+    static void setUpDatabase(File folder) throws SyncbaseException, ExecutionException,
+            InterruptedException {
+        setUpSyncbase(folder);
+        createDatabase();
+    }
+}