Repository: fabfuel/circuitbreaker

Issue:
Create new release
I would like to make use of the failure predicate function in my code, it is only possible if I use the master or commit as a ref when I define the dependencies - which is kinda ugly. A new release (probably 1.4.0) would be appreciated.

Discussion:

Patch:
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..e5edb83
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,1 @@
+github: fabfuel
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..28d7746
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,40 @@
+# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: Build
+
+on:
+  push:
+    branches: [ develop ]
+  pull_request:
+    branches: [ develop, master ]
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["2.7", "3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11.0-beta.3"]
+
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install .
+        pip install -r requirements_dev.txt
+    - name: Lint with flake8
+      run: |
+        # stop the build if there are Python syntax errors or undefined names
+        flake8 circuitbreaker.py tests --count --select=E9,F63,F7,F82 --show-source --statistics
+        # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
+        flake8 circuitbreaker.py tests --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+    - name: Test with pytest
+      run: |
+        pytest --cov circuitbreaker
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index afa7dd9..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-language: python
-python:
-  - "2.7"
-  - "3.5"
-  - "3.6"
-install: pip install tox-travis
-script: tox
-after_script:
-    - pip install scrutinizer-ocular
-    - ocular --data-file ".coverage"
diff --git a/LICENSE.rst b/LICENSE.rst
index 89d7594..c799f0d 100644
--- a/LICENSE.rst
+++ b/LICENSE.rst
@@ -1,23 +1,29 @@
 circuitbreaker
 
-Copyright (c) 2016-2020, Fabian Fuelling opensource@fabfuel.de. All rights reserved.
+Copyright (c) 2016-2020, Fabian Fuelling opensource@fabfuel.de.
+All rights reserved.
 
-Redistribution and use in source and binary forms, with or without modification, are permitted provided
-that the following conditions are met:
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
 
-Redistributions of source code must retain the above copyright notice, this list of conditions and the following
-disclaimer.
+1. Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in the
+   documentation and/or other materials provided with the distribution.
+3. Neither the name of Fabian Fuelling nor the names of his contributors
+   may be used to endorse or promote products derived from this software
+   without specific prior written permission.
 
-Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
-disclaimer in the documentation and/or other materials provided with the distribution.
-
-Neither the name of Fabian Fuelling nor the names of his contributors may be used to endorse or promote products derived
-from this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
-INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
-SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
-WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
-USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGE.
diff --git a/README.rst b/README.rst
index 455dd21..7de0c94 100644
--- a/README.rst
+++ b/README.rst
@@ -4,17 +4,11 @@ CircuitBreaker
 .. image:: https://badge.fury.io/py/circuitbreaker.svg
     :target: https://badge.fury.io/py/circuitbreaker
 
-.. image:: https://travis-ci.org/fabfuel/circuitbreaker.svg?branch=master
-    :target: https://travis-ci.org/fabfuel/circuitbreaker
+.. image:: https://github.com/fabfuel/circuitbreaker/actions/workflows/build.yml/badge.svg
+    :target: https://github.com/fabfuel/circuitbreaker/actions/workflows/build.yml
 
