Skip to content

Lifecycle and Resilience

Four things borrowed from Home Assistant’s integration model, because each solved a problem FreeSDN actually had. None of them changes how you write a plugin; all of them change how one behaves when something goes wrong.

Upgrading a plugin used to change everything except what it ran. The files were replaced and the per-organisation runtimes rebuilt, but the REST handlers stayed bound to the previous object: new code on disk, new version in the UI, old behaviour on the endpoint.

Your method bodies now reload in place. The upgrade is live on the next request, with no restart and without dropping every other plugin, websocket and in-flight request on the appliance.

Practical consequence for how you write a plugin: keep route handlers thin and put the work in methods. A handler that is three lines calling self._scan() reloads completely; one with the logic inline does not.

“Broken” and “blocked” are different

Section titled ““Broken” and “blocked” are different”

Supervision quarantines a plugin that fails repeatedly, with exponential backoff. That is right for code that crashes and wrong for a NetBox that is down for ten minutes: the plugin gets quarantined for somebody else’s outage, then sits out a cooldown after the outage ends, and the operator sees QUARANTINED next to a reason naming their own infrastructure.

Say which it is:

from freesdn_sdk import PluginNotReady
async def _fetch(self):
ok, body = await self._request("get", url, token, "/devices")
if not ok:
raise PluginNotReady(f"LibreNMS at {url} is not reachable: {body}")
return body

PluginNotReady is recorded, surfaced, and retried with its own gentler backoff. It never counts toward quarantine. Anything else you raise is still treated as a fault, which is the right default: a plugin that does not know why it failed should not be able to claim it was somebody else’s problem.

An operator sees the reason, how long it has been waiting, and when the next attempt is due. A plugin route that hits it answers 503 with Retry-After rather than 500, because a 500 sends the caller looking for a bug in FreeSDN.

retry_after is accepted when the remote told you when to come back:

raise PluginNotReady("rate limited", retry_after=120)

If your plugin reads the same remote from a route, a schedule and an event handler, each was fetching its own copy. PluginCoordinator is the shared one:

from freesdn_sdk import PluginCoordinator
def _coordinator(self) -> PluginCoordinator:
if self._devices is None:
self._devices = PluginCoordinator(
self.ctx, "librenms-devices", self._fetch_devices, update_interval=3600,
)
return self._devices
# anywhere that needs the data
devices = await self._coordinator().async_get()

What it gives you beyond a cache:

  • Concurrent callers share one in-flight fetch. Five simultaneous callers produce one request, not five against a remote that is already busy.
  • A failed refresh keeps the last good data. During an outage a report showing the last known state marked stale beats an empty page, and last_update_success says which you are looking at.
  • It does not raise on failure. A coordinator that threw would put a try block around every consumer, which is most of what it removes. async_refresh returns a bool. PluginNotReady is the exception and is re-raised, so the supervisor still sees it.
  • Backoff. Without it, a coordinator on a 30-second schedule retries a dead remote every 30 seconds forever.

Put coordinator.snapshot() in your status route and an operator can see the same thing you can:

{
"name": "librenms-devices",
"last_update_success": true,
"age_seconds": 42.1,
"stale": false,
"consecutive_failures": 0,
"last_error": null
}

settings_schema validates the SHAPE of what an operator typed. It cannot tell them the token is wrong. Without a check at save time they see “Saved” and learn the truth from the first scheduled run – or never, because a schedule failing quietly into a log is exactly what makes unattended work hard to trust.

async def async_validate_config(self) -> str | None:
config = await self._config()
if config is None:
return "netbox_url and netbox_token are both required"
url, token = config
try:
await self._get(url, token, "/dcim/sites/", limit=1)
except Exception as exc:
return f"NetBox did not accept the request: {exc}"
return None

Return None when everything works, or a short sentence naming what is wrong. The result comes back on the save:

{"settings": {...}, "warning": "NetBox did not accept the request: 401"}

Worth reporting the connected but unusable case too, which is the one most likely to waste an afternoon:

if not await self.ctx.settings.get("host_group_id"):
return "Connected. host_group_id is still required before a sync can run."

Home Assistant’s entity and device registries have no equivalent here and should not. FreeSDN already owns device identity through its own inventory, adapters and staging chokepoint. Adding a parallel entity model on top would duplicate all three and leave two answers to “what is this device”.

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.