Bug 1343726 - Respect max_record_payload_bytes limit while uploading records r=rnewman draft
authorGrigory Kruglov <gkruglov@mozilla.com>
Wed, 15 Mar 2017 13:51:31 -0700
changeset 555728 18920cfe7b7599be1984c53ebc0c9897c98fb7d9
parent 555725 b043233ec04f06768d59dcdfb9e928142280f3cc
child 555729 24f1d3d6aa2ee3b6777dc38abdd1e01aba5213c2
push id52312
push userbmo:gkruglov@mozilla.com
push dateTue, 04 Apr 2017 18:49:32 +0000
reviewersrnewman
bugs1343726
milestone55.0a1
Bug 1343726 - Respect max_record_payload_bytes limit while uploading records r=rnewman If we try to upload a record whose payload BSO field is larger than the limit specified by the server, fail that record during BatchingUploader's processing. Consequently, Synchronizer will fail current sync stage and advance to the next. Previous behaviour was to essentially rely on the server to fail our POST request, at which point we'll fail current sync stage. So in a way, this is an optimization to avoid making network requests which we know will fail. MozReview-Commit-ID: 5aJRRNoUuXe
mobile/android/services/src/main/java/org/mozilla/gecko/sync/CryptoRecord.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoConfiguration.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/Record.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploader.java
mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/testhelpers/MockRecord.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockRecord.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploaderTest.java
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CryptoRecord.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CryptoRecord.java
@@ -36,17 +36,18 @@ import org.mozilla.gecko.util.StringUtil
  * <code>CryptoRecord</code> uses <code>CryptoInfo</code> to do the actual
  * encryption and decryption.
  */
 public class CryptoRecord extends Record {
 
   // JSON related constants.
   private static final String KEY_ID         = "id";
   private static final String KEY_COLLECTION = "collection";
-  private static final String KEY_PAYLOAD    = "payload";
+  // We need to pluck out payload for size checks during upload to a Sync Storage server.
+  public static final String KEY_PAYLOAD    = "payload";
   private static final String KEY_MODIFIED   = "modified";
   private static final String KEY_SORTINDEX  = "sortindex";
   private static final String KEY_TTL        = "ttl";
   private static final String KEY_CIPHERTEXT = "ciphertext";
   private static final String KEY_HMAC       = "hmac";
   private static final String KEY_IV         = "IV";
 
   /**
@@ -234,16 +235,17 @@ public class CryptoRecord extends Record
   }
 
   @Override
   protected void initFromPayload(ExtendedJSONObject payload) {
     throw new IllegalStateException("Can't do this with a CryptoRecord.");
   }
 
   // TODO: this only works with encrypted object, and has other limitations.
+  @Override
   public JSONObject toJSONObject() {
     ExtendedJSONObject o = new ExtendedJSONObject();
     o.put(KEY_PAYLOAD, payload.toJSONString());
     o.put(KEY_ID,      this.guid);
     if (this.ttl > 0) {
       o.put(KEY_TTL, this.ttl);
     }
     return o.object;
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoConfiguration.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/InfoConfiguration.java
@@ -23,60 +23,68 @@ import org.mozilla.gecko.background.comm
  *   POST request.
  *
  * - <bold>max_total_records</bold>: the maximum number of records that can be
  *   uploaded to a collection as part of a batched upload.
  *
  * - <bold>max_total_bytes</bold>: the maximum combined size in bytes of the
  *   record payloads that can be uploaded to a collection as part of
  *   a batched upload.
+ *
+ * - <bold>max_record_payload_bytes</bold>: the maximum size of an individual
+ *   BSO payload, in bytes.
  */
 public class InfoConfiguration {
     private static final String LOG_TAG = "InfoConfiguration";
 
     public static final String MAX_REQUEST_BYTES = "max_request_bytes";
     public static final String MAX_POST_RECORDS = "max_post_records";
     public static final String MAX_POST_BYTES = "max_post_bytes";
     public static final String MAX_TOTAL_RECORDS = "max_total_records";
     public static final String MAX_TOTAL_BYTES = "max_total_bytes";
+    public static final String MAX_PAYLOAD_BYTES = "max_record_payload_bytes";
 
     private static final long DEFAULT_MAX_REQUEST_BYTES = 1048576;
     private static final long DEFAULT_MAX_POST_RECORDS = 100;
     private static final long DEFAULT_MAX_POST_BYTES = 1048576;
     private static final long DEFAULT_MAX_TOTAL_RECORDS = 10000;
     private static final long DEFAULT_MAX_TOTAL_BYTES = 104857600;
+    private static final long DEFAULT_MAX_PAYLOAD_BYTES = 262144;
 
     // While int's upper range is (2^31-1), which in bytes is equivalent to 2.147 GB, let's be optimistic
     // about the future and use long here, so that this code works if the server decides its clients are
     // all on fiber and have congress-library sized bookmark collections.
     // Record counts are long for the sake of simplicity.
     public final long maxRequestBytes;
     public final long maxPostRecords;
     public final long maxPostBytes;
     public final long maxTotalRecords;
     public final long maxTotalBytes;
+    public final long maxPayloadBytes;
 
     public InfoConfiguration() {
         Logger.debug(LOG_TAG, "info/configuration is unavailable, using defaults");
 
         maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES;
         maxPostRecords = DEFAULT_MAX_POST_RECORDS;
         maxPostBytes = DEFAULT_MAX_POST_BYTES;
         maxTotalRecords = DEFAULT_MAX_TOTAL_RECORDS;
         maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES;
+        maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES;
     }
 
     public InfoConfiguration(final ExtendedJSONObject record) {
         Logger.debug(LOG_TAG, "info/configuration is " + record.toJSONString());
 
         maxRequestBytes = getValueFromRecord(record, MAX_REQUEST_BYTES, DEFAULT_MAX_REQUEST_BYTES);
         maxPostRecords = getValueFromRecord(record, MAX_POST_RECORDS, DEFAULT_MAX_POST_RECORDS);
         maxPostBytes = getValueFromRecord(record, MAX_POST_BYTES, DEFAULT_MAX_POST_BYTES);
         maxTotalRecords = getValueFromRecord(record, MAX_TOTAL_RECORDS, DEFAULT_MAX_TOTAL_RECORDS);
         maxTotalBytes = getValueFromRecord(record, MAX_TOTAL_BYTES, DEFAULT_MAX_TOTAL_BYTES);
+        maxPayloadBytes = getValueFromRecord(record, MAX_PAYLOAD_BYTES, DEFAULT_MAX_PAYLOAD_BYTES);
     }
 
     private static Long getValueFromRecord(ExtendedJSONObject record, String key, long defaultValue) {
         if (!record.containsKey(key)) {
             return defaultValue;
         }
 
         try {
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/Record.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/domain/Record.java
@@ -1,16 +1,17 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync.repositories.domain;
 
 import java.io.UnsupportedEncodingException;
 
+import org.json.simple.JSONObject;
 import org.mozilla.gecko.sync.CryptoRecord;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 
 /**
  * Record is the abstract base class for all entries that Sync processes:
  * bookmarks, passwords, history, and such.
  *
  * A Record can be initialized from or serialized to a CryptoRecord for
@@ -239,19 +240,24 @@ public abstract class Record {
     return rec;
   }
 
   @SuppressWarnings("static-method")
   public String toJSONString() {
     throw new RuntimeException("Cannot JSONify non-CryptoRecord Records.");
   }
 
-  public byte[] toJSONBytes() {
+  @SuppressWarnings("static-method")
+  public JSONObject toJSONObject() {
+    throw new RuntimeException("Cannot JSONify non-CryptoRecord Records.");
+  }
+
+  public static byte[] stringToJSONBytes(String in) {
     try {
-      return this.toJSONString().getBytes("UTF-8");
+      return in.getBytes("UTF-8");
     } catch (UnsupportedEncodingException e) {
       // Can't happen.
       return null;
     }
   }
 
   /**
    * Utility for safely populating an output CryptoRecord.
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploader.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploader.java
@@ -2,27 +2,27 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync.repositories.uploaders;
 
 import android.net.Uri;
 import android.support.annotation.VisibleForTesting;
 
+import org.json.simple.JSONObject;
 import org.mozilla.gecko.background.common.log.Logger;
-import org.mozilla.gecko.sync.CollectionConcurrentModificationException;
+import org.mozilla.gecko.sync.CryptoRecord;
 import org.mozilla.gecko.sync.InfoConfiguration;
 import org.mozilla.gecko.sync.Server15PreviousPostFailedException;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.repositories.RepositorySession;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
 import org.mozilla.gecko.sync.repositories.domain.Record;
 
 import java.util.ArrayList;
-import java.util.concurrent.Executor;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.atomic.AtomicLong;
 
 /**
  * Uploader which implements batching introduced in Sync 1.5.
  *
  * Batch vs payload terminology:
  * - batch is comprised of a series of payloads, which are all committed at the same time.
@@ -86,16 +86,26 @@ public class BatchingUploader {
     // Will be re-created, so mark it as volatile.
     private volatile UploaderMeta uploaderMeta;
 
     // Used to ensure we have thread-safe access to the following:
     // - byte and record counts in both Payload and BatchMeta objects
     // - buffers in the Payload object
     private final Object payloadLock = new Object();
 
+    // Maximum size of a BSO's payload field that server will accept during an upload.
+    // Our naming here is somewhat unfortunate, since we're calling two different things a "payload".
+    // In context of the uploader, a "payload" is an entirety of what gets POST-ed to the server in
+    // an individual request.
+    // In context of Sync Storage, "payload" is a BSO (basic storage object) field defined as: "a
+    // string containing the data of the record."
+    // Sync Storage servers place a hard limit on how large a payload _field_ might be, and so we
+    // maintain this limit for a single sanity check.
+    private final long maxPayloadFieldBytes;
+
     public BatchingUploader(
             final RepositorySession repositorySession, final ExecutorService workQueue,
             final RepositorySessionStoreDelegate sessionStoreDelegate, final Uri baseCollectionUri,
             final Long localCollectionLastModified, final InfoConfiguration infoConfiguration,
             final AuthHeaderProvider authHeaderProvider) {
         this.repositorySession = repositorySession;
         this.sessionStoreDelegate = sessionStoreDelegate;
         this.collectionUri = baseCollectionUri;
@@ -103,45 +113,75 @@ public class BatchingUploader {
 
         this.uploaderMeta = new UploaderMeta(
                 payloadLock, infoConfiguration.maxTotalBytes, infoConfiguration.maxTotalRecords);
         this.payload = new Payload(
                 payloadLock, infoConfiguration.maxPostBytes, infoConfiguration.maxPostRecords);
 
         this.payloadDispatcher = createPayloadDispatcher(workQueue, localCollectionLastModified);
 
+        this.maxPayloadFieldBytes = infoConfiguration.maxPayloadBytes;
+
         this.executor = workQueue;
     }
 
     // Called concurrently from the threads running off of a record consumer thread pool.
     public void process(final Record record) {
         final String guid = record.guid;
 
         // If store failed entirely, just bail out. We've already told our delegate that we failed.
         if (payloadDispatcher.storeFailed) {
             return;
         }
 
-        // If a record or a payload failed, we won't let subsequent requests proceed.'
+        // If a record or a payload failed, we won't let subsequent requests proceed.
         // This means that we may bail much earlier.
         if (payloadDispatcher.recordUploadFailed) {
             sessionStoreDelegate.deferredStoreDelegate(executor).onRecordStoreFailed(
                     new Server15PreviousPostFailedException(), guid
             );
             return;
         }
 
-        final byte[] recordBytes = record.toJSONBytes();
+        final JSONObject recordJSON = record.toJSONObject();
+
+        final String payloadField = (String) recordJSON.get(CryptoRecord.KEY_PAYLOAD);
+        if (payloadField == null) {
+            sessionStoreDelegate.deferredStoreDelegate(executor).onRecordStoreFailed(
+                    new IllegalRecordException(), guid
+            );
+            return;
+        }
+
+        // We can't upload individual records whose payload fields exceed our payload field byte limit.
+        // UTF-8 uses 1 byte per character for the ASCII range. Contents of the payloadField are
+        // base64 and hex encoded, so character count is sufficient.
+        if (payloadField.length() > this.maxPayloadFieldBytes) {
+            sessionStoreDelegate.deferredStoreDelegate(executor).onRecordStoreFailed(
+                    new PayloadTooLargeToUpload(), guid
+            );
+            return;
+        }
+
+        final byte[] recordBytes = Record.stringToJSONBytes(recordJSON.toJSONString());
+        if (recordBytes == null) {
+            sessionStoreDelegate.deferredStoreDelegate(executor).onRecordStoreFailed(
+                    new IllegalRecordException(), guid
+            );
+            return;
+        }
+
         final long recordDeltaByteCount = recordBytes.length + PER_RECORD_OVERHEAD_BYTE_COUNT;
-
         Logger.debug(LOG_TAG, "Processing a record with guid: " + guid);
 
-        // We can't upload individual records which exceed our payload byte limit.
+        // We can't upload individual records which exceed our payload total byte limit.
         if ((recordDeltaByteCount + PER_PAYLOAD_OVERHEAD_BYTE_COUNT) > payload.maxBytes) {
-            sessionStoreDelegate.onRecordStoreFailed(new RecordTooLargeToUpload(), guid);
+            sessionStoreDelegate.deferredStoreDelegate(executor).onRecordStoreFailed(
+                    new RecordTooLargeToUpload(), guid
+            );
             return;
         }
 
         synchronized (payloadLock) {
             final boolean canFitRecordIntoBatch = uploaderMeta.canFit(recordDeltaByteCount);
             final boolean canFitRecordIntoPayload = payload.canFit(recordDeltaByteCount);
 
             // Record fits!
@@ -242,24 +282,34 @@ public class BatchingUploader {
     /**
      * Allows tests to define their own PayloadDispatcher.
      */
     @VisibleForTesting
     PayloadDispatcher createPayloadDispatcher(ExecutorService workQueue, Long localCollectionLastModified) {
         return new PayloadDispatcher(workQueue, this, localCollectionLastModified);
     }
 
-    public static class BatchingUploaderException extends Exception {
+    /* package-local */ static class BatchingUploaderException extends Exception {
         private static final long serialVersionUID = 1L;
     }
     /* package-local */ static class LastModifiedDidNotChange extends BatchingUploaderException {
         private static final long serialVersionUID = 1L;
     }
     /* package-local */ static class LastModifiedChangedUnexpectedly extends BatchingUploaderException {
         private static final long serialVersionUID = 1L;
     }
     /* package-local */ static class TokenModifiedException extends BatchingUploaderException {
         private static final long serialVersionUID = 1L;
-    };
+    }
+    // We may choose what to do about these failures upstream on the delegate level. For now, if a
+    // record is failed by the uploader, current sync stage will be aborted - although, uploader
+    // doesn't depend on this behaviour.
     private static class RecordTooLargeToUpload extends BatchingUploaderException {
         private static final long serialVersionUID = 1L;
     }
+    @VisibleForTesting
+    /* package-local */ static class PayloadTooLargeToUpload extends BatchingUploaderException {
+        private static final long serialVersionUID = 1L;
+    }
+    private static class IllegalRecordException extends BatchingUploaderException {
+        private static final long serialVersionUID = 1L;
+    }
 }
\ No newline at end of file
--- a/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/testhelpers/MockRecord.java
+++ b/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/testhelpers/MockRecord.java
@@ -1,13 +1,14 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 package org.mozilla.gecko.background.testhelpers;
 
+import org.json.simple.JSONObject;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.repositories.domain.Record;
 
 public class MockRecord extends Record {
 
   public MockRecord(String guid, String collection, long lastModified, boolean deleted) {
     super(guid, collection, lastModified, deleted);
   }
@@ -24,11 +25,20 @@ public class MockRecord extends Record {
   public Record copyWithIDs(String guid, long androidID) {
     MockRecord r = new MockRecord(guid, this.collection, this.lastModified, this.deleted);
     r.androidID = androidID;
     return r;
   }
 
   @Override
   public String toJSONString() {
-    return "{\"id\":\"" + guid + "\", \"payload\": \"foo\"}";
+    return toJSONObject().toJSONString();
+  }
+
+  @Override
+  public JSONObject toJSONObject() {
+    final ExtendedJSONObject o = new ExtendedJSONObject();
+    o.put("payload", "foo");
+    o.put("id", guid);
+    o.put("ttl", 60);
+    return o.object;
   }
 }
\ No newline at end of file
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockRecord.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockRecord.java
@@ -1,13 +1,14 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 package org.mozilla.gecko.background.testhelpers;
 
+import org.json.simple.JSONObject;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.repositories.domain.Record;
 
 import java.util.Random;
 
 public class MockRecord extends Record {
   private final int payloadByteCount;
   public MockRecord(String guid, String collection, long lastModified, boolean deleted) {
@@ -34,18 +35,30 @@ public class MockRecord extends Record {
   public Record copyWithIDs(String guid, long androidID) {
     MockRecord r = new MockRecord(guid, this.collection, this.lastModified, this.deleted);
     r.androidID = androidID;
     return r;
   }
 
   @Override
   public String toJSONString() {
+    return toJSONObject().toJSONString();
+  }
+
+  @Override
+  public JSONObject toJSONObject() {
     // Build up a randomish payload string based on the length we were asked for.
     final Random random = new Random();
     final char[] payloadChars = new char[payloadByteCount];
     for (int i = 0; i < payloadByteCount; i++) {
       payloadChars[i] = (char) (random.nextInt(26) + 'a');
     }
     final String payloadString = new String(payloadChars);
-    return "{\"id\":\"" + guid + "\", \"payload\": \"" + payloadString+ "\"}";
+
+    final ExtendedJSONObject o = new ExtendedJSONObject();
+    o.put("payload", payloadString);
+    o.put("id",      this.guid);
+    if (this.ttl > 0) {
+      o.put("ttl", this.ttl);
+    }
+    return o.object;
   }
 }
\ No newline at end of file
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploaderTest.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/repositories/uploaders/BatchingUploaderTest.java
@@ -3,16 +3,17 @@
 
 package org.mozilla.gecko.sync.repositories.uploaders;
 
 import android.net.Uri;
 import android.os.SystemClock;
 import android.support.annotation.NonNull;
 
 import static org.junit.Assert.*;
+import static org.mockito.Mockito.mock;
 
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mozilla.gecko.background.testhelpers.MockRecord;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.InfoCollections;
@@ -121,42 +122,45 @@ public class BatchingUploaderTest {
         @Override
         public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {
             return null;
         }
     }
 
     class MockStoreDelegate implements RepositorySessionStoreDelegate {
         int storeFailed = 0;
-        int storeSucceeded = 0;
+        int recordStoreSucceeded = 0;
         int storeCompleted = 0;
+        Exception lastRecordStoreFailedException;
+        Exception lastStoreFailedException;
 
         @Override
         public void onRecordStoreFailed(Exception ex, String recordGuid) {
+            lastRecordStoreFailedException = ex;
             ++storeFailed;
         }
 
         @Override
         public void onRecordStoreSucceeded(String guid) {
-            ++storeSucceeded;
+            ++recordStoreSucceeded;
         }
 
         @Override
         public void onStoreCompleted(long storeEnd) {
             ++storeCompleted;
         }
 
         @Override
         public void onStoreFailed(Exception e) {
-
+            lastStoreFailedException = e;
         }
 
         @Override
         public RepositorySessionStoreDelegate deferredStoreDelegate(ExecutorService executor) {
-            return null;
+            return this;
         }
     }
 
     private ExecutorService workQueue;
     private RepositorySessionStoreDelegate storeDelegate;
 
     @Before
     public void setUp() throws Exception {
@@ -221,17 +225,17 @@ public class BatchingUploaderTest {
                 // 13th
                 target.process(record);
                 assertEquals(6, ((MockExecutorService) workQueue).totalPayloads);
                 assertEquals(3, ((MockExecutorService) workQueue).commitPayloads);
             }
         };
 
         tests
-                .setTarget(makeConstrainedUploader(2, 4))
+                .setTarget(makeConstrainedUploader(2, 4, false))
                 .run();
 
         // clear up between test runs
         setUp();
 
         tests
                 .setTarget(makeConstrainedUploader(2, 4, true))
                 .run();
@@ -344,19 +348,19 @@ public class BatchingUploaderTest {
         assertEquals(5, ((MockExecutorService) workQueue).totalPayloads);
         assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
     }
 
     @Test
     public void testPreemtiveUploadByteCounts() {
         // While processing a record, if we know for sure that another one won't fit,
         // we upload the payload.
-        BatchingUploader uploader = makeConstrainedUploader(3, 6);
+        BatchingUploader uploader = makeConstrainedUploader(3, 6, 1000, false);
 
-        // Payload byte max: 1024; batch byte max: 4096
+        // Payload byte max: 1024; Payload field byte max: 1000, batch byte max: 4096
         MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false, 400);
 
         uploader.process(record);
         assertEquals(0, ((MockExecutorService) workQueue).totalPayloads);
         assertEquals(0, ((MockExecutorService) workQueue).commitPayloads);
 
         // After 2nd record, byte count is at 800+overhead. Our payload max is 1024, so it's unlikely
         // we can fit another record at this pace. Expect payload to be uploaded.
@@ -415,16 +419,42 @@ public class BatchingUploaderTest {
             if (delay == i) {
                 uploader.setUnlimitedMode(true);
             }
             uploader.process(new MockRecord(Utils.generateGuid(), null, 0, false, random.nextInt(15000)));
         }
     }
 
     @Test
+    public void testRecordPayloadTooLarge() {
+        final long maxPayloadBytes = 10;
+        BatchingUploader uploader = makeConstrainedUploader(2, 4, maxPayloadBytes, false);
+
+        uploader.process(new MockRecord(Utils.generateGuid(), null, 0, false, 1));
+        assertEquals(0, ((MockStoreDelegate) storeDelegate).storeFailed);
+
+        uploader.process(new MockRecord(Utils.generateGuid(), null, 0, false, 5));
+        assertEquals(0, ((MockStoreDelegate) storeDelegate).storeFailed);
+
+        uploader.process(new MockRecord(Utils.generateGuid(), null, 0, false, 10));
+        assertEquals(0, ((MockStoreDelegate) storeDelegate).storeFailed);
+
+        uploader.process(new MockRecord(Utils.generateGuid(), null, 0, false, 11));
+        assertEquals(1, ((MockStoreDelegate) storeDelegate).storeFailed);
+        assertTrue(((MockStoreDelegate) storeDelegate).lastRecordStoreFailedException instanceof BatchingUploader.PayloadTooLargeToUpload);
+
+        uploader.process(new MockRecord(Utils.generateGuid(), null, 0, false, 1000));
+        assertEquals(2, ((MockStoreDelegate) storeDelegate).storeFailed);
+        assertTrue(((MockStoreDelegate) storeDelegate).lastRecordStoreFailedException instanceof BatchingUploader.PayloadTooLargeToUpload);
+
+        uploader.process(new MockRecord(Utils.generateGuid(), null, 0, false, 9));
+        assertEquals(2, ((MockStoreDelegate) storeDelegate).storeFailed);
+    }
+
+    @Test
     public void testNoMoreRecordsAfterPayloadPost() {
         BatchingUploader uploader = makeConstrainedUploader(2, 4);
 
         // Process two records (payload limit is also two, batch is four),
         // and ensure that 'no more records' commits.
         MockRecord record = new MockRecord(Utils.generateGuid(), null, 0, false);
         uploader.process(record);
         uploader.process(record);
@@ -506,25 +536,30 @@ public class BatchingUploaderTest {
         assertEquals(1, ((MockExecutorService) workQueue).commitPayloads);
     }
 
     private BatchingUploader makeConstrainedUploader(long maxPostRecords, long maxTotalRecords) {
         return makeConstrainedUploader(maxPostRecords, maxTotalRecords, false);
     }
 
     private BatchingUploader makeConstrainedUploader(long maxPostRecords, long maxTotalRecords, boolean firstSync) {
+        return makeConstrainedUploader(maxPostRecords, maxTotalRecords, 1000L, firstSync);
+    }
+
+    private BatchingUploader makeConstrainedUploader(long maxPostRecords, long maxTotalRecords, long maxPayloadBytes, boolean firstSync) {
         ExtendedJSONObject infoConfigurationJSON = new ExtendedJSONObject();
         infoConfigurationJSON.put(InfoConfiguration.MAX_TOTAL_BYTES, 4096L);
         infoConfigurationJSON.put(InfoConfiguration.MAX_TOTAL_RECORDS, maxTotalRecords);
         infoConfigurationJSON.put(InfoConfiguration.MAX_POST_RECORDS, maxPostRecords);
         infoConfigurationJSON.put(InfoConfiguration.MAX_POST_BYTES, 1024L);
         infoConfigurationJSON.put(InfoConfiguration.MAX_REQUEST_BYTES, 1024L);
+        infoConfigurationJSON.put(InfoConfiguration.MAX_PAYLOAD_BYTES, maxPayloadBytes);
 
         Server15RepositorySession server15RepositorySession = new Server15RepositorySession(
-                makeCountConstrainedRepository(maxPostRecords, maxTotalRecords, firstSync)
+                makeConstrainedRepository(firstSync)
         );
         server15RepositorySession.setStoreDelegate(storeDelegate);
         return new MockUploader(
                 server15RepositorySession, workQueue, storeDelegate, Uri.EMPTY, 0L,
                 new InfoConfiguration(infoConfigurationJSON), null);
     }
 
     class MockPayloadDispatcher extends PayloadDispatcher {
@@ -552,30 +587,17 @@ public class BatchingUploaderTest {
         }
 
         @Override
         PayloadDispatcher createPayloadDispatcher(ExecutorService workQueue, Long localCollectionLastModified) {
             return new MockPayloadDispatcher(workQueue, this, localCollectionLastModified);
         }
     }
 
-    private Server15Repository makeCountConstrainedRepository(long maxPostRecords, long maxTotalRecords, boolean firstSync) {
-        return makeConstrainedRepository(1024, 1024, maxPostRecords, 4096, maxTotalRecords, firstSync);
-    }
-
-    private Server15Repository makeConstrainedRepository(long maxRequestBytes, long maxPostBytes, long maxPostRecords, long maxTotalBytes, long maxTotalRecords, boolean firstSync) {
-        ExtendedJSONObject infoConfigurationJSON = new ExtendedJSONObject();
-        infoConfigurationJSON.put(InfoConfiguration.MAX_TOTAL_BYTES, maxTotalBytes);
-        infoConfigurationJSON.put(InfoConfiguration.MAX_TOTAL_RECORDS, maxTotalRecords);
-        infoConfigurationJSON.put(InfoConfiguration.MAX_POST_RECORDS, maxPostRecords);
-        infoConfigurationJSON.put(InfoConfiguration.MAX_POST_BYTES, maxPostBytes);
-        infoConfigurationJSON.put(InfoConfiguration.MAX_REQUEST_BYTES, maxRequestBytes);
-
-        InfoConfiguration infoConfiguration = new InfoConfiguration(infoConfigurationJSON);
-
+    private Server15Repository makeConstrainedRepository(boolean firstSync) {
         InfoCollections infoCollections;
         if (firstSync) {
             infoCollections = new InfoCollections() {
                 @Override
                 public Long getTimestamp(String collection) {
                     return null;
                 }
             };
@@ -590,17 +612,17 @@ public class BatchingUploaderTest {
 
         try {
             return new Server15Repository(
                     "dummyCollection",
                     SystemClock.elapsedRealtime() + TimeUnit.MINUTES.toMillis(30),
                     "http://dummy.url/",
                     null,
                     infoCollections,
-                    infoConfiguration,
+                    mock(InfoConfiguration.class),
                     new NonPersistentRepositoryStateProvider()
             );
         } catch (URISyntaxException e) {
             // Won't throw, and this won't happen.
             return null;
         }
     }