Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 144 additions & 191 deletions docs/providers/errors-and-exceptions.md

Large diffs are not rendered by default.

48 changes: 31 additions & 17 deletions docs/troubleshooting/async-finalizer-in-sync-close-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,36 @@

## Symptom

Arrives inside a `FinalizerError` (as one entry in its `.exceptions`), naming the type whose cached
instance has an async finalizer. `.instance_type` holds that type. A traceback shows it below the
`FinalizerError`, and `except* AsyncFinalizerInSyncCloseError` catches it.
`close_sync()` raises a [`FinalizerError`](finalizer-error.md), and one of its `.exceptions` is an
`AsyncFinalizerInSyncCloseError`:

```text
+ Exception Group Traceback (most recent call last):
| ...
| modern_di.exceptions.lifecycle.FinalizerError: Container.close_sync() found 1 finalizer error(s): AsyncFinalizerInSyncCloseError (1)
| See: https://modern-di.modern-python.org/troubleshooting/finalizer-error/
+-+---------------- 1 ----------------
| modern_di.exceptions.lifecycle.AsyncFinalizerInSyncCloseError: Cannot run async finalizer for AsyncResource during sync close. Use `await container.close_async()` (or `async with container:`) instead.
| See: https://modern-di.modern-python.org/troubleshooting/async-finalizer-in-sync-close-error/
+------------------------------------
```

`.instance_type` holds the type of the cached instance, and `except* AsyncFinalizerInSyncCloseError`
catches it.

## Cause

`close_sync()` cannot `await` anything. When it reaches a cached resource whose `CacheSettings`
finalizer is an async function, it can't run it synchronously, so it records this error for that entry
instead. Unlike a normal finalizer failure, it keeps the resource's cache entry intact rather than
discarding it.
`close_sync()` cannot `await`. It records this error for a cached instance whose finalizer is an
`async def`, and for one whose sync finalizer returns an awaitable. In both cases it keeps the
instance cached, unlike a finalizer that raised, whose instance is dropped. While the instance stays
cached, a repeated `close_sync()` reports it again, and resolving after a reopen returns the same
instance.

## Fix

Use `close_async()` (or `async with container:`) for containers that hold any resource with an async
finalizer, since it's the only path that can actually run that cleanup:
Close the container with `close_async()` or `async with container:`, the only paths that can run an
async finalizer. After a `close_sync()` that reported this error, `await container.close_async()`
runs the finalizer and finishes the cleanup:

<!-- invisible-code-block: python
from modern_di import Container, Group, Scope, exceptions, providers
Expand All @@ -40,21 +55,20 @@ container = Container(groups=[Dependencies])
-->

```python
container.resolve(AsyncResource) # has an async finalizer
container.resolve(AsyncResource)

try:
container.close_sync()
except* exceptions.AsyncFinalizerInSyncCloseError:
# cache retained, not lost
...
pass # AsyncResource is still cached

await container.close_async() # recovers: runs the async finalizer, completes cleanup
await container.close_async() # runs close_resource
```

Prefer `async with container:` (or `await close_async()`) by default whenever any provider might have
an async finalizer, and treat `close_sync()` as a fallback only for containers you know are entirely
sync.
Use `close_sync()` only for containers you know hold no async finalizer.

## See also

