Your webhook receiver calls entry_points(group=...) at startup and one plugin's import raises — how do you keep discovery robust?
answer
- Two phases with different risk
- One bad neighbour should not stop the process
- Reading metadata never runs their code
- Activation from a module body invites cycles
- Load on first use, log what resolved
basics
~20 sSplit discovery from activation: reading entry-point metadata is safe, EntryPoint.load() runs third-party code. Load each entry point in its own try/except, log the name and value, decide per group whether a failure degrades or stops the service, and prefer loading lazily.
solid answer
~40 s`entry_points(group=...)` only parses metadata; the risk lives in `EntryPoint.load()`, which imports somebody else's module into your process. So loop with a per-entry-point `try`/`except`, log `ep.name` and `ep.value` plus the providing distribution, and continue — then apply a deliberate policy: an optional enrichment handler should leave the receiver degraded and visible on the health endpoint, a signature-verification handler should refuse to start. A frequent startup failure is a cycle: the host activates plugins from its own module body, a plugin imports the half-initialised host package, and the import fails. Move activation into a function that runs after the host finishes importing, expose the base class from a dependency-free leaf module, and load lazily on first use so you also stop paying every plugin's import cost at boot.
code
python · 12 linesimport logging
from importlib.metadata import entry_points
log = logging.getLogger("hooksrv")
handlers, broken = {}, []
for ep in entry_points(group="hooksrv.handlers"):
try:
handlers[ep.name] = ep.load()
except Exception:
log.exception("plugin %s (%s) failed to load", ep.name, ep.value)
broken.append(ep.name)
print(f"active={sorted(handlers)} broken={broken}")go deeper
Know that loading a plugin runs code written by someone else, so it can raise like any import, and that a bare loop over discovered plugins will stop at the first failure.
Explain the split between reading metadata and calling load(), and write the per-plugin try/except that logs the entry point name and value and continues.
Demonstrate production judgement: fail-open versus fail-closed per group, an operator switch to disable a named plugin, logging the resolved set at startup, and restructuring activation out of module import to break the cycle.
Own the policy: what the plugin contract promises, who is allowed to install into the runtime environment, whether untrusted extensions require process isolation, and how a plugin's startup cost is budgeted against the service's readiness target.
### Separate discovery from activation `entry_points(group="hooksrv.handlers")` only reads metadata files; it never imports plugin code. `EntryPoint.load()` imports arbitrary third-party code into your process. Treat these as two phases with different risk profiles. Discovery can happen eagerly at startup and be logged; activation is where a stranger's module body runs, and it needs the defensive treatment. ### Isolate the failure The default plugin loop is a single unprotected loop, so one bad plugin takes the whole receiver down. Wrap each load individually, keep going, and record enough to identify the culprit — the entry point name, its value, and the distribution that provided it: ```python handlers, broken = {}, [] for ep in entry_points(group="hooksrv.handlers"): try: handlers[ep.name] = ep.load() except Exception: log.exception("plugin %s (%s) failed to load", ep.name, ep.value) broken.append(ep.name) ``` Then decide policy deliberately rather than by accident: a webhook receiver whose optional enrichment plugin fails should start degraded and say so on its health endpoint; one whose signature verification plugin fails should refuse to start. "Every plugin is fatal" and "every plugin is optional" are both wrong defaults — the group's contract should say which it is, and an operator switch (an environment variable or config list that disables named entry points) is what turns a 3 a.m. page into a restart. ### The circular import at startup The specific failure that keeps recurring: the host calls discovery *at module import time*, a plugin's module imports the host package to reach a base class or registry, and the host package is still half-executed. The plugin's `from hooksrv import BaseHandler` then fails with an `ImportError` naming a partially initialised module — Python's message even says it is most likely a circular import. Nothing is wrong with either package in isolation; the cycle exists only because activation was triggered from inside the host's own module body. The fix is structural. Move the loop into a function that runs **after** the host package has finished importing — a `setup()` call, an application factory, or the first request that needs a handler. Publish the base class and protocol from a small leaf module that imports nothing else from the package, so a plugin can import the contract without dragging in the host's world. And prefer **lazy activation**: keep the `EntryPoint` objects, load on first use, and you both dodge the cycle and stop paying the import cost of every installed plugin's dependency tree on every start. ### Determinism and precedence Two installed distributions can register the same name in your group; `entry_points()` returns both. Discovery order follows `sys.path` and directory listings, so "last one wins" is not reproducible across machines. Sort explicitly, or reject duplicates loudly with an error naming both providers. Log the resolved plugin set at startup — name, value, version — because the first question during an incident is "was this plugin even loaded here?", and the environment differs between the laptop and the image. ### Validate the contract, and remember the trust boundary After `load()`, check that the object is what the group promised — a subclass, a `typing.Protocol` match, or a duck-typed attribute check — and reject it with a clear message otherwise, because a plugin author's mistake should not surface later as an `AttributeError` deep in request handling. The trust boundary is worth stating plainly: **any distribution installed in the environment can register into your group**, and `load()` runs its code with your process's privileges. Entry points are not a sandbox. In a service you control this is fine — you control the image — but "we will let customers pip install plugins into the receiver" is a different security posture, and the honest senior answer names that rather than assuming discovery is safe because it is declarative. ### Startup budget Finally, measure. Eager activation of a dozen plugins can add seconds to start, which matters for a receiver that must be ready before the upstream retries. Lazy loading, or activating only the handlers the configuration actually references, keeps startup proportional to what you use rather than to what happens to be installed.
- Two installed distributions register the same entry point name in your group. What happens, and what should you do?The collection returned by `entry_points(group=...)` contains both records; nothing deduplicates them, and the order follows `sys.path` and directory listings, so it is not reproducible across machines. Decide precedence explicitly — sort by a documented rule, or fail loudly with an error naming both providers and their distributions. Silently taking the last one is how a plugin conflict becomes an environment-specific bug that reproduces on one host only.
- A plugin fails to load in production but works locally. Where do you look first?At the environment, because discovery reads the installed distributions rather than your source tree. Compare the resolved plugin sets — this is why logging name, value and version at startup is worth the two lines. Typical causes are a plugin missing from the image, a version whose module layout moved, or an optional dependency absent in the slim runtime image but present in the dev extras.
- Is it safe to let users install plugins into the same environment as the service?Only if you trust them as you trust your own code. `load()` imports their module with your process's privileges and no sandbox; entry points are a discovery mechanism, not an isolation boundary. If the plugin authors are third parties, the answer is process or container isolation with an explicit protocol between them, plus a curated allowlist, rather than trusting whatever the environment happens to contain.
saying these in an interview costs you the question
- Wraps the whole discovery loop in one try/except
- Assumes discovery itself can execute plugin code
- Treats every plugin failure as fatal without asking
- Calls the plugin loader at host module import time
- Relies on discovery order for precedence
- Calls entry points a security boundary for untrusted code