Skip to content

Commit b94b21d

Browse files
authored
Merge pull request #1646 from rackerlabs/retry_nautobot_intermittent_disconnects
feat: PUC-1466: Added retry logic for transient Nautobot failures
2 parents d8a1532 + 5b104da commit b94b21d

5 files changed

Lines changed: 142 additions & 3 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ repos:
106106
- "sushy"
107107
- "types-requests"
108108
- "netapp-ontap==9.17.1.0"
109+
- "tenacity"
109110

110111
- repo: local
111112
hooks:

python/understack-workflows/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dependencies = [
2626
"sushy>=5.3.0,<6",
2727
"kubernetes==33.1.0",
2828
"netapp-ontap==9.17.1.0",
29+
"tenacity>=8.0.0",
2930
]
3031

3132
[project.scripts]

python/understack-workflows/tests/test_nautobot_device_sync.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from unittest.mock import patch
66

77
import pytest
8+
import requests
89

910
from understack_workflows.oslo_event.nautobot_device_sync import EXIT_STATUS_FAILURE
1011
from understack_workflows.oslo_event.nautobot_device_sync import EXIT_STATUS_SUCCESS
@@ -15,6 +16,7 @@
1516
)
1617
from understack_workflows.oslo_event.nautobot_device_sync import _generate_device_name
1718
from understack_workflows.oslo_event.nautobot_device_sync import _get_record_value
19+
from understack_workflows.oslo_event.nautobot_device_sync import _is_retryable_error
1820
from understack_workflows.oslo_event.nautobot_device_sync import _normalise_manufacturer
1921
from understack_workflows.oslo_event.nautobot_device_sync import (
2022
_populate_from_inventory,
@@ -807,3 +809,85 @@ def test_handle_delete_event_success(self, mock_delete, mock_conn, mock_nautobot
807809

808810
assert result == EXIT_STATUS_SUCCESS
809811
mock_delete.assert_called_once_with(node_uuid, mock_nautobot)
812+
813+
814+
class TestIsRetryableError:
815+
"""Test cases for _is_retryable_error function."""
816+
817+
def test_connection_error_is_retryable(self):
818+
exc = requests.exceptions.ConnectionError("Connection refused")
819+
assert _is_retryable_error(exc) is True
820+
821+
def test_503_service_unavailable_is_retryable(self):
822+
from pynautobot import RequestError
823+
824+
mock_response = MagicMock()
825+
mock_response.status_code = 503
826+
mock_response.reason = "Service Unavailable"
827+
mock_response.json.return_value = {"detail": "Service temporarily unavailable"}
828+
mock_response.request.body = None
829+
mock_response.url = "http://nautobot/api/dcim/devices/"
830+
mock_response.text = "Service Unavailable"
831+
832+
exc = RequestError(mock_response)
833+
assert _is_retryable_error(exc) is True
834+
835+
def test_502_bad_gateway_is_retryable(self):
836+
from pynautobot import RequestError
837+
838+
mock_response = MagicMock()
839+
mock_response.status_code = 502
840+
mock_response.reason = "Bad Gateway"
841+
mock_response.json.return_value = {}
842+
mock_response.request.body = None
843+
mock_response.url = "http://nautobot/api/dcim/devices/"
844+
mock_response.text = "Bad Gateway"
845+
846+
exc = RequestError(mock_response)
847+
assert _is_retryable_error(exc) is True
848+
849+
def test_504_gateway_timeout_is_retryable(self):
850+
from pynautobot import RequestError
851+
852+
mock_response = MagicMock()
853+
mock_response.status_code = 504
854+
mock_response.reason = "Gateway Timeout"
855+
mock_response.json.return_value = {}
856+
mock_response.request.body = None
857+
mock_response.url = "http://nautobot/api/dcim/devices/"
858+
mock_response.text = "Gateway Timeout"
859+
860+
exc = RequestError(mock_response)
861+
assert _is_retryable_error(exc) is True
862+
863+
def test_400_bad_request_is_not_retryable(self):
864+
from pynautobot import RequestError
865+
866+
mock_response = MagicMock()
867+
mock_response.status_code = 400
868+
mock_response.reason = "Bad Request"
869+
mock_response.json.return_value = {"device_type": ["Not found"]}
870+
mock_response.request.body = None
871+
mock_response.url = "http://nautobot/api/dcim/devices/"
872+
mock_response.text = "Bad Request"
873+
874+
exc = RequestError(mock_response)
875+
assert _is_retryable_error(exc) is False
876+
877+
def test_404_not_found_is_not_retryable(self):
878+
from pynautobot import RequestError
879+
880+
mock_response = MagicMock()
881+
mock_response.status_code = 404
882+
mock_response.reason = "Not Found"
883+
mock_response.json.return_value = {}
884+
mock_response.request.body = None
885+
mock_response.url = "http://nautobot/api/dcim/devices/123/"
886+
mock_response.text = "Not Found"
887+
888+
exc = RequestError(mock_response)
889+
assert _is_retryable_error(exc) is False
890+
891+
def test_value_error_is_not_retryable(self):
892+
exc = ValueError("Invalid value")
893+
assert _is_retryable_error(exc) is False

python/understack-workflows/understack_workflows/oslo_event/nautobot_device_sync.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@
88
Can be called from any event handler - provision, inspect, CRUD, etc.
99
"""
1010

11+
import logging
1112
import re
1213
from dataclasses import dataclass
1314
from dataclasses import field
1415
from typing import Any
1516
from uuid import UUID
1617

18+
import requests
19+
import tenacity
1720
from ironicclient.common.apiclient import exceptions as ironic_exceptions
1821
from openstack.connection import Connection
22+
from pynautobot import RequestError
1923
from pynautobot.core.api import Api as Nautobot
2024

2125
from understack_workflows.helpers import setup_logger
@@ -30,6 +34,28 @@
3034
EXIT_STATUS_SUCCESS = 0
3135
EXIT_STATUS_FAILURE = 1
3236

37+
# Retry configuration for transient failures
38+
RETRY_ATTEMPTS = 5
39+
RETRY_WAIT_MIN = 2
40+
RETRY_WAIT_MAX = 30
41+
42+
43+
def _is_retryable_error(exc: BaseException) -> bool:
44+
"""Determine if an exception is retryable.
45+
46+
Retries on:
47+
- Connection errors (network issues, DNS failures)
48+
- 503 Service Unavailable (Nautobot temporarily down)
49+
- 502 Bad Gateway (proxy/load balancer issues)
50+
- 504 Gateway Timeout
51+
"""
52+
if isinstance(exc, requests.exceptions.ConnectionError):
53+
return True
54+
if isinstance(exc, RequestError):
55+
status_code = getattr(exc.req, "status_code", None)
56+
return status_code in (502, 503, 504)
57+
return False
58+
3359

3460
@dataclass
3561
class DeviceInfo:
@@ -205,6 +231,9 @@ def _set_location_from_switches(
205231
device_info.rack_id = rack_id
206232

207233
except Exception as e:
234+
# Re-raise retryable errors (503, connection issues) to trigger retry
235+
if _is_retryable_error(e):
236+
raise
208237
logger.error(
209238
"Failed to determine location for node %s: %s", device_info.uuid, e
210239
)
@@ -484,6 +513,15 @@ def _find_or_create_nautobot_device(
484513
return _create_nautobot_device(ironic_node_info, nautobot_client)
485514

486515

516+
@tenacity.retry(
517+
retry=tenacity.retry_if_exception(_is_retryable_error),
518+
wait=tenacity.wait_random_exponential(
519+
multiplier=1, min=RETRY_WAIT_MIN, max=RETRY_WAIT_MAX
520+
),
521+
stop=tenacity.stop_after_attempt(RETRY_ATTEMPTS),
522+
before_sleep=tenacity.before_sleep_log(logger, logging.WARNING),
523+
reraise=True,
524+
)
487525
def sync_device_to_nautobot(
488526
node_uuid: str,
489527
nautobot_client: Nautobot,
@@ -496,7 +534,8 @@ def sync_device_to_nautobot(
496534
2. Creates or updates the device in Nautobot
497535
3. Optionally syncs interfaces (ports) to Nautobot
498536
499-
Can be called from any event handler.
537+
Can be called from any event handler. Automatically retries on transient
538+
failures (503, connection errors) with exponential backoff.
500539
501540
Args:
502541
node_uuid: Ironic node UUID
@@ -539,7 +578,10 @@ def sync_device_to_nautobot(
539578
logger.info(str(e))
540579
return EXIT_STATUS_FAILURE
541580

542-
except Exception:
581+
except Exception as e:
582+
# Re-raise retryable errors to trigger tenacity retry
583+
if _is_retryable_error(e):
584+
raise
543585
logger.exception("Failed to sync device %s to Nautobot", node_uuid)
544586
return EXIT_STATUS_FAILURE
545587

python/understack-workflows/uv.lock

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)