Merge "wrap wsgi_app.init_application with latch_error_on_raise"

This commit is contained in:
Zuul
2025-03-25 04:35:20 +00:00
committed by Gerrit Code Review
6 changed files with 128 additions and 0 deletions

View File

@@ -15,6 +15,7 @@ import os
import sys
from oslo_config import cfg
from oslo_db import exception as odbe
from oslo_log import log as logging
from oslo_reports import guru_meditation_report as gmr
from oslo_reports import opts as gmr_opts
@@ -116,6 +117,7 @@ def init_global_data(conf_files, service_name):
logging.DEBUG)
@utils.latch_error_on_raise(retryable=(odbe.DBConnectionError,))
def init_application(name):
conf_files = _get_config_files()

View File

@@ -304,6 +304,7 @@ class TestCase(base.BaseTestCase):
# make sure that the wsgi app is fully initialized for all testcase
# instead of only once initialized for test worker
wsgi_app.init_global_data.reset()
wsgi_app.init_application.reset()
# Reset the placement client singleton
report.PLACEMENTCLIENT = None

View File

@@ -82,6 +82,8 @@ document_root = /tmp
# raised during it.
self.assertRaises(test.TestingException, wsgi_app.init_application,
'nova-api')
# reset the latch_error_on_raise decorator
wsgi_app.init_application.reset()
# Now run init_application a second time, it should succeed since no
# exception is being raised (the init of global data should not be
# re-attempted).
@@ -89,6 +91,26 @@ document_root = /tmp
self.assertIn('Global data already initialized, not re-initializing.',
self.stdlog.logger.output)
@mock.patch(
'sys.argv', new=mock.MagicMock(return_value=mock.sentinel.argv))
@mock.patch('nova.api.openstack.wsgi_app._get_config_files')
def test_init_application_called_unrecoverable(self, mock_get_files):
"""Test that init_application can tolerate being called more than once
in a single python interpreter instance and raises the same exception
forever if its unrecoverable.
"""
error = ValueError("unrecoverable config error")
excepted_type = type(error)
mock_get_files.side_effect = [
error, test.TestingException, test.TestingException]
for i in range(3):
e = self.assertRaises(
excepted_type, wsgi_app.init_application, 'nova-api')
self.assertIs(e, error)
# since the expction is latched on the first raise mock_get_files
# should not be called again on each iteration
mock_get_files.assert_called_once()
@mock.patch('nova.objects.Service.get_by_host_and_binary')
@mock.patch('nova.utils.raise_if_old_compute')
def test_setup_service_version_workaround(self, mock_check_old, mock_get):

View File

@@ -1398,3 +1398,58 @@ class RunOnceTests(test.NoDBTestCase):
self.assertRaises(ValueError, f.reset)
self.assertFalse(f.called)
mock_clean.assert_called_once_with()
class LatchErrorOnRaiseTests(test.NoDBTestCase):
error = test.TestingException()
unrecoverable = ValueError('some error')
@utils.latch_error_on_raise(retryable=(test.TestingException,))
def dummy_test_func(self, error=None):
if error:
raise error
return True
def setUp(self):
super().setUp()
self.dummy_test_func.reset()
@mock.patch.object(utils.LOG, 'exception')
def test_wrapped_success(self, fake_logger):
self.assertTrue(self.dummy_test_func())
fake_logger.assert_not_called()
self.assertIsNone(self.dummy_test_func.error)
@mock.patch.object(utils.LOG, 'exception')
def test_wrapped_raises_recoverable(self, fake_logger):
expected = LatchErrorOnRaiseTests.error
e = self.assertRaises(
type(expected), self.dummy_test_func, error=expected)
self.assertIs(expected, e)
# we just leave recoverable exception flow though the decorator
# without catching them so the logger should not be called by the
# decorator
fake_logger.assert_not_called()
self.assertIsNone(self.dummy_test_func.error)
self.assertTrue(self.dummy_test_func())
@mock.patch.object(utils.LOG, 'exception')
def test_wrapped_raises_unrecoverable(self, fake_logger):
expected = LatchErrorOnRaiseTests.unrecoverable
e = self.assertRaises(
type(expected), self.dummy_test_func, error=expected)
self.assertIs(expected, e)
fake_logger.assert_called_once_with(expected)
self.assertIsNotNone(self.dummy_test_func.error)
self.assertIs(self.dummy_test_func.error, expected)
@mock.patch.object(utils.LOG, 'exception', new=mock.MagicMock())
def test_wrapped_raises_forever(self):
expected = LatchErrorOnRaiseTests.unrecoverable
first = self.assertRaises(
type(expected), self.dummy_test_func, error=expected)
self.assertIs(expected, first)
second = self.assertRaises(
type(expected), self.dummy_test_func, error=expected)
self.assertIs(first, second)

View File

@@ -1194,3 +1194,42 @@ def run_once(message, logger, cleanup=None):
wrapper.reset = functools.partial(reset, wrapper)
return wrapper
return outer_wrapper
class _SentinelException(Exception):
"""This type exists to act as a placeholder and will never be raised"""
def latch_error_on_raise(retryable=(_SentinelException,)):
"""This is a utility decorator to ensure if a function ever raises
it will always raise the same exception going forward.
The only exception we know is safe to ignore is an oslo db connection
error as the db may be temporarily unavailable and we should allow
mod_wsgi to retry
"""
def outer_wrapper(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if wrapper.error:
raise wrapper.error
try:
return func(*args, **kwargs)
except retryable:
# reraise any retryable exception to allow them to be handled
# by the caller.
raise
except Exception as e:
wrapper.error = e
LOG.exception(e)
raise
wrapper.error = None
def reset(wrapper):
wrapper.error = None
wrapper.reset = functools.partial(reset, wrapper)
return wrapper
return outer_wrapper

View File

@@ -0,0 +1,9 @@
---
fixes:
- |
The nova (metadata)api wsgi application will now detect fatal errors
(configuration, et al) on startup and lock into a permanent error state
until fixed and restarted. This solves a problem with some wsgi runtimes
ignoring initialization errors and continuing to send requests to the
half-initialized service. See https://bugs.launchpad.net/nova/+bug/2103811
for more details.