pax_global_header00006660000000000000000000000064152712026100014506gustar00rootroot0000000000000052 comment=187c0e692295725fab7df4f1fd486790c19aae1a jdelic-django-dbconn-retry-187c0e6/000077500000000000000000000000001527120261000171435ustar00rootroot00000000000000jdelic-django-dbconn-retry-187c0e6/.github/000077500000000000000000000000001527120261000205035ustar00rootroot00000000000000jdelic-django-dbconn-retry-187c0e6/.github/dependabot.yml000066400000000000000000000010201527120261000233240ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" jdelic-django-dbconn-retry-187c0e6/.github/workflows/000077500000000000000000000000001527120261000225405ustar00rootroot00000000000000jdelic-django-dbconn-retry-187c0e6/.github/workflows/django.yml000066400000000000000000000030341527120261000245250ustar00rootroot00000000000000name: Django CI on: push: branches: [ "master" ] pull_request: branches: [ "master" ] jobs: build: runs-on: ubuntu-latest strategy: max-parallel: 4 matrix: python-version: ["3.13", "3.14"] steps: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install Dependencies run: | python -m pip install --upgrade pip wheel pip install -r requirements-test.txt -e . - name: Start PostgreSQL run: | sudo systemctl start postgresql.service pg_isready - name: Set up PostgreSQL run: | sudo -u postgres psql --command="CREATE USER dbconntest WITH CREATEDB LOGIN PASSWORD 'dbconntest'" --command="\du" sudo -u postgres createdb -O dbconntest -p 5432 dbconntest - name: Run Tests run: | coverage run --source=django_dbconn_retry test_project/manage.py test coverage report -m coverage lcov - name: Run Coveralls uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} file: coverage.lcov parallel: true - name: Run Flake8 run: flake8 --max-line-length=120 django_dbconn_retry finish: needs: build if: ${{ always() }} runs-on: ubuntu-latest steps: - name: Close parallel build uses: coverallsapp/github-action@v2 with: parallel-finished: true jdelic-django-dbconn-retry-187c0e6/.github/workflows/publish.yml000066400000000000000000000022271527120261000247340ustar00rootroot00000000000000name: Publish Python package on: push: tags: - 'v*' permissions: contents: read jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version: '3.14' - name: Install build tools run: | python -m pip install --upgrade pip python -m pip install build twine - name: Check that the tag matches the package version run: | version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" if [ "$GITHUB_REF_NAME" != "v$version" ]; then echo "::error::Tag $GITHUB_REF_NAME doesn't match the version $version in pyproject.toml" exit 1 fi - name: Build sdist and wheel run: python -m build - name: Check distribution metadata run: python -m twine check --strict dist/* - name: Publish to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} run: python -m twine upload dist/* jdelic-django-dbconn-retry-187c0e6/.gitignore000066400000000000000000000000731527120261000211330ustar00rootroot00000000000000.coverage *.iml *.pyc *.pyd build/ dist/ test/ *.egg-info/ jdelic-django-dbconn-retry-187c0e6/LICENSE000066400000000000000000000027321527120261000201540ustar00rootroot00000000000000Copyright (c) 2018, Jonas Maurus All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 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 the copyright holder nor the names of its 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 HOLDER 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. jdelic-django-dbconn-retry-187c0e6/MANIFEST.in000066400000000000000000000001251527120261000206770ustar00rootroot00000000000000recursive-include django_dbconn_retry/tests *.py recursive-include test_project *.py jdelic-django-dbconn-retry-187c0e6/README.rst000066400000000000000000000172171527120261000206420ustar00rootroot00000000000000Django Database Connection Autoreconnect ======================================== .. image:: https://coveralls.io/repos/github/jdelic/django-dbconn-retry/badge.svg?branch=master :target: https://coveralls.io/github/jdelic/django-dbconn-retry?branch=master .. image:: https://github.com/jdelic/django-dbconn-retry/actions/workflows/django.yml/badge.svg :target: https://github.com/jdelic/django-dbconn-retry/actions This library monkeypatches ``django.db.backends.base.BaseDatabaseWrapper`` so that when a database operation fails because the underlying TCP connection was already closed, it first tries to reconnect, instead of immediately raising an ``OperationException``. Why is this useful? ------------------- I use `HAProxy`_ as a load-balancer in front of my PostgreSQL databases all the time, sometimes in addition to ``pgbouncer``. Even though you can mostly prevent surprises by enabling TCP keep-alive packets through `tcpka`_, `clitcpka`_ and `srvtcpka`_, I still encounter situations where the underlying TCP connection has been closed through the load-balancer. Most often this results in .. code-block:: django.db.utils.OperationalError: server closed the connection unexpectedly This probably means the server terminated abnormally before or while processing the request. This library patches Django such that it will try to reconnect once before failing. Another application of this is when using `Hashicorp Vault`_, where credentials for a database connection can expire at any time and then need to be refreshed from Vault. How to install? --------------- Just pull the library in using ``pip install django-dbconn-retry``. Then add ``django_dbconn_retry`` to ``INSTALLED_APPS`` in your ``settings.py``. Signals ------- The library provides an interface for other code to plug into the process to, for example, allow `12factor-vault`_ to refresh the database credentials before the code tries to reestablish the database connection. These are implemented using `Django Signals`_. =========================== ================================================== Signal Description =========================== ================================================== ``pre_reconnect`` Installs a hook of the type ``Callable[[type, BaseDatabaseWrapper], None]`` that will be called before the library tries to reestablish a connection. 12factor-vault uses this to refresh the database credentials from Vault. Once ``MAX_DBCONN_RETRY_TIMES`` is exhausted, the library makes only a single connection attempt per request until one succeeds. It still calls this hook before each of those attempts so that they can use refreshed credentials. ``post_reconnect`` Installs a hook of the type ``Callable[[type, BaseDatabaseWrapper], None]`` that will be called after the library tried to reestablish the connection. Success or failure has not been tested at this point. So the connection may be in any state. =========================== ================================================== Both signals send a parameter ``dbwrapper`` which points to the current instance of ``django.db.backends.base.BaseDatabaseWrapper`` which allows the signal receiver to act on the database connection. Settings -------- Here’s a list of settings available in django-dbconn-retry and their default values. You can change the value in your ``settings.py``. =========================== ================================================== Setting Description =========================== ================================================== ``MAX_DBCONN_RETRY_TIMES`` Default: ``1`` The max times which django-dbconn-retry will try. Set to ``0`` to disable retries entirely. ``DBCONN_RETRY_DELAY`` Default: ``0`` The base delay in seconds before each retry attempt. Used as the initial delay and, together with ``DBCONN_RETRY_BACKOFF``, controls the delay for subsequent retries. Set to ``0`` to retry immediately. ``DBCONN_RETRY_BACKOFF`` Default: ``1`` The multiplier applied to the delay after each retry. For example, with ``DBCONN_RETRY_DELAY=1`` and ``DBCONN_RETRY_BACKOFF=2``, the delays will be 1s, 2s, 4s, etc. Note that delays grow exponentially and can become very large for higher values. For example, with ``DBCONN_RETRY_DELAY=10``, ``DBCONN_RETRY_BACKOFF=3`` and ``MAX_DBCONN_RETRY_TIMES=10``, the final retry would wait on the order of tens of hours. Choose these settings carefully if very long waits are not acceptable for your deployment. =========================== ================================================== Contributors ------------ Many thanks to people who have contributed to this library: * durant (`@zhangzhw8 `__) * Shawn Zivontsis (`@shawnz `__) License ======= Copyright (c) 2018, Jonas Maurus All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 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 the copyright holder nor the names of its 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 HOLDER 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. .. _12factor-vault: https://github.com/jdelic/12factor-vault/ .. _Django Signals: https://docs.djangoproject.com/en/dev/topics/signals/ .. _HAProxy: http://www.haproxy.org/ .. _tcpka: https://cbonte.github.io/haproxy-dconv/1.8/configuration.html#option%20tcpka .. _clitcpka: https://cbonte.github.io/haproxy-dconv/1.8/configuration.html#4-option%20clitcpka .. _srvtcpka: https://cbonte.github.io/haproxy-dconv/1.8/configuration.html#option%20srvtcpka .. _Hashicorp Vault: https://vaultproject.io/ jdelic-django-dbconn-retry-187c0e6/django_dbconn_retry/000077500000000000000000000000001527120261000231555ustar00rootroot00000000000000jdelic-django-dbconn-retry-187c0e6/django_dbconn_retry/__init__.py000066400000000000000000000003251527120261000252660ustar00rootroot00000000000000# -* encoding: utf-8 *- from django_dbconn_retry.apps import pre_reconnect, post_reconnect, monkeypatch_django, DjangoIntegration __all__ = [pre_reconnect, post_reconnect, monkeypatch_django, DjangoIntegration] jdelic-django-dbconn-retry-187c0e6/django_dbconn_retry/apps.py000066400000000000000000000155341527120261000245020ustar00rootroot00000000000000import logging import time from django.apps.config import AppConfig from django.conf import settings from django.db.backends.base import base as django_db_base from django.db.utils import ProgrammingError from django.dispatch import Signal from typing import Union, Tuple, Callable, List # noqa. flake8 #118 _log = logging.getLogger(__name__) pre_reconnect = Signal() post_reconnect = Signal() _operror_types = () # type: Union[Tuple[type], Tuple] database_modules = [ ("django.db.utils", "OperationalError"), ("psycopg2", "OperationalError"), ("psycopg", "OperationalError"), ("sqlite3", "OperationalError"), ("MySQLdb", "OperationalError"), ("pyodbc", "InterfaceError"), ] for module_name, error_name in database_modules: try: module = __import__(module_name, fromlist=[error_name]) error_type = getattr(module, error_name) _operror_types += (error_type,) except ImportError: pass def monkeypatch_django() -> None: def ensure_connection_with_retries(self: django_db_base.BaseDatabaseWrapper) -> None: self._max_dbconn_retry_times = getattr(settings, "MAX_DBCONN_RETRY_TIMES", 1) # Validate and normalize max retry times to a non-negative integer if not isinstance(self._max_dbconn_retry_times, int) or self._max_dbconn_retry_times < 0: _log.warning( "Invalid MAX_DBCONN_RETRY_TIMES setting %r; falling back to 1.", self._max_dbconn_retry_times, ) self._max_dbconn_retry_times = 1 self._dbconn_retry_delay = getattr(settings, "DBCONN_RETRY_DELAY", 0) # Validate and normalize retry delay to a non-negative number if not isinstance(self._dbconn_retry_delay, (int, float)) or self._dbconn_retry_delay < 0: _log.warning( "Invalid DBCONN_RETRY_DELAY setting %r; falling back to 0 seconds.", self._dbconn_retry_delay, ) self._dbconn_retry_delay = 0 self._dbconn_retry_backoff = getattr(settings, "DBCONN_RETRY_BACKOFF", 1) # Validate and normalize backoff factor to a positive number if not isinstance(self._dbconn_retry_backoff, (int, float)) or self._dbconn_retry_backoff <= 0: _log.warning( "Invalid DBCONN_RETRY_BACKOFF setting %r; falling back to 1.", self._dbconn_retry_backoff, ) self._dbconn_retry_backoff = 1 if self.connection is not None and hasattr(self.connection, 'closed') and self.connection.closed: _log.debug("failed connection detected") if self.in_atomic_block: self.closed_in_transaction = True self.connection = None if self.connection is None and not hasattr(self, '_in_connecting'): if self.in_atomic_block and self.closed_in_transaction: raise ProgrammingError("Cannot reconnect to the database in an atomic block.") with self.wrap_database_errors: try: self._in_connecting = True if self._max_dbconn_retry_times > 0 and getattr(self, "_dbconn_failing_fast", False): # Retries have been exhausted before, so we only make a single attempt until one succeeds. # Still give libraries like 12factor-vault the chance to update the credentials first, # otherwise no attempt could ever succeed once they have expired. pre_reconnect.send(self.__class__, dbwrapper=self) self.connect() except Exception as e: if isinstance(e, _operror_types): if self._max_dbconn_retry_times == 0: _log.info("Not reconnecting; MAX_DBCONN_RETRY_TIMES=0.") raise elif ( hasattr(self, "_connection_retries") and self._connection_retries >= self._max_dbconn_retry_times ): _log.error("Reconnecting to the database didn't help %s", str(e)) del self._in_connecting post_reconnect.send(self.__class__, dbwrapper=self) # _connection_retries is deliberately not reset here, so further connection attempts fail # fast instead of hammering a struggling database until one succeeds. self._dbconn_failing_fast = True raise else: _log.info("Database connection failed. Refreshing...") # mark the retry try: self._connection_retries += 1 except AttributeError: self._connection_retries = 1 # ensure that we retry the connection. Sometimes .closed isn't set correctly. self.connection = None del self._in_connecting # apply delay with backoff before retry if self._dbconn_retry_delay > 0: current_delay = self._dbconn_retry_delay * ( self._dbconn_retry_backoff ** (self._connection_retries - 1) ) _log.debug("Waiting %.2f seconds before retry attempt %d", current_delay, self._connection_retries) time.sleep(current_delay) # give libraries like 12factor-vault the chance to update the credentials pre_reconnect.send(self.__class__, dbwrapper=self) self.ensure_connection() post_reconnect.send(self.__class__, dbwrapper=self) else: _log.debug("Database connection failed, but not due to a known error for dbconn_retry %s", str(e)) raise else: # connection successful, reset the retry counter self._connection_retries = 0 self._dbconn_failing_fast = False finally: # always clear the flag if hasattr(self, '_in_connecting'): del self._in_connecting _log.debug("django_dbconn_retry: monkeypatching BaseDatabaseWrapper") django_db_base.BaseDatabaseWrapper.ensure_connection = ensure_connection_with_retries class DjangoIntegration(AppConfig): name = "django_dbconn_retry" def ready(self) -> None: monkeypatch_django() jdelic-django-dbconn-retry-187c0e6/django_dbconn_retry/tests/000077500000000000000000000000001527120261000243175ustar00rootroot00000000000000jdelic-django-dbconn-retry-187c0e6/django_dbconn_retry/tests/__init__.py000066400000000000000000000307611527120261000264370ustar00rootroot00000000000000# -* encoding: utf-8 *- import random import sys import logging from unittest.mock import Mock, patch from typing import Any import django_dbconn_retry as ddr from django.db.backends.base.base import BaseDatabaseWrapper from django.db import connection, OperationalError, ProgrammingError, transaction from django.test import TestCase, TransactionTestCase, override_settings logging.basicConfig(stream=sys.stderr) logging.getLogger("django_dbconn_retry").setLevel(logging.DEBUG) _log = logging.getLogger(__name__) class MockBaseException(BaseException): pass class FullErrorTests(TestCase): """ This is SUPERHACKY. I couldn't find a better way to ensure that the database connections reliably fail. If I had been able to think of a better way, I'd have used it. """ max_dbconn_retry_times = random.randint(1, 100) def test_getting_root(self) -> None: self.client.get('/') def setUp(self) -> None: _log.debug("[FullErrorTests] patching for setup") self.s_connect = BaseDatabaseWrapper.connect BaseDatabaseWrapper.connect = Mock(side_effect=OperationalError('fail testing')) BaseDatabaseWrapper.connection = property(lambda x: None, lambda x, y: None) # type: ignore def tearDown(self) -> None: _log.debug("[FullErrorTests] restoring") BaseDatabaseWrapper.connect = self.s_connect del BaseDatabaseWrapper.connection def do_assert(self, cb): self.assertRaises(OperationalError, connection.ensure_connection) self.assertTrue(cb.called) self.assertEqual(connection._connection_retries, self.max_dbconn_retry_times) del connection._connection_retries @override_settings(MAX_DBCONN_RETRY_TIMES=max_dbconn_retry_times) def test_prehook(self) -> None: cb = Mock(name='pre_reconnect_hook') ddr.pre_reconnect.connect(cb) self.do_assert(cb) @override_settings(MAX_DBCONN_RETRY_TIMES=max_dbconn_retry_times) def test_posthook(self) -> None: cb = Mock(name='post_reconnect_hook') ddr.post_reconnect.connect(cb) self.do_assert(cb) @override_settings(MAX_DBCONN_RETRY_TIMES=max_dbconn_retry_times) def test_non_operational_error_propagates(self) -> None: BaseDatabaseWrapper.connect = Mock(side_effect=ValueError('not a db error')) self.assertRaises(ValueError, connection.ensure_connection) BaseDatabaseWrapper.connect.assert_called_once() @override_settings(MAX_DBCONN_RETRY_TIMES=max_dbconn_retry_times) def test_base_exception_clears_in_connecting(self) -> None: BaseDatabaseWrapper.connect = Mock(side_effect=MockBaseException()) self.assertRaises(MockBaseException, connection.ensure_connection) self.assertFalse(hasattr(connection, '_in_connecting')) def fix_connection(sender: type, *, dbwrapper: BaseDatabaseWrapper, **kwargs: Any) -> None: dbwrapper.connect = dbwrapper.s_connect class ReconnectTests(TransactionTestCase): def test_ensure_closed(self) -> None: from django.db import connection connection.close() self.assertFalse(connection.is_usable()) # should be true after setUp def do_assert(self, cb): from django.db import connection connection.close() connection.s_connect = connection.connect connection.connect = Mock(side_effect=OperationalError('reconnect testing')) connection.ensure_connection() self.assertTrue(cb.called) self.assertTrue(connection.is_usable()) self.assertEqual(connection._connection_retries, 0) def test_prehook(self) -> None: cb = Mock(name='pre_reconnect_hook') ddr.pre_reconnect.connect(fix_connection) ddr.pre_reconnect.connect(cb) self.do_assert(cb) def test_posthook(self) -> None: cb = Mock(name='post_reconnect_hook') ddr.pre_reconnect.connect(fix_connection) ddr.post_reconnect.connect(cb) self.do_assert(cb) @override_settings(MAX_DBCONN_RETRY_TIMES=1) def test_prehook_after_exhausted_retries(self) -> None: """ Once retries are exhausted, connection attempts fail fast, but pre_reconnect must still be sent before each attempt so that hooks can refresh expired credentials and a later attempt can succeed. """ from django.db import connection credentials_rotated = False hook_calls = 0 def rotate_credentials(sender: type, *, dbwrapper: BaseDatabaseWrapper, **kwargs: Any) -> None: nonlocal hook_calls hook_calls += 1 # only fix the connection once new credentials are available if credentials_rotated: fix_connection(sender, dbwrapper=dbwrapper) # other tests leave fix_connection connected, which would fix the connection during the first retries ddr.pre_reconnect.disconnect(fix_connection) connection._dbconn_failing_fast = False connection.close() connection.s_connect = connection.connect failing_connect = Mock(side_effect=OperationalError('expired credentials')) connection.connect = failing_connect ddr.pre_reconnect.connect(rotate_credentials) try: self.assertRaises(OperationalError, connection.ensure_connection) self.assertEqual(failing_connect.call_count, 2) self.assertEqual(hook_calls, 1) self.assertEqual(connection._connection_retries, 1) # retries are exhausted, so this fails fast after a single attempt, but sends pre_reconnect first self.assertRaises(OperationalError, connection.ensure_connection) self.assertEqual(failing_connect.call_count, 3) self.assertEqual(hook_calls, 2) # the hook rotates the credentials before the single attempt, so it succeeds credentials_rotated = True connection.ensure_connection() self.assertEqual(failing_connect.call_count, 3) self.assertEqual(hook_calls, 3) self.assertTrue(connection.is_usable()) self.assertEqual(connection._connection_retries, 0) self.assertFalse(connection._dbconn_failing_fast) finally: ddr.pre_reconnect.disconnect(rotate_credentials) connection.connect = connection.s_connect class DelayBackoffTests(TestCase): """ Tests for the delay and backoff retry functionality. """ def setUp(self) -> None: _log.debug("[DelayBackoffTests] patching for setup") self.s_connect = BaseDatabaseWrapper.connect BaseDatabaseWrapper.connect = Mock(side_effect=OperationalError('backoff testing')) BaseDatabaseWrapper.connection = property(lambda x: None, lambda x, y: None) # type: ignore def tearDown(self) -> None: _log.debug("[DelayBackoffTests] restoring") BaseDatabaseWrapper.connect = self.s_connect del BaseDatabaseWrapper.connection @override_settings(MAX_DBCONN_RETRY_TIMES=3, DBCONN_RETRY_DELAY=1.0, DBCONN_RETRY_BACKOFF=2.0) @patch('django_dbconn_retry.apps.time.sleep') def test_delay_with_backoff(self, mock_sleep: Mock) -> None: """Test that delay increases with backoff multiplier.""" self.assertRaises(OperationalError, connection.ensure_connection) # With 3 retries, delay=1.0, backoff=2.0: # retry 1: 1.0 * 2.0^0 = 1.0 # retry 2: 1.0 * 2.0^1 = 2.0 # retry 3: 1.0 * 2.0^2 = 4.0 self.assertEqual(mock_sleep.call_count, 3) expected_delays = [1.0, 2.0, 4.0] for i, call in enumerate(mock_sleep.call_args_list): self.assertEqual(call[0][0], expected_delays[i]) del connection._connection_retries @override_settings(MAX_DBCONN_RETRY_TIMES=3) @patch('django_dbconn_retry.apps.time.sleep') def test_no_delay_by_default(self, mock_sleep: Mock) -> None: """Test that sleep is not called with default settings (delay=0).""" self.assertRaises(OperationalError, connection.ensure_connection) mock_sleep.assert_not_called() self.assertEqual(connection._connection_retries, 3) del connection._connection_retries @override_settings(MAX_DBCONN_RETRY_TIMES=3, DBCONN_RETRY_DELAY=1.0) @patch('django_dbconn_retry.apps.time.sleep') def test_constant_delay_by_default(self, mock_sleep: Mock) -> None: """Test that delay stays constant with default backoff (backoff=1).""" self.assertRaises(OperationalError, connection.ensure_connection) # With default backoff=1, all delays should be equal to the base delay self.assertEqual(mock_sleep.call_count, 3) for call in mock_sleep.call_args_list: self.assertEqual(call[0][0], 1.0) del connection._connection_retries @override_settings(DBCONN_RETRY_DELAY="invalid") @patch("django_dbconn_retry.apps.time.sleep") def test_invalid_retry_delay(self, mock_sleep: Mock) -> None: """Test that an invalid delay defaults to zero""" self.assertRaises(OperationalError, connection.ensure_connection) mock_sleep.assert_not_called() self.assertEqual(connection._connection_retries, 1) del connection._connection_retries @override_settings( MAX_DBCONN_RETRY_TIMES=3, DBCONN_RETRY_DELAY=1.0, DBCONN_RETRY_BACKOFF="invalid", ) @patch("django_dbconn_retry.apps.time.sleep") def test_invalid_retry_backoff(self, mock_sleep: Mock) -> None: """Test that an invalid backoff defaults to no backoff""" self.assertRaises(OperationalError, connection.ensure_connection) self.assertEqual(mock_sleep.call_count, 3) for call in mock_sleep.call_args_list: self.assertEqual(call[0][0], 1.0) del connection._connection_retries class MaxRetriesTests(TestCase): """ Tests for MAX_DBCONN_RETRY_TIMES validation and the zero-retries case. """ def setUp(self) -> None: _log.debug("[MaxRetriesTests] patching for setup") self.s_connect = BaseDatabaseWrapper.connect BaseDatabaseWrapper.connect = Mock(side_effect=OperationalError('max retries testing')) BaseDatabaseWrapper.connection = property(lambda x: None, lambda x, y: None) # type: ignore def tearDown(self) -> None: _log.debug("[MaxRetriesTests] restoring") BaseDatabaseWrapper.connect = self.s_connect del BaseDatabaseWrapper.connection @override_settings(MAX_DBCONN_RETRY_TIMES=0) def test_zero_disables_retries(self) -> None: pre_cb = Mock(name='pre_reconnect_hook') post_cb = Mock(name='post_reconnect_hook') ddr.pre_reconnect.connect(pre_cb) ddr.post_reconnect.connect(post_cb) self.assertRaises(OperationalError, connection.ensure_connection) BaseDatabaseWrapper.connect.assert_called_once() self.assertFalse(pre_cb.called) self.assertFalse(post_cb.called) @override_settings(MAX_DBCONN_RETRY_TIMES="invalid") def test_invalid_max_retry_times(self) -> None: self.assertRaises(OperationalError, connection.ensure_connection) self.assertEqual(connection._connection_retries, 1) del connection._connection_retries class AtomicBlockTests(TransactionTestCase): """ Tests for connection retry behavior inside transaction.atomic() blocks. """ def test_no_retry_when_connection_lost_in_atomic_block(self) -> None: from django.db import connection connection.ensure_connection() with transaction.atomic(): mock_conn = Mock() mock_conn.closed = True connection.connection = mock_conn with self.assertRaises(ProgrammingError): connection.ensure_connection() # After exiting the atomic block, reconnection should work connection.ensure_connection() self.assertTrue(connection.is_usable()) def test_no_retry_when_connection_closed_in_atomic_block(self) -> None: from django.db import connection connection.ensure_connection() with transaction.atomic(): connection.close() with self.assertRaises(ProgrammingError): connection.ensure_connection() # After exiting the atomic block, reconnection should work connection.ensure_connection() self.assertTrue(connection.is_usable()) def test_lazy_connection_in_atomic_block_allowed(self) -> None: from django.db import connection connection.close() with transaction.atomic(): connection.ensure_connection() self.assertTrue(connection.is_usable()) jdelic-django-dbconn-retry-187c0e6/pyproject.toml000066400000000000000000000017251527120261000220640ustar00rootroot00000000000000[build-system] requires = ["setuptools>=77.0"] build-backend = "setuptools.build_meta" [project] name = "django-dbconn-retry" version = "0.3.3" description = "Patch Django to retry a database connection first before failing." readme = { file = "README.rst", content-type = "text/x-rst" } license = "BSD-3-Clause" license-files = ["LICENSE"] authors = [ { name = "Jonas Maurus (@jdelic)", email = "jonas-dbconn-retry@gopythongo.com" }, ] maintainers = [ { name = "GoPythonGo.com", email = "info@gopythongo.com" }, ] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "Programming Language :: Python :: 3 :: Only", "Operating System :: POSIX", ] urls = { Homepage = "https://github.com/jdelic/django-dbconn-retry/" } dependencies = [] [tool.setuptools] packages = ["django_dbconn_retry"] # MANIFEST.in adds the tests to the sdist, keep them out of the wheel include-package-data = false jdelic-django-dbconn-retry-187c0e6/requirements-test.txt000066400000000000000000000002111527120261000233760ustar00rootroot00000000000000coverage==7.15.4 flake8==7.3.0 doc8==2.0.0 mypy==2.4.0 psycopg2-binary==2.9.13 Django==6.1.2 django-stubs==6.1.2 django-stubs-ext==6.1.0 jdelic-django-dbconn-retry-187c0e6/test_project/000077500000000000000000000000001527120261000216505ustar00rootroot00000000000000jdelic-django-dbconn-retry-187c0e6/test_project/manage.py000066400000000000000000000012341527120261000234520ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os import sys def main(): """Run administrative tasks.""" os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) if __name__ == '__main__': main() jdelic-django-dbconn-retry-187c0e6/test_project/test_project/000077500000000000000000000000001527120261000243555ustar00rootroot00000000000000jdelic-django-dbconn-retry-187c0e6/test_project/test_project/__init__.py000066400000000000000000000000001527120261000264540ustar00rootroot00000000000000jdelic-django-dbconn-retry-187c0e6/test_project/test_project/asgi.py000066400000000000000000000006211527120261000256510ustar00rootroot00000000000000""" ASGI config for test_project project. It exposes the ASGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ """ import os from django.core.asgi import get_asgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') application = get_asgi_application() jdelic-django-dbconn-retry-187c0e6/test_project/test_project/settings.py000066400000000000000000000100131527120261000265620ustar00rootroot00000000000000""" Django settings for test_project project. Generated by 'django-admin startproject' using Django 4.0.2. For more information on this file, see https://docs.djangoproject.com/en/4.0/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.0/ref/settings/ """ from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = 'django-insecure-=#1bt1w$67$t9d80$z9v8yf*4=#o1a85s74#vmux46&9rz3)wv' # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ 'django_dbconn_retry', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] ROOT_URLCONF = 'test_project.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, }, ] WSGI_APPLICATION = 'test_project.wsgi.application' # Database # https://docs.djangoproject.com/en/4.0/ref/settings/#databases DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'dbconntest', 'USER': 'dbconntest', 'HOST': 'localhost', 'PASSWORD': 'dbconntest', } } # Password validation # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', }, { 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', }, { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, ] # Internationalization # https://docs.djangoproject.com/en/4.0/topics/i18n/ LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.0/howto/static-files/ STATIC_URL = 'static/' # Default primary key field type # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' LOGGING = { "version": 1, "disable_existing_loggers": False, "formatters": { "simple": { "format": "%(asctime)s %(levelname)s %(message)s", }, }, "handlers": { "application_logs": { "level": "DEBUG", "formatter": "simple", "class": 'logging.StreamHandler', "stream": 'ext://sys.stderr', }, "server_logs": { "level": "INFO", "formatter": "simple", "class": 'logging.StreamHandler', "stream": 'ext://sys.stdout', }, }, "loggers": { "": { "handlers": ["application_logs"], "level": "DEBUG", "propagate": True, }, }, } jdelic-django-dbconn-retry-187c0e6/test_project/test_project/urls.py000066400000000000000000000015741527120261000257230ustar00rootroot00000000000000"""test_project URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/4.0/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: path('', views.home, name='home') Class-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.urls import path from django.http import HttpRequest, HttpResponse def testview(request: HttpRequest) -> HttpResponse: return HttpResponse("nothing here", content_type='text/plain', status=200) urlpatterns = [ path('', testview), ] jdelic-django-dbconn-retry-187c0e6/test_project/test_project/wsgi.py000066400000000000000000000006211527120261000256770ustar00rootroot00000000000000""" WSGI config for test_project project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') application = get_wsgi_application() jdelic-django-dbconn-retry-187c0e6/tox.ini000066400000000000000000000010561527120261000204600ustar00rootroot00000000000000[tox] envlist = flake8, doc8, mypy, django, [testenv] envdir = {toxinidir}/.toxenv usedevelop = true deps = -rrequirements-test.txt django: Django setenv = MYPYPATH=. passenv = COVERALLS_REPO_TOKEN commands = django: coverage run --source=django_dbconn_retry test_project/manage.py test django: coverage report -m django: coveralls flake8: flake8 --max-line-length=120 django_dbconn_retry doc8: doc8 README.rst mypy: mypy --ignore-missing-imports --follow-imports=skip --disallow-untyped-calls --disallow-untyped-defs -p django_dbconn_retry