Repository: paperless-ngx/paperless-ngx

Issue:
[BUG] setting PAPERLESS_DB_TIMEOUT causes stackdump (psycopg2.ProgrammingError: invalid dsn: invalid connection option "timeout")
### Description

This configuration parameter is **not** specified in any of the default samples/templates; though it does appear in https://docs.paperless-ngx.com/configuration/#database

Paperless runs fine without specifying this.

Specifying any value for this parameter results in the following stackdump:

```
Traceback (most recent call last):
  File "/var/lib/paperless-ngx-v1.15.1/.venv/lib/python3.9/site-packages/django/db/backends/base/base.py", line 282, in ensure_connection
    self.connect()
  File "/var/lib/paperless-ngx-v1.15.1/.venv/lib/python3.9/site-packages/django/utils/asyncio.py", line 26, in inner
    return func(*args, **kwargs)
  File "/var/lib/paperless-ngx-v1.15.1/.venv/lib/python3.9/site-packages/django/db/backends/base/base.py", line 263, in connect
    self.connection = self.get_new_connection(conn_params)
  File "/var/lib/paperless-ngx-v1.15.1/.venv/lib/python3.9/site-packages/django/utils/asyncio.py", line 26, in inner
    return func(*args, **kwargs)
  File "/var/lib/paperless-ngx-v1.15.1/.venv/lib/python3.9/site-packages/django/db/backends/postgresql/base.py", line 215, in get_new_connection
    connection = Database.connect(**conn_params)
  File "/var/lib/paperless-ngx-v1.15.1/.venv/lib/python3.9/site-packages/psycopg2/__init__.py", line 121, in connect
    dsn = _ext.make_dsn(dsn, **kwargs)
  File "/var/lib/paperless-ngx-v1.15.1/.venv/lib/python3.9/site-packages/psycopg2/extensions.py", line 167, in make_dsn
    parse_dsn(dsn)
psycopg2.ProgrammingError: invalid dsn: invalid connection option "timeout"
```

