From 66b95cde5b2e8c830dc3611d42e1226da7ea0e3f Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 8 Aug 2026 18:11:30 +1200 Subject: [PATCH] [API] Handle AppRegistryNotReady (#12571) Explicit handling for AppRegistryNotReady error --- src/backend/InvenTree/InvenTree/exceptions.py | 19 ++++++++++++++++ src/backend/InvenTree/InvenTree/test_api.py | 22 +++++++++++++++++++ src/backend/InvenTree/plugin/base/ui/tests.py | 21 ++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/src/backend/InvenTree/InvenTree/exceptions.py b/src/backend/InvenTree/InvenTree/exceptions.py index 597f1f5063..76aaf53868 100644 --- a/src/backend/InvenTree/InvenTree/exceptions.py +++ b/src/backend/InvenTree/InvenTree/exceptions.py @@ -7,6 +7,7 @@ import traceback from typing import Optional from django.conf import settings +from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import ValidationError as DjangoValidationError from django.utils.translation import gettext_lazy as _ @@ -121,6 +122,24 @@ def exception_handler(exc, context): # If sentry.io fails, we don't want to crash the server! pass + # The Django app registry can be transiently un-ready while the plugin + # registry is reloading apps (see plugin.registry.PluginsRegistry._reload_apps). + # Any request handled by another thread/worker during that window can trip + # this - it is not a real server error, so ask the client to retry shortly + # rather than surfacing a 500. + if isinstance(exc, AppRegistryNotReady): + response = Response( + { + 'error': 'AppRegistryNotReady', + 'detail': _( + 'Server is temporarily reloading, please retry the request' + ), + }, + status=503, + ) + response['Retry-After'] = '1' + return response + # Catch any django validation error, and re-throw a DRF validation error if isinstance(exc, DjangoValidationError): exc = DRFValidationError(detail=serializers.as_serializer_error(exc)) diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index 028e305926..c74632d4bc 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -4,12 +4,15 @@ from base64 import b64encode from pathlib import Path from tempfile import TemporaryDirectory +from django.core.exceptions import AppRegistryNotReady +from django.test import TestCase from django.urls import reverse from rest_framework import status from InvenTree.api import read_license_file from InvenTree.api_version import INVENTREE_API_VERSION +from InvenTree.exceptions import exception_handler from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase from InvenTree.version import inventreeApiText, parse_version_text from users.ruleset import RULESET_NAMES @@ -66,6 +69,25 @@ class HTMLAPITests(InvenTreeTestCase): self.assertEqual(response.status_code, 404) +class ExceptionHandlerTests(TestCase): + """Tests for the custom DRF exception handler.""" + + def test_app_registry_not_ready(self): + """AppRegistryNotReady should be reported as a transient 503, not a 500. + + Regression test: this can be raised on a request served by one thread while + the plugin registry is mid-reload on another (see + plugin.registry.PluginsRegistry._reload_apps, which briefly clears Django's + app registry) - it is not a genuine server error, so the client should be + told to retry rather than seeing a hard failure. + """ + response = exception_handler(AppRegistryNotReady(), {}) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.data['error'], 'AppRegistryNotReady') + self.assertEqual(response['Retry-After'], '1') + + class ApiAccessTests(InvenTreeAPITestCase): """Tests for various access scenarios with the InvenTree API.""" diff --git a/src/backend/InvenTree/plugin/base/ui/tests.py b/src/backend/InvenTree/plugin/base/ui/tests.py index 0a4a94977b..f05eac0046 100644 --- a/src/backend/InvenTree/plugin/base/ui/tests.py +++ b/src/backend/InvenTree/plugin/base/ui/tests.py @@ -1,5 +1,8 @@ """Unit tests for base mixins for plugins.""" +from unittest import mock + +from django.core.exceptions import AppRegistryNotReady from django.urls import reverse from common.models import InvenTreeSetting @@ -86,6 +89,24 @@ class UserInterfaceMixinTests(InvenTreeAPITestCase): response = self.get(url) self.assertEqual(len(response.data), 3) + def test_ui_feature_list_app_registry_not_ready(self): + """A mid-reload AppRegistryNotReady should surface as a 503, not a 500. + + Regression test: the plugin registry can force-reload Django's app registry + (e.g. when ENABLE_PLUGINS_INTERFACE is toggled - see + common.setting.system.reload_plugin_registry) while other requests are still + being served, so this endpoint's own settings lookup can race that reload. + """ + url = reverse('api-plugin-ui-feature-list', kwargs={'feature': 'dashboard'}) + + with mock.patch( + 'plugin.base.ui.api.get_global_setting', side_effect=AppRegistryNotReady() + ): + response = self.get(url, expected_code=503) + + self.assertEqual(response.data['error'], 'AppRegistryNotReady') + self.assertEqual(response['Retry-After'], '1') + def test_ui_panels(self): """Test that the sample UI plugin provides custom panels.""" from part.models import Part