CircularDependencyError¶
Symptom¶
Resolving a provider that sits on a cycle raises:
modern_di.exceptions.resolution.CircularDependencyError: Circular dependency detected:
APP ServiceA (myapp.cycle:6)
APP └─> ServiceB (myapp.cycle:11)
APP └─> ServiceA (myapp.cycle:6)
Check your provider graph for unintended cycles.
See: https://modern-di.modern-python.org/troubleshooting/circular-dependency/
container.validate() reports the same cycle inside ValidationFailedError:
+ Exception Group Traceback (most recent call last):
| ...
| modern_di.exceptions.container.ValidationFailedError: Container.validate() found 1 issue(s): CircularDependencyError (1)
| See: https://modern-di.modern-python.org/troubleshooting/validation-failed-error/
+-+---------------- 1 ----------------
| modern_di.exceptions.resolution.CircularDependencyError: Circular dependency detected:
| APP ServiceA (myapp.cycle:6)
| APP └─> ServiceB (myapp.cycle:11)
| APP └─> ServiceA (myapp.cycle:6)
| Check your provider graph for unintended cycles.
| See: https://modern-di.modern-python.org/troubleshooting/circular-dependency/
+------------------------------------
Each line is one hop of the cycle, with its scope and, for a Factory, the module and line where its
creator is declared. .steps holds the hops, and .cycle_path and .cycle_locations hold their
names and locations.
Cause¶
The providers form a loop: A depends on B, which depends back on A, directly or through other
providers. The loop can run through a parameter's type annotation, a kwargs entry that holds a
provider, or an Alias.
The runtime cycle guard (without validate())¶
Resolving from an unvalidated cyclic graph still raises CircularDependencyError: the first
resolve overflows the stack, and Container.resolve_provider catches that RecursionError,
re-walks the static graph from the failing provider, and, since a cycle is reachable, raises
CircularDependencyError (with the same cycle-path rendering shown above) from the original
RecursionError. A creator that recurses on its own, with no cycle in the provider graph, still
raises the original RecursionError unchanged. This guard runs on every resolve, whether or not
validate() was ever called.
The guard covers resolution on one thread. Each cached factory locks its cache item while it is
created, so two threads that cold-resolve different providers of the same cycle at the same time
can each hold one of those locks and wait for the other forever, and neither reaches the guard.
If the graph might have a cycle, call validate() at startup, before any thread resolves.
Fix¶
Find every cycle at startup with validate(). It walks the whole graph in one pass and reports each
issue it finds, where a resolve reports only the cycle it happens to hit:
Then break the loop with one of these:
- Pass one side a static value through
kwargs. Only a value breaks the cycle: akwargsentry that holds a provider is still an edge of the graph, sokwargs={"a": service_a}keeps the loop. - Have one side depend on an interface whose provider sits outside the cycle. An
Aliasthat maps the interface back to a provider on the cycle keeps the loop. - Move the logic both sides need into a third provider that both depend on.
Option 2, with ServiceB taking a Notifier that EmailNotifier provides instead of ServiceA:
class Notifier(Protocol): ...
class EmailNotifier: ...
class ServiceB:
def __init__(self, notifier: Notifier) -> None:
self.notifier = notifier
class ServiceA:
def __init__(self, b: ServiceB) -> None:
self.b = b
class Fixed(Group):
email = providers.Factory(EmailNotifier, scope=Scope.APP)
notifier = providers.Alias(EmailNotifier, bound_type=Notifier)
service_a = providers.Factory(ServiceA, scope=Scope.APP)
service_b = providers.Factory(ServiceB, scope=Scope.APP)
container = Container(groups=[Fixed])
container.validate()
See also¶
- Errors and exceptions: where this error sits in the hierarchy.
- Lifecycle: validation: what
validate()checks. - ValidationFailedError: how
validate()groups the errors it finds.