It should be noted that the [psycopg2](https://github.com/psycopg/psycopg2/issues/1241) python module points out that this is consumer use problem, not a library issue.

### Steps to reproduce

1. Setup paperless with postgresql (bare metal in this case, but any deployment should work).
2. Explicitly specify a database timeout option `PAPERLESS_DB_TIMEOUT=5.0`
3. Run paperless, or any `manage.py` commands, such as `createsuperuser`


### Webserver logs

```bash
N/A
```


### Browser logs

```bash
N/A
```


### Paperless-ngx version

1.15.1

### Host OS

Debian bullseye, x64

### Installation method

Bare metal

### Browser

N/A

### Configuration changes

PAPERLESS_DB_TIMEOUT=5.0

### Other

Configuration works without specifying this setting.


Discussion:
The quick solution is to note that is only for SQLite and shouldn't be set for other database types.

Resolving patch:
diff --git a/src/paperless/settings.py b/src/paperless/settings.py
--- a/src/paperless/settings.py
+++ b/src/paperless/settings.py
@@ -476,69 +476,82 @@ def _parse_beat_schedule() -> Dict:
 SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid"
 LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language"
 
+
 ###############################################################################
 # Database                                                                    #
 ###############################################################################
-
-DATABASES = {
-    "default": {
-        "ENGINE": "django.db.backends.sqlite3",
-        "NAME": os.path.join(DATA_DIR, "db.sqlite3"),
-        "OPTIONS": {},
-    },
-}
-
-if os.getenv("PAPERLESS_DBHOST"):
-    # Have sqlite available as a second option for management commands
-    # This is important when migrating to/from sqlite
-    DATABASES["sqlite"] = DATABASES["default"].copy()
-
-    DATABASES["default"] = {
-        "HOST": os.getenv("PAPERLESS_DBHOST"),
-        "NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
-        "USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
-        "PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
-        "OPTIONS": {},
+def _parse_db_settings() -> Dict:
+    databases = {
+        "default": {
+            "ENGINE": "django.db.backends.sqlite3",
+            "NAME": os.path.join(DATA_DIR, "db.sqlite3"),
+            "OPTIONS": {},
+        },
     }
-    if os.getenv("PAPERLESS_DBPORT"):
-        DATABASES["default"]["PORT"] = os.getenv("PAPERLESS_DBPORT")
-
-    # Leave room for future extensibility
-    if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
-        engine = "django.db.backends.mysql"
-        options = {
-            "read_default_file": "/etc/mysql/my.cnf",
-            "charset": "utf8mb4",
-            "ssl": {
-                "ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"),
-                "ca": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
-                "cert": os.getenv("PAPERLESS_DBSSLCERT", None),
-                "key": os.getenv("PAPERLESS_DBSSLKEY", None),
-            },
-        }
-
-        # Silence Django error on old MariaDB versions.
-        # VARCHAR can support > 255 in modern versions
-        # https://docs.djangoproject.com/en/4.1/ref/checks/#database
-        # https://mariadb.com/kb/en/innodb-system-variables/#innodb_large_prefix
-        SILENCED_SYSTEM_CHECKS = ["mysql.W003"]
-
-    else:  # Default to PostgresDB
-        engine = "django.db.backends.postgresql_psycopg2"
-        options = {
-            "sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"),
-            "sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
-            "sslcert": os.getenv("PAPERLESS_DBSSLCERT", None),
-            "sslkey": os.getenv("PAPERLESS_DBSSLKEY", None),
+    if os.getenv("PAPERLESS_DBHOST"):
+        # Have sqlite available as a second option for management commands
+        # This is important when migrating to/from sqlite
+        databases["sqlite"] = databases["default"].copy()
+
+        databases["default"] = {
+            "HOST": os.getenv("PAPERLESS_DBHOST"),
+            "NAME": os.getenv("PAPERLESS_DBNAME", "paperless"),
+            "USER": os.getenv("PAPERLESS_DBUSER", "paperless"),
+            "PASSWORD": os.getenv("PAPERLESS_DBPASS", "paperless"),
+            "OPTIONS": {},
         }
-
-    DATABASES["default"]["ENGINE"] = engine
-    DATABASES["default"]["OPTIONS"].update(options)
-
-if os.getenv("PAPERLESS_DB_TIMEOUT") is not None:
-    DATABASES["default"]["OPTIONS"].update(
-        {"timeout": float(os.getenv("PAPERLESS_DB_TIMEOUT"))},
-    )
+        if os.getenv("PAPERLESS_DBPORT"):
+            databases["default"]["PORT"] = os.getenv("PAPERLESS_DBPORT")
+
+        # Leave room for future extensibility
+        if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
+            engine = "django.db.backends.mysql"
+            options = {
+                "read_default_file": "/etc/mysql/my.cnf",
+                "charset": "utf8mb4",
+                "ssl": {
+                    "ssl_mode": os.getenv("PAPERLESS_DBSSLMODE", "PREFERRED"),
+                    "ca": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
+                    "cert": os.getenv("PAPERLESS_DBSSLCERT", None),
+                    "key": os.getenv("PAPERLESS_DBSSLKEY", None),
+                },
+            }
+
+        else:  # Default to PostgresDB
+            engine = "django.db.backends.postgresql_psycopg2"
+            options = {
+                "sslmode": os.getenv("PAPERLESS_DBSSLMODE", "prefer"),
+                "sslrootcert": os.getenv("PAPERLESS_DBSSLROOTCERT", None),
+                "sslcert": os.getenv("PAPERLESS_DBSSLCERT", None),
+                "sslkey": os.getenv("PAPERLESS_DBSSLKEY", None),
+            }
+
+        databases["default"]["ENGINE"] = engine
+        databases["default"]["OPTIONS"].update(options)
+
+    if os.getenv("PAPERLESS_DB_TIMEOUT") is not None:
+        if databases["default"]["ENGINE"] == "django.db.backends.sqlite3":
+            databases["default"]["OPTIONS"].update(
+                {"timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
+            )
+        else:
+            databases["default"]["OPTIONS"].update(
+                {"connect_timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
+            )
+            databases["sqlite"]["OPTIONS"].update(
+                {"timeout": int(os.getenv("PAPERLESS_DB_TIMEOUT"))},
+            )
+    return databases
+
+
+DATABASES = _parse_db_settings()
+
+if os.getenv("PAPERLESS_DBENGINE") == "mariadb":
+    # Silence Django error on old MariaDB versions.
+    # VARCHAR can support > 255 in modern versions
+    # https://docs.djangoproject.com/en/4.1/ref/checks/#database
+    # https://mariadb.com/kb/en/innodb-system-variables/#innodb_large_prefix
+    SILENCED_SYSTEM_CHECKS = ["mysql.W003"]
 
 DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
 


Test patch:
diff --git a/src/paperless/tests/test_settings.py b/src/paperless/tests/test_settings.py
--- a/src/paperless/tests/test_settings.py
+++ b/src/paperless/tests/test_settings.py
@@ -6,6 +6,7 @@
 from celery.schedules import crontab
 
 from paperless.settings import _parse_beat_schedule
+from paperless.settings import _parse_db_settings
 from paperless.settings import _parse_ignore_dates
 from paperless.settings import _parse_redis_url
 from paperless.settings import default_threads_per_worker
@@ -291,3 +292,60 @@ def test_schedule_configuration_disabled_all(self):
             {},
             schedule,
         )
+
+
+class TestDBSettings(TestCase):
+    def test_db_timeout_with_sqlite(self):
+        """
+        GIVEN:
+            - PAPERLESS_DB_TIMEOUT is set
+        WHEN:
+            - Settings are parsed
+        THEN:
+            - PAPERLESS_DB_TIMEOUT set for sqlite
+        """
+        with mock.patch.dict(
+            os.environ,
+            {
+                "PAPERLESS_DB_TIMEOUT": "10",
+            },
+        ):
+            databases = _parse_db_settings()
+
+            self.assertDictEqual(
+                {
+                    "timeout": 10.0,
+                },
+                databases["default"]["OPTIONS"],
+            )
+
+    def test_db_timeout_with_not_sqlite(self):
+        """
+        GIVEN:
+            - PAPERLESS_DB_TIMEOUT is set but db is not sqlite
+        WHEN:
+            - Settings are parsed
+        THEN:
+            - PAPERLESS_DB_TIMEOUT set correctly in non-sqlite db & for fallback sqlite db
+        """
+        with mock.patch.dict(
+            os.environ,
+            {
+                "PAPERLESS_DBHOST": "127.0.0.1",
+                "PAPERLESS_DB_TIMEOUT": "10",
+            },
+        ):
+            databases = _parse_db_settings()
+
+            self.assertDictContainsSubset(
+                {
+                    "connect_timeout": 10.0,
+                },
+                databases["default"]["OPTIONS"],
+            )
+            self.assertDictEqual(
+                {
+                    "timeout": 10.0,
+                },
+                databases["sqlite"]["OPTIONS"],
+            )