- [Lifecycle: close-failure semantics](../providers/lifecycle.md#close-failure-semantics).
- [Lifecycle: async finalizers and `close_sync()`](../providers/lifecycle.md#async-finalizers-and-close_sync)
— how `close_sync()` treats an async finalizer.
- [FinalizerError](finalizer-error.md) — the group this error arrives in.
32 changes: 20 additions & 12 deletions docs/troubleshooting/child-container-registration-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@

## Symptom

Raised from `Container.add_providers()` on a child container. It names the child container's scope.
`Container.add_providers()` raises it when called on a child container:

```text
modern_di.exceptions.registration.ChildContainerRegistrationError: Providers can only be registered on a root container: the providers registry is shared tree-wide, so registering on a child container (scope REQUEST) would mutate every container in the tree. Pass groups= to the root Container or call add_providers on it instead.
See: https://modern-di.modern-python.org/troubleshooting/child-container-registration-error/
```

`.container_scope` holds the child container's scope.

## Cause

Providers were registered on a child container rather than the root. The providers registry is
shared tree-wide (every container in the chain points at the same registry), so registering from a
child would silently mutate every container in the tree, so the call is disallowed.
Every container in a tree shares one providers registry, so a registration on a child would change
what every other container in the tree resolves. `add_providers()` accepts calls only on a root.

## Fix

Register on the root container instead, either with `groups=` when you build it or with
`add_providers()` later:
Register on the root container, either with `groups=` when you build it or with `add_providers()`
afterwards:

<!-- invisible-code-block: python
from modern_di import Container, Group, Scope, providers
Expand All @@ -30,23 +36,25 @@ class MyGroup(Group):


late_provider = providers.Factory(Mailer, scope=Scope.APP)

app_container = Container(scope=Scope.APP, groups=[MyGroup])
request_container = app_container.build_child_container(scope=Scope.REQUEST)
-->

<!-- raises: ChildContainerRegistrationError -->

```python
app_container = Container(scope=Scope.APP, groups=[MyGroup])
request_container = app_container.build_child_container(scope=Scope.REQUEST)

# Broken
request_container.add_providers(late_provider) # raises ChildContainerRegistrationError
request_container.add_providers(late_provider)
```

```python
# Works
app_container.add_providers(late_provider)
```

If you only have a reference to the child container at the call site, keep a reference to the root
container around (e.g. store it at app startup) instead of walking up via `parent_container`.
Any root works, whatever its scope. Register at startup, because `add_providers()` is not
coordinated with resolves running at the same time on other threads.

## See also

Expand Down
28 changes: 17 additions & 11 deletions docs/troubleshooting/container-closed-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ Raised from `resolve()` or `resolve_provider()` (and so from `resolve_dependency
integration's `FromDI`) when the call reaches a container that was closed:

```text
Container (scope APP) is closed. Reopen it with `open()` or by re-entering `with`/`async with` before resolving from it or from any of its child containers.
modern_di.exceptions.container.ContainerClosedError: Container (scope APP) is closed. Reopen it with `open()` or by re-entering `with`/`async with` before resolving from it or from any of its child containers.
See: https://modern-di.modern-python.org/troubleshooting/container-closed-error/
```

`.container_scope` names the closed container. When a child's resolve reaches back into a closed
Expand All @@ -26,9 +27,8 @@ A fresh container is open from construction, so this never means "you forgot to
## Fix

Reopen the container before resolving from it again. Call `container.open()`, or enter it again
with `with` / `async with`: `__enter__` and `__aenter__` call `open()` for you. This is how a test
harness enters the same container twice, how a broker stops and starts, and how a framework
lifespan runs more than once in one process. Integrations call `open()` in their startup hook.
with `with` / `async with`, whose `__enter__` and `__aenter__` call `open()` for you. Integrations
call `open()` in their startup hook.

<!-- invisible-code-block: python
from modern_di import Container, Group, providers
Expand All @@ -39,20 +39,25 @@ class Settings: ...

class Dependencies(Group):
settings = providers.Factory(Settings)


container = Container(groups=[Dependencies])
-->

<!-- raises: ContainerClosedError -->

```python
container = Container(groups=[Dependencies])

with container:
container.resolve(Settings)
# closed here: finalizers ran

container.resolve(Settings) # Broken: raises ContainerClosedError
# Broken
container.resolve(Settings)
```

with container: # Works: __enter__ reopens it
```python
# Works: __enter__ reopens the container
with container:
container.resolve(Settings)
```

Expand All @@ -63,9 +68,10 @@ comes from and stop it outliving the container. Reopening there would hide the l
cached after the reopen would never be finalized, because the shutdown that should close it has
already run.

A finalizer that resolves from its own container also raises, because the container counts as
closed while its finalizers run. Do not reopen the container from a finalizer. Give the cached
instance what its finalizer needs when it is created.
A finalizer that resolves from its own container fails the same way, because the container counts
as closed while its finalizers run. That `ContainerClosedError` arrives as an entry inside the
[`FinalizerError`](finalizer-error.md) that `close_sync()` or `close_async()` raises. Do not reopen
the container from a finalizer. Give the cached instance what its finalizer needs when it is created.

## See also

Expand Down
32 changes: 19 additions & 13 deletions docs/troubleshooting/finalizer-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,25 @@
Raised by `close_sync()` / `close_async()` when finalizers fail during cleanup. The message's first
line names each kind of error with its count:

```
```text
modern_di.exceptions.lifecycle.FinalizerError: Container.close_sync() found 2 finalizer error(s): ConnectionError (1), ValueError (1)
See: https://modern-di.modern-python.org/troubleshooting/finalizer-error/
```

It is an `ExceptionGroup`, so a traceback shows each finalizer exception below it, with a note naming
the type of the cached instance whose finalizer raised it, such as
`raised by the finalizer of a cached Database`.
`raised by the finalizer of a cached Database`. `.is_async` tells you whether `close_sync()` or
`close_async()` raised it.

## Cause

One or more cached providers' finalizers raised while the container was closing. Closing never stops
at the first failure: every finalizer runs regardless, so this error aggregates every failure.
One or more finalizers of cached providers raised while the container was closing. Finalizers run
newest first, in the reverse of the order their instances were created, and closing never stops at
the first failure. Every finalizer runs, and this error collects every failure.

## Fix

Inspect `.exceptions` for the individual exceptions and fix the offending finalizer(s):
Inspect `.exceptions` for the individual exceptions and fix the finalizers that raised:

<!-- invisible-code-block: python
from modern_di import Container, Group, Scope, exceptions, providers
Expand Down Expand Up @@ -74,16 +76,20 @@ except* ConnectionError as group:
print("cleanup failed:", err)
```

Because every finalizer still ran, a broken one doesn't leak a resource a later finalizer would have
closed. Only the exceptions themselves need attention, not the cleanup order. `.is_async` tells you
whether `close_sync()` or `close_async()` produced the error.
What `except*` does not catch is raised again as a `FinalizerError` holding only the remaining
errors.

## Escape hatches
A finalizer that raised is not retried. Its instance is dropped from the cache, so a later close does
not call that finalizer again, and resolving after a reopen builds a new instance. Because every other
finalizer still ran, a broken one does not leak a resource that a later finalizer would have closed.

If one entry in `.exceptions` is an `AsyncFinalizerInSyncCloseError`, that specific resource's
cache was retained, and calling `await container.close_async()` afterward finalizes it
and completes cleanup.
An `AsyncFinalizerInSyncCloseError` entry is the exception to this: `close_sync()` keeps that
instance cached, and a later `await container.close_async()` runs its finalizer. See
[AsyncFinalizerInSyncCloseError](async-finalizer-in-sync-close-error.md).

## See also

- [Lifecycle: close-failure semantics](../providers/lifecycle.md#close-failure-semantics).
- [Lifecycle: close-failure semantics](../providers/lifecycle.md#close-failure-semantics) — what
closing does when a finalizer fails or is cancelled.
- [AsyncFinalizerInSyncCloseError](async-finalizer-in-sync-close-error.md) — an async finalizer
reached by `close_sync()`.
68 changes: 46 additions & 22 deletions docs/troubleshooting/invalid-child-scope-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,72 @@

## Symptom

Raised from `Container(...)` or `build_child_container(scope=...)`, naming the parent scope, the
requested child scope, and the list of scopes that would have been accepted.
`build_child_container(scope=...)` raises it when the scope you pass is not deeper than the parent's:

```text
modern_di.exceptions.container.InvalidChildScopeError: Scope of child container cannot be SESSION if parent scope is SESSION (child scope value must be strictly greater than parent scope value). Possible scopes are ['REQUEST', 'ACTION', 'STEP'].
See: https://modern-di.modern-python.org/troubleshooting/invalid-child-scope-error/
```

`.parent_scope` and `.child_scope` hold the two scopes, and `.allowed_scopes` holds the members
listed after `Possible scopes are`. The `Container(...)` constructor never raises this error, because
a root container can start at any scope.

## Cause

A child's scope must be strictly deeper (a higher `IntEnum` value) than the parent's. Passing an
explicit `scope=` that is equal to the parent's (e.g. `Scope.SESSION` from a `SESSION` parent) or
shallower (e.g. `Scope.APP` from a `SESSION` parent) raises this error.
A child's scope must have a strictly higher integer value than its parent's. A child at the same
scope (`Scope.SESSION` under a `SESSION` parent) or a shallower one (`Scope.APP` under a `SESSION`
parent) raises.

The check compares integer values, so a member of another `IntEnum` is accepted when its value is
higher. The message and `.allowed_scopes` list only the deeper members of the parent's own enum,
though. Under a `STEP` parent, or a parent at the last member of a custom enum, the message says
`Possible scopes are [].` even though a higher-valued member of another enum would be accepted.

## Fix

Pass a scope whose value is strictly greater than the parent's:
Pass a scope deeper than the parent's:

<!-- invisible-code-block: python
from modern_di import Container, Group
from modern_di import Container, Scope


class MyGroup(Group): ...
app_container = Container(scope=Scope.APP)
session_container = app_container.build_child_container(scope=Scope.SESSION)
-->

<!-- raises: InvalidChildScopeError -->

```python
from modern_di import Scope

app_container = Container(scope=Scope.APP, groups=[MyGroup])

# Broken: SESSION is not deeper than SESSION
mid = app_container.build_child_container(scope=Scope.SESSION)
bad = mid.build_child_container(scope=Scope.SESSION) # raises InvalidChildScopeError
session_container.build_child_container(scope=Scope.SESSION)
```

```python
# Works
good = mid.build_child_container(scope=Scope.REQUEST)
request_container = session_container.build_child_container(scope=Scope.REQUEST)
```

## Escape hatches
You can also omit `scope=`. `build_child_container()` then takes the next deeper member of the
parent's enum and never raises this error. When the enum has no deeper member it raises
[`MaxScopeReachedError`](max-scope-reached-error.md) instead.

To go deeper than the parent's enum reaches, pass a member of your own `IntEnum` with a higher value:

```python
import enum


class MyScope(enum.IntEnum):
SUBSTEP = Scope.STEP + 1

Omit `scope=` entirely. `build_child_container()` derives the next deeper scope automatically, so
this error can only occur when you explicitly pin a scope value. Inspect `.allowed_scopes` on the
caught exception for the exact list of valid choices at that point in the tree, as members of the
parent's scope enum.

step_container = Container(scope=Scope.STEP)
substep_container = step_container.build_child_container(scope=MyScope.SUBSTEP)
```

## See also

- [Scopes](../providers/scopes.md#the-scope-dependency-rule) explains the scope hierarchy and ordering rule.
- [Scopes: custom scopes](../providers/scopes.md#custom-scopes) — how members of different enums mix
in one container tree.
- [MaxScopeReachedError](max-scope-reached-error.md) — the error from `build_child_container()`
without `scope=` at the last member of an enum.
Loading
Loading