Add support for `max_consecutive_error` in `repeat`. (#740)

diff --git a/mobly/base_test.py b/mobly/base_test.py
index ebcc2b8..9d728d1 100644
--- a/mobly/base_test.py
+++ b/mobly/base_test.py
@@ -45,14 +45,15 @@
 
 # Attribute names
 ATTR_REPEAT_CNT = '_repeat_count'
-ATTR_MAX_RETRY_CNT = '_max_count'
+ATTR_MAX_RETRY_CNT = '_max_retry_count'
+ATTR_MAX_CONSEC_ERROR = '_max_consecutive_error'
 
 
 class Error(Exception):
   """Raised for exceptions that occurred in BaseTestClass."""
 
 
-def repeat(count):
+def repeat(count, max_consecutive_error=None):
   """Decorator for repeating a test case multiple times.
 
   The BaseTestClass will execute the test cases annotated with this decorator
@@ -63,6 +64,9 @@
 
   Args:
     count: int, the total number of times to execute the decorated test case.
+    max_consecutive_error: int, the maximum number of consecutively failed
+      iterations allowed. If reached, the remaining iterations is abandoned.
+      By default this is not enabled.
 
   Returns:
     The wrapped test function.
@@ -74,8 +78,14 @@
     raise ValueError(
         f'The `count` for `repeat` must be larger than 1, got "{count}".')
 
+  if max_consecutive_error is not None and max_consecutive_error > count:
+    raise ValueError(
+        f'The `max_consecutive_error` ({max_consecutive_error}) for `repeat` '
+        f'must be smaller than `count` ({count}).')
+
   def _outer_decorator(func):
     setattr(func, ATTR_REPEAT_CNT, count)
+    setattr(func, ATTR_MAX_CONSEC_ERROR, max_consecutive_error)
 
     @functools.wraps(func)
     def _wrapper(*args):
@@ -649,13 +659,13 @@
     def should_retry(record):
       return record.result in [
           records.TestResultEnums.TEST_RESULT_FAIL,
-          records.TestResultEnums.TEST_RESULT_ERROR
+          records.TestResultEnums.TEST_RESULT_ERROR,
       ]
 
     previous_record = self.exec_one_test(test_name, test_method)
 
     if not should_retry(previous_record):
-      return previous_record
+      return
 
     for i in range(max_count - 1):
       retry_name = f'{test_name}_retry_{i+1}'
@@ -665,6 +675,47 @@
       if not should_retry(previous_record):
         break
 
+  def _exec_one_test_with_repeat(self, test_name, test_method, repeat_count,
+                                 max_consecutive_error):
+    """Repeatedly execute a test case.
+
+    This method performs the action defined by the `repeat` decorator.
+
+    If the number of consecutive failures reach the threshold set by
+    `max_consecutive_error`, the remaining iterations will be abandoned.
+
+    Args:
+      test_name: string, Name of the test.
+      test_method: function, The test method to execute.
+      repeat_count: int, the number of times to repeat the test case.
+      max_consecutive_error: int, the maximum number of consecutive iterations
+        allowed to fail before abandoning the remaining iterations.
+    """
+
+    consecutive_error_count = 0
+
+    # If max_consecutive_error is not set by user, it is considered the same as
+    # the repeat_count.
+    if max_consecutive_error == 0:
+      max_consecutive_error = repeat_count
+
+    for i in range(repeat_count):
+      new_test_name = f'{test_name}_{i}'
+      record = self.exec_one_test(new_test_name, test_method)
+      if record.result in [
+          records.TestResultEnums.TEST_RESULT_FAIL,
+          records.TestResultEnums.TEST_RESULT_ERROR,
+      ]:
+        consecutive_error_count += 1
+      else:
+        consecutive_error_count = 0
+      if consecutive_error_count == max_consecutive_error:
+        logging.error(
+            'Repeated test case "%s" has consecutively failed %d iterations, '
+            'aborting the remaining %d iterations.', test_name,
+            consecutive_error_count, repeat_count - 1 - i)
+        return
+
   def exec_one_test(self, test_name, test_method, record=None):
     """Executes one test and update test results.
 
@@ -677,7 +728,7 @@
       test_method: function, The test method to execute.
       record: records.TestResultRecord, optional arg for injecting a record
         object to use for this test execution. If not set, a new one is created
-        created. This is meant for passing infomation between consecutive test
+        created. This is meant for passing information between consecutive test
         case execution for retry purposes. Do NOT abuse this for "magical"
         features.
 
@@ -884,12 +935,7 @@
         test_method = self._generated_test_table[test_name]
       else:
         raise Error('%s does not have test method %s.' % (self.TAG, test_name))
-      repeat_count = getattr(test_method, ATTR_REPEAT_CNT, 0)
-      if repeat_count:
-        for i in range(repeat_count):
-          test_methods.append((f'{test_name}_{i}', test_method))
-      else:
-        test_methods.append((test_name, test_method))
+      test_methods.append((test_name, test_method))
     return test_methods
 
   def _skip_remaining_tests(self, exception):
@@ -953,9 +999,15 @@
         return setup_class_result
       # Run tests in order.
       for test_name, test_method in tests:
-        max_count = getattr(test_method, ATTR_MAX_RETRY_CNT, 0)
-        if max_count:
-          self._exec_one_test_with_retry(test_name, test_method, max_count)
+        max_consecutive_error = getattr(test_method, ATTR_MAX_CONSEC_ERROR, 0)
+        repeat_count = getattr(test_method, ATTR_REPEAT_CNT, 0)
+        max_retry_count = getattr(test_method, ATTR_MAX_RETRY_CNT, 0)
+        if max_retry_count:
+          self._exec_one_test_with_retry(test_name, test_method,
+                                         max_retry_count)
+        elif repeat_count:
+          self._exec_one_test_with_repeat(test_name, test_method, repeat_count,
+                                          max_consecutive_error)
         else:
           self.exec_one_test(test_name, test_method)
       return self.results
diff --git a/tests/mobly/base_test_test.py b/tests/mobly/base_test_test.py
index a720752..7fcfb70 100755
--- a/tests/mobly/base_test_test.py
+++ b/tests/mobly/base_test_test.py
@@ -17,6 +17,7 @@
 import io
 import os
 import mock
+import re
 import shutil
 import tempfile
 import unittest
@@ -2248,6 +2249,19 @@
         def test_something(self):
           pass
 
+  def test_repeat_invalid_max_consec_error(self):
+
+    with self.assertRaisesRegex(
+        ValueError,
+        re.escape('The `max_consecutive_error` (4) for `repeat` must be '
+                  'smaller than `count` (3).')):
+
+      class MockBaseTest(base_test.BaseTestClass):
+
+        @base_test.repeat(count=3, max_consecutive_error=4)
+        def test_something(self):
+          pass
+
   def test_repeat(self):
     repeat_count = 3
 
@@ -2285,6 +2299,89 @@
     self.assertEqual(iter_1.test_name, 'test_something_0')
     self.assertEqual(iter_3.test_name, 'test_something_2')
 
+  @mock.patch('logging.error')
+  def test_repeat_with_consec_error_at_the_beginning_aborts_repeat(
+      self, mock_logging_error):
+    repeat_count = 5
+    max_consec_error = 2
+    mock_action = mock.MagicMock()
+    mock_action.side_effect = [
+        Exception('Error 1'),
+        Exception('Error 2'),
+        Exception('Error 3'),
+    ]
+
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.repeat(count=repeat_count,
+                        max_consecutive_error=max_consec_error)
+      def test_something(self):
+        mock_action()
+
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    bt_cls.run()
+    mock_logging_error.assert_called_with(
+        'Repeated test case "%s" has consecutively failed %d iterations, aborting'
+        ' the remaining %d iterations.', 'test_something', 2, 3)
+    self.assertEqual(max_consec_error, len(bt_cls.results.executed))
+    self.assertEqual(max_consec_error, len(bt_cls.results.error))
+    for i, record in enumerate(bt_cls.results.error):
+      self.assertEqual(record.test_name, f'test_something_{i}')
+
+  @mock.patch('logging.error')
+  def test_repeat_with_consec_error_in_the_middle_aborts_repeat(
+      self, mock_logging_error):
+    repeat_count = 5
+    max_consec_error = 2
+    mock_action = mock.MagicMock()
+    mock_action.side_effect = [
+        None,
+        None,
+        Exception('Error 1'),
+        Exception('Error 2'),
+        Exception('Error 3'),
+    ]
+
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.repeat(count=repeat_count,
+                        max_consecutive_error=max_consec_error)
+      def test_something(self):
+        mock_action()
+
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    bt_cls.run()
+    mock_logging_error.assert_called_with(
+        'Repeated test case "%s" has consecutively failed %d iterations, aborting'
+        ' the remaining %d iterations.', 'test_something', 2, 1)
+    self.assertEqual(4, len(bt_cls.results.executed))
+    self.assertEqual(2, len(bt_cls.results.error))
+    self.assertEqual(2, len(bt_cls.results.passed))
+    for i, record in enumerate(bt_cls.results.passed):
+      self.assertEqual(record.test_name, f'test_something_{i}')
+
+  def test_repeat_with_consec_error_does_not_abort_repeat(self):
+    repeat_count = 5
+    max_consec_error = 2
+    mock_action = mock.MagicMock()
+    mock_action.side_effect = [
+        Exception('Error 1'), None,
+        Exception('Error 2'), None,
+        Exception('Error 3'),
+    ]
+
+    class MockBaseTest(base_test.BaseTestClass):
+
+      @base_test.repeat(count=repeat_count,
+                        max_consecutive_error=max_consec_error)
+      def test_something(self):
+        mock_action()
+
+    bt_cls = MockBaseTest(self.mock_test_cls_configs)
+    bt_cls.run()
+    self.assertEqual(repeat_count, len(bt_cls.results.executed))
+    self.assertEqual(3, len(bt_cls.results.error))
+
   def test_retry_invalid_count(self):
 
     with self.assertRaisesRegex(