#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

"""Unit tests for the retry module."""

# pytype: skip-file

import unittest

from parameterized import parameterized

from apache_beam.utils import retry

# Protect against environments where apitools library is not available.
# pylint: disable=wrong-import-order, wrong-import-position
# TODO(sourabhbajaj): Remove the GCP specific error code to a submodule
try:
  from apitools.base.py.exceptions import HttpError
except ImportError:
  HttpError = None
# pylint: enable=wrong-import-order, wrong-import-position


class FakeClock(object):
  """A fake clock object implementing sleep() and recording calls."""
  def __init__(self):
    self.calls = []

  def sleep(self, value):
    self.calls.append(value)


class FakeLogger(object):
  """A fake logger object implementing log() and recording calls."""
  def __init__(self):
    self.calls = []

  def log(self, message, interval, func_name, exn_name, exn_traceback):
    _ = interval, exn_traceback
    self.calls.append((message, func_name, exn_name))


@retry.with_exponential_backoff(clock=FakeClock())
def _test_function(a, b):
  _ = a, b
  raise NotImplementedError


@retry.with_exponential_backoff(initial_delay_secs=0.1, num_retries=1)
def _test_function_with_real_clock(a, b):
  _ = a, b
  raise NotImplementedError


@retry.no_retries
def _test_no_retry_function(a, b):
  _ = a, b
  raise NotImplementedError


class RetryTest(unittest.TestCase):
  def setUp(self):
    self.clock = FakeClock()
    self.logger = FakeLogger()
    self.calls = 0

  def permanent_failure(self, a, b):
    raise NotImplementedError

  def transient_failure(self, a, b):
    self.calls += 1
    if self.calls > 4:
      return a + b
    raise NotImplementedError

  def http_error(self, code):
    if HttpError is None:
      raise RuntimeError("This is not a valid test as GCP is not enabled")
    raise HttpError({'status': str(code)}, '', '')

  def test_with_explicit_decorator(self):
    # We pass one argument as positional argument and one as keyword argument
    # so that we cover both code paths for argument handling.
    self.assertRaises(NotImplementedError, _test_function, 10, b=20)

  def test_with_no_retry_decorator(self):
    self.assertRaises(NotImplementedError, _test_no_retry_function, 1, 2)

  def test_with_real_clock(self):
    self.assertRaises(
        NotImplementedError, _test_function_with_real_clock, 10, b=20)

  def test_with_default_number_of_retries(self):
    self.assertRaises(
        NotImplementedError,
        retry.with_exponential_backoff(clock=self.clock)(
            self.permanent_failure),
        10,
        b=20)
    self.assertEqual(len(self.clock.calls), 7)

  def test_with_explicit_number_of_retries(self):
    self.assertRaises(
        NotImplementedError,
        retry.with_exponential_backoff(clock=self.clock,
                                       num_retries=10)(self.permanent_failure),
        10,
        b=20)
    self.assertEqual(len(self.clock.calls), 10)

  @unittest.skipIf(HttpError is None, 'google-apitools is not installed')
  def test_with_http_error_that_should_not_be_retried(self):
    self.assertRaises(
        HttpError,
        retry.with_exponential_backoff(clock=self.clock,
                                       num_retries=10)(self.http_error),
        404)
    # Make sure just one call was made.
    self.assertEqual(len(self.clock.calls), 0)

  @unittest.skipIf(HttpError is None, 'google-apitools is not installed')
  def test_with_http_error_that_should_be_retried(self):
    self.assertRaises(
        HttpError,
        retry.with_exponential_backoff(clock=self.clock,
                                       num_retries=10)(self.http_error),
        500)
    self.assertEqual(len(self.clock.calls), 10)

  def test_with_explicit_initial_delay(self):
    self.assertRaises(
        NotImplementedError,
        retry.with_exponential_backoff(
            initial_delay_secs=10.0, clock=self.clock,
            fuzz=False)(self.permanent_failure),
        10,
        b=20)
    self.assertEqual(len(self.clock.calls), 7)
    self.assertEqual(self.clock.calls[0], 10.0)

  @parameterized.expand([(str(i), i) for i in range(0, 1000, 47)])
  def test_with_stop_after_secs(self, _, stop_after_secs):
    max_delay_secs = 10
    self.assertRaises(
        NotImplementedError,
        retry.with_exponential_backoff(
            num_retries=10000,
            initial_delay_secs=10.0,
            clock=self.clock,
            fuzz=False,
            max_delay_secs=max_delay_secs,
            stop_after_secs=stop_after_secs)(self.permanent_failure),
        10,
        b=20)
    total_delay = sum(self.clock.calls)
    self.assertLessEqual(total_delay, stop_after_secs)
    self.assertGreaterEqual(total_delay, stop_after_secs - max_delay_secs)

  def test_log_calls_for_permanent_failure(self):
    self.assertRaises(
        NotImplementedError,
        retry.with_exponential_backoff(
            clock=self.clock, logger=self.logger.log)(self.permanent_failure),
        10,
        b=20)
    self.assertEqual(len(self.logger.calls), 7)
    for message, func_name, exn_name in self.logger.calls:
      self.assertTrue(message.startswith('Retry with exponential backoff:'))
      self.assertEqual(exn_name, 'NotImplementedError\n')
      self.assertEqual(func_name, 'permanent_failure')

  def test_log_calls_for_transient_failure(self):
    result = retry.with_exponential_backoff(
        clock=self.clock, logger=self.logger.log,
        fuzz=False)(self.transient_failure)(
            10, b=20)
    self.assertEqual(result, 30)
    self.assertEqual(len(self.clock.calls), 4)
    self.assertEqual(self.clock.calls, [
        5.0 * 1,
        5.0 * 2,
        5.0 * 4,
        5.0 * 8,
    ])
    self.assertEqual(len(self.logger.calls), 4)
    for message, func_name, exn_name in self.logger.calls:
      self.assertTrue(message.startswith('Retry with exponential backoff:'))
      self.assertEqual(exn_name, 'NotImplementedError\n')
      self.assertEqual(func_name, 'transient_failure')


class DummyClass(object):
  def __init__(self, results):
    self.index = 0
    self.results = results

  @retry.with_exponential_backoff(num_retries=2, initial_delay_secs=0.1)
  def func(self):
    self.index += 1
    if self.index > len(self.results) or \
        self.results[self.index - 1] == "Error":
      raise ValueError("Error")
    return self.results[self.index - 1]


class RetryStateTest(unittest.TestCase):
  """The test_two_failures and test_single_failure would fail if we have
  any shared state for the retry decorator. This test tries to prevent a bug we
  found where the state in the decorator was shared across objects and retries
  were not available correctly.

  The test_call_two_objects would test this inside the same test.
  """
  def test_two_failures(self):
    dummy = DummyClass(["Error", "Error", "Success"])
    dummy.func()
    self.assertEqual(3, dummy.index)

  def test_single_failure(self):
    dummy = DummyClass(["Error", "Success"])
    dummy.func()
    self.assertEqual(2, dummy.index)

  def test_call_two_objects(self):
    dummy = DummyClass(["Error", "Error", "Success"])
    dummy.func()
    self.assertEqual(3, dummy.index)

    dummy2 = DummyClass(["Error", "Success"])
    dummy2.func()
    self.assertEqual(2, dummy2.index)


if __name__ == '__main__':
  unittest.main()
