Skip to content

Commit 20761ca

Browse files
tobixenclaude
andcommitted
docs: async design critique — dual-mode pattern fragility and alternatives
Documents the structural problem with the current is_async_client branching approach: silent coroutine discard, incorrect type annotations, growing method-pair count, and which methods still lack async support. Also covers alternative designs (separate async classes, full sans-IO) and practical recommendations (mandatory coroutine tests, type overloads, lint rule). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9fe97cc commit 20761ca

2 files changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# Async Design: Current Approach and Its Problems
2+
3+
**Written:** March 2026
4+
**Context:** Bug hunt following commit e819a3a5 (async save/complete fixes)
5+
6+
---
7+
8+
## The Current Approach
9+
10+
The library adds async support through a **dual-mode single-class** pattern:
11+
12+
- `DAVClient` and `AsyncDAVClient` are separate classes at the HTTP layer.
13+
- `Calendar`, `Event`, `Todo`, and friends are **shared** between sync and async use.
14+
- Methods detect at runtime whether they are being called from an async context
15+
via the `is_async_client` property, then either do the work directly or delegate
16+
to a private `_async_*` counterpart that uses `await`.
17+
18+
Example (simplified):
19+
20+
```python
21+
def save(self, ...):
22+
if self.is_async_client:
23+
return self._async_save(...) # returns a coroutine
24+
# ... sync implementation ...
25+
self._create(...)
26+
return self
27+
28+
async def _async_save(self, ...):
29+
# ... same logic, with await ...
30+
await self._async_create(...)
31+
return self
32+
```
33+
34+
The caller then does either `obj.save()` or `await obj.save()` depending on context.
35+
36+
---
37+
38+
## Why This Is Fragile
39+
40+
### Silent coroutine discard
41+
42+
The single biggest problem: **if a method calls `self.save()` internally and
43+
forgets the async check, the coroutine is silently discarded with no error**.
44+
45+
```python
46+
def uncomplete(self):
47+
...ical manipulation...
48+
self.save() # BUG: returns a coroutine in async mode, discarded silently
49+
```
50+
51+
The object appears to work — `uncomplete()` returns `None` as expected — but the
52+
change is never written to the server. There is no exception, no warning, nothing.
53+
54+
Commit e819a3a5 fixed `save()` and `complete()`. The subsequent commit fixed
55+
`uncomplete()`, `set_relation()`, `get_relatives()`, and the invite-reply methods.
56+
Each was caught only because someone wrote an explicit unit test checking
57+
`asyncio.iscoroutine(result)`.
58+
59+
### Every new I/O method is a latent bug
60+
61+
The pattern requires that **every** method touching I/O has:
62+
63+
1. An async check at the top.
64+
2. A corresponding `_async_*` method.
65+
3. A unit test verifying the coroutine is returned.
66+
67+
Miss any one of these three and you have a silent bug. There is no compiler
68+
enforcement, no type checker that catches it (the return type annotations currently
69+
lie — `-> None` but actually `-> Coroutine | None`), and no runtime warning.
70+
71+
### Type annotations are incorrect
72+
73+
`save()` is annotated `-> Self`. In async mode it actually returns
74+
`Coroutine[Any, Any, Self]`. These are not the same type. Any type-checked
75+
caller (`mypy`, `pyright`) that writes:
76+
77+
```python
78+
obj = event.save()
79+
obj.icalendar_component # AttributeError: Coroutine has no attribute icalendar_component
80+
```
81+
82+
will get a runtime error that mypy would not catch.
83+
84+
### Growing method count
85+
86+
Each I/O-touching method produces a pair: the public method and its `_async_*`
87+
twin. As the feature surface grows, so does the duplication. Helpers like
88+
`_add_relation_to_ical()` and `_parse_relatives_from_ical()` reduce *some* of the
89+
duplication, but the structural problem remains.
90+
91+
---
92+
93+
## Alternative Approaches
94+
95+
### 1. Separate async classes (the "motor" pattern)
96+
97+
Create `AsyncCalendar`, `AsyncEvent`, `AsyncTodo` etc. that inherit all the pure
98+
ical-manipulation logic from the base classes but override every I/O method as
99+
`async def`:
100+
101+
```python
102+
class AsyncEvent(Event):
103+
async def save(self, ...): ...
104+
async def load(self, ...): ...
105+
async def complete(self, ...): ...
106+
# etc.
107+
```
108+
109+
Pros:
110+
- Correct type annotations — `AsyncEvent.save()` returns `Coroutine`, `Event.save()` returns `Self`.
111+
- No runtime branching — no `if self.is_async_client`.
112+
- Missing overrides become obvious (the sync version just works, perhaps wrongly, but at least it's detectable).
113+
- Familiar pattern (aiohttp vs requests, motor vs pymongo, etc.).
114+
115+
Cons:
116+
- Breaking API change for anyone currently using `Calendar` and `AsyncDAVClient` together.
117+
- More class hierarchy to maintain.
118+
- Factory functions (`get_calendar()`, `get_calendars()`) would need to return the right subclass.
119+
120+
### 2. Full Sans-I/O at the object level
121+
122+
Push all I/O out of `CalendarObjectResource` entirely. Methods like `save()` and
123+
`load()` would produce **request descriptors** that the caller passes to the client:
124+
125+
```python
126+
req = event.build_save_request()
127+
response = await client.execute(req)
128+
event.apply_save_response(response)
129+
```
130+
131+
Pros: Completely decoupled, fully testable without mocks, one code path.
132+
Cons: Very different API, massive refactor, probably not worth it for a library
133+
at this level of abstraction.
134+
135+
### 3. asyncio.run() wrapper (rejected)
136+
137+
Wrap all async methods with `asyncio.run()` for the sync case. Rejected because
138+
nested event loops are forbidden, and `asyncio.run()` cannot be called from an
139+
already-running loop.
140+
141+
---
142+
143+
## Recommendation
144+
145+
The dual-mode pattern is pragmatic and probably good enough for the near term,
146+
but it needs systematic guarding:
147+
148+
1. **Every public method that does I/O must have a unit test** asserting
149+
`asyncio.iscoroutine(result)` for async clients. The test should also assert
150+
that awaiting the coroutine produces the expected side-effect (not just that it
151+
returned a coroutine).
152+
153+
2. **Fix the type annotations** — either accept that they are wrong and document
154+
it, or use an overload:
155+
```python
156+
@overload
157+
def save(self: "SyncSelf", ...) -> Self: ...
158+
@overload
159+
def save(self: "AsyncSelf", ...) -> Coroutine[Any, Any, Self]: ...
160+
```
161+
This is verbose but gives type checkers a chance.
162+
163+
3. **Consider a linting rule or metaclass hook** that walks all `CalendarObjectResource`
164+
subclass methods looking for `self.save()`, `self.load()`, `self.parent.*()` calls
165+
without a preceding `is_async_client` guard. This could be a simple AST check run
166+
in CI.
167+
168+
4. **Long-term**: if async use grows significantly, the separate-class approach is
169+
cleaner. The migration could be done incrementally — `AsyncCalendar` delegates to
170+
`Calendar` for now, then gradually overrides methods.
171+
172+
---
173+
174+
## Methods Still Missing Async Support (as of March 2026)
175+
176+
The following methods call I/O internally but do **not** yet have async support.
177+
They will silently misbehave when called on objects associated with an async client:
178+
179+
- `_complete_recurring_safe()` — calls `self.complete()` and `completed.save()` and
180+
`completed.complete()`. Protected by the `handle_rrule=True → NotImplementedError`
181+
guard in `_async_complete()`, but the underlying methods are not async-safe.
182+
- `_complete_recurring_thisandfuture()` — same protection, same underlying issue.
183+
- `accept_invite()` / `decline_invite()` / `tentatively_accept_invite()` — now raise
184+
`NotImplementedError` for async clients. A proper async implementation would need
185+
to await `load()`, `add_event()`, `schedule_outbox()`, and `save()`.
186+
- `_handle_reverse_relations()` / `check_reverse_relations()` / `fix_reverse_relations()`
187+
— call `get_relatives()` (now async-aware) and `set_relation()` (now async-aware), but
188+
the methods themselves are not async-aware and will get a coroutine back from
189+
`get_relatives()` and try to iterate it synchronously.
190+
- `is_invite_request()` / `is_invite_reply()` — call `self.load(only_if_unloaded=True)`,
191+
which returns a coroutine in async mode; these return the wrong thing silently.

docs/design/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ Proposed Ruff configuration for linting and formatting.
8888
### [RUFF_REMAINING_ISSUES.md](RUFF_REMAINING_ISSUES.md)
8989
Remaining linting issues to address.
9090

91+
### [ASYNC_DESIGN_CRITIQUE.md](ASYNC_DESIGN_CRITIQUE.md)
92+
Critique of the current dual-mode async pattern (same class, runtime `is_async_client`
93+
branching): why it is fragile, what alternative designs exist, and which methods still
94+
lack async support as of March 2026.
95+
9196
## Historical Note
9297

9398
Some design documents from the exploration phase were removed in January 2026 after

0 commit comments

Comments
 (0)