-.. image:: https://scrutinizer-ci.com/g/fabfuel/circuitbreaker/badges/coverage.png?b=master
-    :target: https://scrutinizer-ci.com/g/fabfuel/circuitbreaker
-
-.. image:: https://scrutinizer-ci.com/g/fabfuel/circuitbreaker/badges/quality-score.png?b=master
-    :target: https://scrutinizer-ci.com/g/fabfuel/circuitbreaker
-
-This is a Python implementation of the "Circuit Breaker" Pattern (http://martinfowler.com/bliki/CircuitBreaker.html).
-Inspired by Michael T. Nygard's highly recommendable book *Release It!* (https://pragprog.com/book/mnee/release-it).
+This is a Python implementation of the "Circuit Breaker" Pattern (https://martinfowler.com/bliki/CircuitBreaker.html).
+Inspired by Michael T. Nygard's highly recommendable book *Release It!* (https://pragprog.com/titles/mnee2/release-it-second-edition/).
 
 
 Installation
@@ -31,7 +25,7 @@ Usage
 This is the simplest example. Just decorate a function with the ``@circuit`` decorator::
 
     from circuitbreaker import circuit
-    
+
     @circuit
     def external_call():
         ...
@@ -51,20 +45,24 @@ This decorator sets up a circuit breaker with the default settings. The circuit
 What does *failure* mean?
 =========================
 A *failure* is a raised exception, which was not caught during the function call.
-By default, the circuit breaker listens for all exceptions based on the class ``Exception``. 
-That means, that all exceptions raised during the function call are considered as an 
+By default, the circuit breaker listens for all exceptions based on the class ``Exception``.
+That means, that all exceptions raised during the function call are considered as an
 "expected failure" and will increase the failure count.
 
 Get specific about the expected failure
 =======================================
-It is important, to be **as specific as possible**, when defining the expected exception. 
+It is important, to be **as specific as possible**, when defining the expected exception.
 The main purpose of a circuit breaker is to protect your distributed system from a cascading failure.
 That means, you probably want to open the circuit breaker only, if the integration point on the other
 end is unavailable. So e.g. if there is an ``ConnectionError`` or a request ``Timeout``.
 
-If you are e.g. using the requests library (http://docs.python-requests.org/) for making HTTP calls, 
+If you are e.g. using the requests library (https://docs.python-requests.org/) for making HTTP calls,
 its ``RequestException`` class would be a great choice for the ``expected_exception`` parameter.
 
+The logic for treating thrown exceptions as failures can also be customized by passing a callable. The
+callable will be passed the exception type and value, and should return True if the exception should be
+treated as a failure.
+
 All recognized exceptions will be re-raised anyway, but the goal is, to let the circuit breaker only
 recognize those exceptions which are related to the communication to your integration point.
 
@@ -77,7 +75,7 @@ Configuration
 The following configuration options can be adjusted via decorator parameters. For example::
 
     from circuitbreaker import circuit
-    
+
     @circuit(failure_threshold=10, expected_exception=ConnectionError)
     def external_call():
         ...
@@ -96,12 +94,40 @@ You can adjust this value with the ``recovery_timeout`` parameter.
 expected exception
 ==================
 By default, the circuit breaker listens for all exceptions which are based on the ``Exception`` class.
-You can adjust this with the ``expected_exception`` parameter. It can be either an exception class or a tuple of exception classes.
+You can adjust this with the ``expected_exception`` parameter. It can be either an exception class, an iterable of an exception classes,
+or a callable.
+
+Use a callable if the logic to flag exceptions as failures is more complex than a type check. For example::
+
+    # Assume we are using the requests library
+    def is_not_http_error(thrown_type, thrown_value):
+        return issubclass(thrown_type, RequestException) and not issubclass(thrown_type, HTTPError)
+
+    def is_rate_limited(thrown_type, thrown_value):
+        return issubclass(thrown_type, HTTPError) and thrown_value.status_code == 429
+
+    @circuit(expected_exception=is_not_http_error)
+    def call_flaky_api(...):
+        rsp = requests.get(...)
+        rsp.raise_for_status()
+        return rsp
+
+    @circuit(expected_exception=is_rate_limited)
+    def call_slow_server(...):
+        rsp = requests.get(...)
+        rsp.raise_for_status()
+        return rsp
+        ```
 
 name
 ====
 By default, the circuit breaker name is the name of the function it decorates. You can adjust the name with parameter ``name``.
 
+fallback function
+=================
+By default, the circuit breaker will raise a ``CircuitBreaker`` exception when the circuit is opened.
+You can instead specify a function to be called when the circuit is opened. This function can be specified with the
+``fallback_function`` parameter and will be called with the same parameters as the decorated function would be.
 
 Advanced Usage
 --------------
@@ -109,13 +135,13 @@ If you apply circuit breakers to a couple of functions and you always set specif
 you can extend the ``CircuitBreaker`` class and create your own circuit breaker subclass instead::
 
     from circuitbreaker import CircuitBreaker
-    
+
     class MyCircuitBreaker(CircuitBreaker):
         FAILURE_THRESHOLD = 10
         RECOVERY_TIMEOUT = 60
         EXPECTED_EXCEPTION = RequestException
-        
-        
+
+
 Now you have two options to apply your circuit breaker to a function. As an Object directly::
 
     @MyCircuitBreaker()
@@ -123,7 +149,7 @@ Now you have two options to apply your circuit breaker to a function. As an Obje
         ...
 
 Please note, that the circuit breaker class has to be initialized, you have to use a class instance as decorator (``@MyCircuitBreaker()``), not the class itself (``@MyCircuitBreaker``).
-        
+
 Or via the decorator proxy::
 
     @circuit(cls=MyCircuitBreaker)
@@ -138,8 +164,3 @@ Monitoring
 To keep track of the health of your application and the state of your circuit breakers, every circuit breaker registers itself at the ``CircuitBreakerMonitor``. You can receive all registered circuit breakers via ``CircuitBreakerMonitor.get_circuits()``.
 
 To get an aggregated health status, you can ask the Monitor via ``CircuitBreakerMonitor.all_closed()``. Or you can retrieve the currently open circuits via ``CircuitBreakerMonitor.get_open()`` and the closed circuits via ``CircuitBreakerMonitor.get_closed()``.
-
-
-Todo
-----
-- add unit tests
diff --git a/circuitbreaker.py b/circuitbreaker.py
index d04a204..23ec5e1 100644
--- a/circuitbreaker.py
+++ b/circuitbreaker.py
@@ -6,13 +6,69 @@ from __future__ import absolute_import
 
 from functools import wraps
 from datetime import datetime, timedelta
+from inspect import isgeneratorfunction, isclass
 from typing import AnyStr, Iterable
+from math import ceil, floor
+
+try:
+    from time import monotonic
+except ImportError:
+    from monotonic import monotonic
+
+# Python2 vs Python3 strings
+try:
+    STRING_TYPES = (basestring,)
+except NameError:
+    STRING_TYPES = (bytes, str)
 
 STATE_CLOSED = 'closed'
 STATE_OPEN = 'open'
 STATE_HALF_OPEN = 'half_open'
 
 
+def in_exception_list(*exc_types):
+    """Build a predicate function that checks if an exception is a subtype from a list"""
+
+    def matches_types(thrown_type, _):
+        return issubclass(thrown_type, exc_types)
+
+    return matches_types
+
+
+def build_failure_predicate(expected_exception):
+    """ Build a failure predicate_function.
+          The returned function has the signature (Type[Exception], Exception) -> bool.
+          Return value True indicates a failure in the underlying function.
+
+        :param expected_exception: either an type of Exception, iterable of Exception types, or a predicate function.
+
+          If an Exception type or iterable of Exception types, the failure predicate will return True when a thrown
+          exception type matches one of the provided types.
+
+          If a predicate function, it will just be returned as is.
+
+         :return: callable (Type[Exception], Exception) -> bool
+    """
+
+    if isclass(expected_exception) and issubclass(expected_exception, Exception):
+        failure_predicate = in_exception_list(expected_exception)
+    else:
+        try:
+            # Check for an iterable of Exception types
+            iter(expected_exception)
+
+            # guard against a surprise later
+            if isinstance(expected_exception, STRING_TYPES):
+                raise ValueError("expected_exception cannot be a string. Did you mean name?")
+            failure_predicate = in_exception_list(*expected_exception)
+        except TypeError:
+            # not iterable. guess that it's a predicate function
+            if not callable(expected_exception) or isclass(expected_exception):
+                raise ValueError("expected_exception does not look like a predicate")
+            failure_predicate = expected_exception
+    return failure_predicate
+
+
 class CircuitBreaker(object):
     FAILURE_THRESHOLD = 5
     RECOVERY_TIMEOUT = 30
@@ -24,32 +80,83 @@ class CircuitBreaker(object):
                  recovery_timeout=None,
                  expected_exception=None,
                  name=None,
-                 fallback_function=None):
+                 fallback_function=None
+                 ):
+        """
+        Construct a circuit breaker.
+
+        :param failure_threshold: break open after this many failures
+        :param recovery_timeout: close after this many seconds
+        :param expected_exception: either an type of Exception, iterable of Exception types, or a predicate function.
+        :param name: name for this circuitbreaker
+        :param fallback_function: called when the circuit is opened
+
+           :return: Circuitbreaker instance
+           :rtype: Circuitbreaker
+        """
         self._last_failure = None
         self._failure_count = 0
         self._failure_threshold = failure_threshold or self.FAILURE_THRESHOLD
         self._recovery_timeout = recovery_timeout or self.RECOVERY_TIMEOUT
-        self._expected_exception = expected_exception or self.EXPECTED_EXCEPTION
+
+        # Build the failure predicate. In order of precedence, prefer the
+        # * the constructor argument
+        # * the subclass attribute EXPECTED_EXCEPTION
+        # * the CircuitBreaker attribute EXPECTED_EXCEPTION
+        if not expected_exception:
+            try:
+                # Introspect our final type, then grab the  value via __dict__ to avoid python Descriptor magic
+                #  in the case where it's a callable function.
+                expected_exception = type(self).__dict__["EXPECTED_EXCEPTION"]
+            except KeyError:
+                expected_exception = CircuitBreaker.EXPECTED_EXCEPTION
+
+        self.is_failure = build_failure_predicate(expected_exception)
+
         self._fallback_function = fallback_function or self.FALLBACK_FUNCTION
         self._name = name
         self._state = STATE_CLOSED
-        self._opened = datetime.utcnow()
+        self._opened = monotonic()
 
     def __call__(self, wrapped):
         return self.decorate(wrapped)
 
+    def __enter__(self):
+        return None
+
+    def __exit__(self, exc_type, exc_value, _traceback):
+        if exc_type and self.is_failure(exc_type, exc_value):
+            # exception was raised and is our concern
+            self._last_failure = exc_value
+            self.__call_failed()
+        else:
+            self.__call_succeeded()
+        return False  # return False to raise exception if any
+
     def decorate(self, function):
         """
         Applies the circuit breaker to a function
         """
         if self._name is None:
-            self._name = function.__name__
+            try:
+                self._name = function.__qualname__
+            except AttributeError:
+                self._name = function.__name__
 
         CircuitBreakerMonitor.register(self)
 
+        if isgeneratorfunction(function):
+            call = self.call_generator
+        else:
+            call = self.call
+
         @wraps(function)
         def wrapper(*args, **kwargs):
-            return self.call(function, *args, **kwargs)
+            if self.opened:
+                if self.fallback_function:
+                    return self.fallback_function(*args, **kwargs)
+                raise CircuitBreakerError(self)
+            return call(function, *args, **kwargs)
 
         return wrapper
 
@@ -59,19 +166,18 @@ class CircuitBreaker(object):
         rules on success or failure
         :param func: Decorated function
         """
-        if self.opened:
-            if self.fallback_function:
-                return self.fallback_function(*args, **kwargs)
-            raise CircuitBreakerError(self)
-        try:
-            result = func(*args, **kwargs)
-        except self._expected_exception as e:
-            self._last_failure = e
-            self.__call_failed()
-            raise
+        with self:
+            return func(*args, **kwargs)
 
-        self.__call_succeeded()
-        return result
+    def call_generator(self, func, *args, **kwargs):
+        """
+        Calls the decorated generator function and applies the circuit breaker
+        rules on success or failure
+        :param func: Decorated generator function
+        """
+        with self:
+            for el in func(*args, **kwargs):
+                yield el
 
     def __call_succeeded(self):
         """
@@ -88,7 +194,7 @@ class CircuitBreaker(object):
         self._failure_count += 1
         if self._failure_count >= self._failure_threshold:
             self._state = STATE_OPEN
-            self._opened = datetime.utcnow()
+            self._opened = monotonic()
 
     @property
     def state(self):
@@ -99,10 +205,10 @@ class CircuitBreaker(object):
     @property
     def open_until(self):
         """
-        The datetime, when the circuit breaker will try to recover
+        The approximate datetime when the circuit breaker will try to recover
         :return: datetime
         """
-        return self._opened + timedelta(seconds=self._recovery_timeout)
+        return datetime.utcnow() + timedelta(seconds=self.open_remaining)
 
     @property
     def open_remaining(self):
@@ -110,7 +216,8 @@ class CircuitBreaker(object):
         Number of seconds remaining, the circuit breaker stays in OPEN state
         :return: int
         """
-        return (self.open_until - datetime.utcnow()).total_seconds()
+        remain = (self._opened + self._recovery_timeout) - monotonic()
+        return ceil(remain) if remain > 0 else floor(remain)
 
     @property
     def failure_count(self):
@@ -204,7 +311,6 @@ def circuit(failure_threshold=None,
             name=None,
             fallback_function=None,
             cls=CircuitBreaker):
-
     # if the decorator is used without parameters, the
     # wrapped function is provided as first argument
     if callable(failure_threshold):
diff --git a/requirements.txt b/requirements.txt
index 9241fde..2713329 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,1 +1,2 @@
 typing; python_version < '3.5'
+monotonic; python_version < '3.0'
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 35df629..6976f4a 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,6 +1,6 @@
-mock
+mock; python_version < '3.3'
 pytest
-pytest-flake8
 pytest-mock
 pytest-cov
 coverage
+flake8
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 0529600..92fa7a1 100644
--- a/setup.py
+++ b/setup.py
@@ -9,11 +9,11 @@ def readme():
         return f.read()
 
 
-dependencies = ["typing; python_version < '3.5'"]
+dependencies = ["typing; python_version < '3.5'", "monotonic; python_version < '3.0'"]
 
 setup(
     name='circuitbreaker',
-    version='1.3.2',
+    version='1.4.0',
     url='https://github.com/fabfuel/circuitbreaker',
     download_url='https://github.com/fabfuel/circuitbreaker/archive/1.3.1.tar.gz',
     license='BSD-3-Clause',
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index f11de68..0000000
--- a/tox.ini
+++ /dev/null
@@ -1,27 +0,0 @@
-[tox]
-envlist = py27, py35, py36, py37, flake8
-
-[testenv:py27]
-basepython = python2.7
-
-[testenv:py35]
-basepython = python3.5
-
-[testenv:py36]
-basepython = python3.6
-
-[testenv:py37]
-basepython = python3.7
-
-[testenv:flake8]
-basepython=python3
-deps=flake8
-commands=flake8 circuitbreaker
-
-[testenv]
-setenv =
-    PYTHONPATH = {toxinidir}:{toxinidir}/circuitbreaker
-deps =
-    -r{toxinidir}/requirements.txt
-    -r{toxinidir}/requirements_dev.txt
-commands=py.test tests --cov circuitbreaker {posargs}


Test patch:
diff --git a/tests/test_functional.py b/tests/test_functional.py
index ba6527f..3d79daf 100644
--- a/tests/test_functional.py
+++ b/tests/test_functional.py
@@ -1,6 +1,10 @@
 from time import sleep
 
-from mock.mock import patch, Mock
+try:
+    from unittest.mock import Mock, patch
+except ImportError:
+    from mock import Mock, patch
+
 from pytest import raises
 
 from circuitbreaker import CircuitBreaker, CircuitBreakerError, \
@@ -21,6 +25,13 @@ def circuit_failure():
     raise IOError()
 
 
+@CircuitBreaker(failure_threshold=1, name="circuit_generator_failure")
+def circuit_generator_failure():
+    pseudo_remote_call()
+    yield 1
+    raise IOError()
+
+
 @CircuitBreaker(failure_threshold=1, name="threshold_1")
 def circuit_threshold_1():
     return pseudo_remote_call()
@@ -42,16 +53,16 @@ def test_circuit_pass_through():
 
 def test_circuitbreaker_monitor():
     assert CircuitBreakerMonitor.all_closed() is True
-    assert len(list(CircuitBreakerMonitor.get_circuits())) == 5
-    assert len(list(CircuitBreakerMonitor.get_closed())) == 5
+    assert len(list(CircuitBreakerMonitor.get_circuits())) == 6
+    assert len(list(CircuitBreakerMonitor.get_closed())) == 6
     assert len(list(CircuitBreakerMonitor.get_open())) == 0
 
     with raises(IOError):
         circuit_failure()
 
     assert CircuitBreakerMonitor.all_closed() is False
-    assert len(list(CircuitBreakerMonitor.get_circuits())) == 5
-    assert len(list(CircuitBreakerMonitor.get_closed())) == 4
+    assert len(list(CircuitBreakerMonitor.get_circuits())) == 6
+    assert len(list(CircuitBreakerMonitor.get_closed())) == 5
     assert len(list(CircuitBreakerMonitor.get_open())) == 1
 
 
@@ -71,7 +82,7 @@ def test_threshold_hit_prevents_consequent_calls(mock_remote):
     with raises(CircuitBreakerError):
         circuit_threshold_1()
 
-    mock_remote.assert_called_once()
+    mock_remote.assert_called_once_with()
 
 
 @patch('test_functional.pseudo_remote_call', return_value=True)
@@ -218,3 +229,20 @@ def test_circuitbreaker_reopens_after_successful_calls(mock_remote):
     assert circuit_threshold_2_timeout_1()
     assert circuit_threshold_2_timeout_1()
     assert circuit_threshold_2_timeout_1()
+
+
+@patch("test_functional.pseudo_remote_call", return_value=True)
+def test_circuitbreaker_handles_generator_functions(mock_remote):
+    # type: (Mock) -> None
+    circuitbreaker = CircuitBreakerMonitor.get("circuit_generator_failure")
+    assert circuitbreaker.closed
+
+    with raises(IOError):
+        list(circuit_generator_failure())
+
+    assert circuitbreaker.opened
+
+    with raises(CircuitBreakerError):
+        list(circuit_generator_failure())
+
+    mock_remote.assert_called_once_with()
diff --git a/tests/test_unit.py b/tests/test_unit.py
index 1a79f2b..68f7830 100644
--- a/tests/test_unit.py
+++ b/tests/test_unit.py
@@ -1,14 +1,22 @@
 try:
-    from unittest.mock import Mock
+    from unittest.mock import Mock, patch
 except ImportError:
-    from mock import Mock
+    from mock import Mock, patch
 
-from mock.mock import patch
 from pytest import raises
 
 from circuitbreaker import CircuitBreaker, CircuitBreakerError, circuit
 
 
+class FooError(Exception):
+    def __init__(self, val=None):
+        self.val = val
+
+
+class BarError(Exception):
+    pass
+
+
 def test_circuitbreaker__str__():
     cb = CircuitBreaker(name='Foobar')
     assert str(cb) == 'Foobar'
@@ -47,29 +55,35 @@ def test_circuitbreaker_should_clear_last_exception_on_success_call():
 def test_circuitbreaker_should_call_fallback_function_if_open():
     fallback = Mock(return_value=True)
 
-    func = Mock(return_value=False)
+    func = Mock(return_value=False, __name__="Mock")  # attribute __name__ required for 2.7 compat with functools.wraps
 
     CircuitBreaker.opened = lambda self: True
-    
+
     cb = CircuitBreaker(name='WithFallback', fallback_function=fallback)
-    cb.call(func)
+    decorated_func = cb.decorate(func)
+
+    decorated_func()
     fallback.assert_called_once_with()
 
+
 def test_circuitbreaker_should_not_call_function_if_open():
     fallback = Mock(return_value=True)
 
-    func = Mock(return_value=False)
+    func = Mock(return_value=False, __name__="Mock")  # attribute __name__ required for 2.7 compat with functools.wraps
 
     CircuitBreaker.opened = lambda self: True
-    
+
     cb = CircuitBreaker(name='WithFallback', fallback_function=fallback)
-    assert cb.call(func) == fallback.return_value
+    decorated_func = cb.decorate(func)
+
+    assert decorated_func() == fallback.return_value
     assert not func.called
-    
+
 
 def mocked_function(*args, **kwargs):
     pass
 
+
 def test_circuitbreaker_call_fallback_function_with_parameters():
     fallback = Mock(return_value=True)
 
@@ -79,28 +93,120 @@ def test_circuitbreaker_call_fallback_function_with_parameters():
     cb.opened = lambda self: True
     func_decorated = cb.decorate(mocked_function)
 
-    func_decorated('test2',test='test')
+    func_decorated('test2', test='test')
 
     # check args and kwargs are getting correctly to fallback function
-    
+
     fallback.assert_called_once_with('test2', test='test')
 
+
 @patch('circuitbreaker.CircuitBreaker.decorate')
 def test_circuit_decorator_without_args(circuitbreaker_mock):
-    function = lambda: True
+    def function():
+        return True
+
     circuit(function)
     circuitbreaker_mock.assert_called_once_with(function)
 
 
-@patch('circuitbreaker.CircuitBreaker.__init__')
-def test_circuit_decorator_with_args(circuitbreaker_mock):
-    circuitbreaker_mock.return_value = None
-    function_fallback = lambda: True
-    circuit(10, 20, KeyError, 'foobar', function_fallback)
-    circuitbreaker_mock.assert_called_once_with(
-        expected_exception=KeyError,
-        failure_threshold=10,
-        recovery_timeout=20,
-        name='foobar',
-        fallback_function=function_fallback
-    )
+def test_circuit_decorator_with_args():
+    def function_fallback():
+        return True
+
+    breaker = circuit(10, 20, KeyError, 'foobar', function_fallback)
+
+    assert breaker.is_failure(KeyError, None)
+    assert not breaker.is_failure(Exception, None)
+    assert not breaker.is_failure(FooError, None)
+    assert breaker._failure_threshold == 10
+    assert breaker._recovery_timeout == 20
+    assert breaker._name == "foobar"
+    assert breaker._fallback_function == function_fallback
+
+
+def test_breaker_expected_exception_is_predicate():
+    def is_four_foo(thrown_type, thrown_value):
+        return thrown_value.val == 4
+
+    breaker_four = circuit(expected_exception=is_four_foo)
+
+    assert breaker_four.is_failure(FooError, FooError(4))
+    assert not breaker_four.is_failure(FooError, FooError(2))
+
+
+def test_breaker_default_constructor_traps_Exception():
+    breaker = circuit()
+    assert breaker.is_failure(Exception, Exception())
+    assert breaker.is_failure(FooError, FooError())
+
+
+def test_breaker_expected_exception_is_custom_exception():
+    breaker = circuit(expected_exception=FooError)
+    assert breaker.is_failure(FooError, FooError())
+    assert not breaker.is_failure(Exception, Exception())
+
+
+def test_breaker_constructor_expected_exception_is_exception_list():
+    breaker = circuit(expected_exception=(FooError, BarError))
+    assert breaker.is_failure(FooError, FooError())
+    assert breaker.is_failure(BarError, BarError())
+    assert not breaker.is_failure(Exception, Exception())
+
+
+def test_constructor_mistake_name_bytes():
+    with raises(ValueError, match="expected_exception cannot be a string *"):
+        circuit(10, 20, b"foobar")
+
+
+def test_constructor_mistake_name_unicode():
+    with raises(ValueError, match="expected_exception cannot be a string *"):
+        circuit(10, 20, u"foobar")
+
+
+def test_constructor_mistake_expected_exception():
+    class Widget:
+        pass
+
+    with raises(ValueError, match="expected_exception does not look like a predicate*"):
+        circuit(10, 20, expected_exception=Widget)
+
+
+def test_advanced_usage_circuitbreaker_subclass():
+    class MyCircuitBreaker(CircuitBreaker):
+        EXPECTED_EXCEPTION = FooError
+
+    mybreaker = circuit(cls=MyCircuitBreaker)
+    assert not mybreaker.is_failure(Exception, Exception())
+    assert mybreaker.is_failure(FooError, FooError())
+
+
+def test_advanced_usage_circuitbreaker_subclass_with_list():
+    class MyCircuitBreaker(CircuitBreaker):
+        EXPECTED_EXCEPTION = (FooError, BarError)
+
+    mybreaker = circuit(cls=MyCircuitBreaker)
+    assert not mybreaker.is_failure(Exception, Exception())
+    assert mybreaker.is_failure(FooError, FooError())
+    assert mybreaker.is_failure(BarError, BarError())
+
+
+def test_advanced_usage_circuitbreaker_subclass_with_predicate():
+    def is_foo_4(thrown_type, thrown_value):
+        return issubclass(thrown_type, FooError) and thrown_value.val == 4
+
+    class FooFourBreaker(CircuitBreaker):
+        EXPECTED_EXCEPTION = is_foo_4
+
+    breaker = circuit(cls=FooFourBreaker)
+    assert not breaker.is_failure(Exception, Exception())
+    assert not breaker.is_failure(FooError, FooError())
+    assert breaker.is_failure(FooError, FooError(4))
+
+
+def test_advanced_usage_circuitbreaker_default_expected_exception():
+    class NervousBreaker(CircuitBreaker):
+        FAILURE_THRESHOLD = 1
+
+    breaker = circuit(cls=NervousBreaker)
+    assert breaker._failure_threshold == 1
+    assert breaker.is_failure(Exception, Exception())