Skip to content

Commit 7a54341

Browse files
authored
Concurrency hardening for the async SDK (#60)
* test(concurrency): regression suite for six verified races Adds deterministic concurrency tests for the async client and its generated sync twin. Interleaving is driven by Event/Barrier handshakes rather than wall-clock sleeps, so the ordering under test is fixed. Tests assert the invariant (a failure surfaces as CodesphereError), not the exception class, so they encode what callers depend on and survive the fixes that follow. Eleven of these fail against the current client, each for a distinct defect: unrefcounted close(), a captured client across retry backoff, cross-thread TOCTOU in the sync open(), a lost flags invalidation, a retried DELETE whose 404 masks success, and in-place model mutation that diverges from server write ordering. * fix(client): reference-count transport scopes, map closed-client errors Sharing one SDK across tasks was unsafe: open() was idempotent but close() was not, so the first scope to exit tore the transport down for everyone else. open()/close() are now reference counted behind _compat.Lock, which also closes the check-then-act window that let two threads in the sync twin each build a transport and orphan one. The teardown awaits __aexit__ outside the lock so a slow drain cannot block a concurrent open() of the next transport. request() now re-resolves the client on every retry attempt instead of capturing it once, so a close during backoff is caught by the SDK rather than surfacing as httpx's bare RuntimeError. That error is remapped to the new ClientStateError(CodesphereError, RuntimeError) only when the client is actually gone; unrelated httpx internals still propagate. The dual base keeps existing `except RuntimeError` handlers working. test_open_is_idempotent asserted the old (buggy) teardown semantics and is replaced by tests for reuse, balanced counting, and close underflow. * fix(flags): honor invalidate() against an in-flight fetch invalidate() cleared the snapshot outside the lock, so a get() already awaiting its request would overwrite the None with data fetched before the invalidation. The invalidation evaporated and the next read served the stale snapshot. A fetch now stamps the generation it started under and installs its result only if invalidate() has not bumped it meanwhile. invalidate() stays synchronous: taking the lock there would block the caller for the duration of an in-flight flags request. require() also read _legacy_platform outside the lock after get() returned, so a concurrent refresh could pair a snapshot with the legacy flag of a different fetch. _fetch() now returns both, and the new _get_with_legacy() hands them to callers from one lock acquisition. * fix(retry): treat a 404 on a retried DELETE as success When a DELETE reached the platform and only its response was lost (a gateway 503), the retry saw 404 and the SDK raised NotFoundError for an operation that had in fact succeeded. Tearing down a landscape reported "no landscape deployed" precisely because the teardown worked. A 404 is now treated as success when the request is a DELETE and at least one retry has already run. The attempt > 0 guard matters: a 404 on the first attempt is a genuine miss and still raises. The rule is scoped to DELETE, so a retried PUT that 404s is unaffected. Safe for every current DELETE operation: all five declare response_model=NoneType, so the response body is never parsed. The RetryConfig docstring now states the residual risk this does not solve. PUT and DELETE are idempotent as methods but not always in effect, and without idempotency keys the SDK cannot stop a retried teardown from executing twice. Callers who cannot tolerate that are pointed at max_retries=0. * feat(models)!: invalidate models after a write instead of guessing BREAKING CHANGE: reading a field on a model after update() raises StaleModelError until refresh() is called. Writes copied values back into the local model, which is wrong by construction under concurrency: the platform orders writes by arrival while the client sees them by response. Two tasks updating the same workspace could leave the model reporting "first" while the server held "second", permanently and silently. Workspace.update() wrote back the request payload; the Domain methods wrote back the server's response, which is authoritative for the moment it was produced but can still be overtaken by a concurrent write. Both are now handled the same way: the instance is marked stale. Staleness drops the field values from __dict__ so reads route through __getattr__, which costs nothing until a model actually goes stale. Identity fields survive, so a stale instance can still be logged and re-fetched, and repr() marks it. model_dump/model_dump_json are guarded too: pydantic serializes straight from __dict__, so without that a stale model would quietly dump only its identity. Workspace.refresh() and Domain.refresh() re-read the entity in place. Domain writes still return the server's response; prefer it over self. Removes codesphere.utils.update_model_fields, now unused. * fix(landscape): detect foreign restarts and stop overrunning deadlines wait_for_stage() could report success for a pipeline run the caller never started: if someone else redeployed mid-wait, it simply observed whatever run was current. The API exposes no run id, but started_at changes on restart, so the run is pinned on first sighting and a different value now raises ConflictError. Both wait_for_stage() and wait_until_running() counted elapsed time by summing poll_interval, ignoring how long the status requests took. A slow endpoint could overrun the timeout by a wide margin (5.15s against a 0.2s budget in the regression test). Both now measure against a monotonic deadline and never sleep past it. LogStream is backed by one SSE response body that can only be read once. Re-entering it stranded the first stream context, and a second iterator surfaced a raw httpx.StreamConsumed. Both now raise ClientStateError explaining that streams are single-use. Adds docs/guides/concurrency.md. Alongside the guarantees, it states plainly what the SDK cannot do: with no ETags, If-Match, or idempotency keys, read-modify-write cycles have an unclosable lost-update window and retried writes can execute twice. * build(sync-gen): do not abort generation when unasyncd rewrites files unasyncd exits non-zero whenever it transforms a file, the way a formatter signals "changed". Since run() defaulted to fatal, the very run that did the work aborted before restore_all_blocks() and before ruff normalized the generated tree — so `make sync-gen` failed on every real change and had to be run twice to produce a correct result.
1 parent 44ad556 commit 7a54341

28 files changed

Lines changed: 1555 additions & 169 deletions

File tree

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,53 @@
1414
quickstart, guides, full API reference, and `llms.txt`; deployed to
1515
GitHub Pages on pushes to `main`.
1616

17+
### Added
18+
19+
- Concurrency guide covering what is safe to share, how stale models
20+
work, and the lost-update windows the Codesphere API provides no way
21+
to close (it has no ETags, `If-Match`, or idempotency keys).
22+
- `Workspace.refresh()` and `Domain.refresh()` re-read an entity from the
23+
server in place.
24+
25+
### Fixed
26+
27+
- `wait_for_stage()` pins the pipeline run it is waiting on by its start
28+
time and raises `ConflictError` if someone else restarts the stage
29+
meanwhile, instead of silently reporting the outcome of a run the
30+
caller never started.
31+
- `wait_for_stage()` and `wait_until_running()` now measure their
32+
`timeout` against the wall clock. Previously only the sleeps counted,
33+
so slow status requests could overrun the deadline substantially.
34+
- Reusing a `LogStream` raises `ClientStateError` instead of stranding
35+
the first stream context or surfacing a raw `httpx.StreamConsumed`.
36+
- Sharing one SDK across tasks or threads is now safe. `open()`/`close()`
37+
are reference counted, so nested and concurrent scopes no longer tear
38+
the transport down for each other, and two threads racing `open()` in
39+
the sync client can no longer each build (and orphan) a connection
40+
pool. Closing while a request is in flight now raises the new
41+
`ClientStateError` instead of leaking httpx's bare `RuntimeError`.
42+
- `sdk.flags.invalidate()` is no longer discarded when a flags fetch is
43+
already in flight; the stale snapshot used to be reinstated silently.
44+
The `legacy_platform` marker in feature-flag errors can also no longer
45+
come from a different fetch than the snapshot it is reported with.
46+
- A `404` on a retried `DELETE` is treated as success. Previously a
47+
teardown that had actually succeeded reported `NotFoundError` when the
48+
first attempt's response was lost behind a gateway error. A `404` on
49+
the first attempt still raises.
50+
1751
### Changed
1852

53+
- **Breaking:** writes no longer copy values back into the local model.
54+
`Workspace.update()`, `Domain.update()`,
55+
`Domain.update_workspace_connections()` and `Domain.verify_status()`
56+
now mark the instance **stale**: reading a field, `to_dict()`,
57+
`to_json()` or `to_yaml()` raises the new `StaleModelError` until you
58+
call `await refresh()`. The platform orders writes by arrival while the
59+
client sees them by response, so the old write-back could leave a model
60+
reporting a value the platform did not hold. Identity fields (`id`, or
61+
`name`/`team_id` for domains) stay readable. `Domain` methods still
62+
return the server's response, which is authoritative — prefer it over
63+
re-reading `self`.
1964
- Retries on transient failures are now enabled by default
2065
(`max_retries=2`). Idempotent methods (`GET`, `HEAD`, `PUT`, `DELETE`)
2166
are retried on `429`/`502`/`503`/`504` and connect/timeout errors.
@@ -32,6 +77,8 @@
3277

3378
- The deprecated module path `codesphere.resources.workspace.envVars`
3479
(use `codesphere.resources.workspace.env_vars`).
80+
- `codesphere.utils.update_model_fields`, the helper behind the removed
81+
write-back behavior. It had no remaining callers.
3582

3683
## v1.0.0 (2026-02-21)
3784

docs/guides/concurrency.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# Concurrency
2+
3+
The async client is built to be shared. This guide covers what the SDK
4+
guarantees when several tasks use it at once, and — just as important —
5+
what it cannot guarantee, because the Codesphere API offers no way to.
6+
7+
## Share one client
8+
9+
Create one `CodesphereSDK` and use it from as many tasks as you like.
10+
Sharing is preferred: each instance owns a connection pool, so creating
11+
one per task throws away connection reuse.
12+
13+
```python
14+
import asyncio
15+
from codesphere import CodesphereSDK
16+
17+
async def main():
18+
async with CodesphereSDK() as sdk:
19+
teams = await sdk.teams.list()
20+
# One client, many concurrent calls.
21+
workspaces = await asyncio.gather(
22+
*(sdk.workspaces.list(team.id) for team in teams)
23+
)
24+
```
25+
26+
Scopes are reference counted, so nested and concurrent `async with`
27+
blocks are safe — the transport closes when the last one exits, not the
28+
first:
29+
30+
```python
31+
async def worker(sdk):
32+
async with sdk: # each worker holds its own scope
33+
await sdk.teams.list()
34+
35+
async with CodesphereSDK() as sdk:
36+
await asyncio.gather(worker(sdk), worker(sdk))
37+
await sdk.teams.list() # still open
38+
```
39+
40+
Using the client after it is fully closed raises
41+
[`ClientStateError`][codesphere.ClientStateError], which is both a
42+
`CodesphereError` and a `RuntimeError`.
43+
44+
The synchronous client offers the same guarantees across threads.
45+
46+
## Models go stale after a write
47+
48+
The platform applies writes in the order they arrive; your program sees
49+
them in the order responses come back. Those orders can differ, so the
50+
SDK cannot know an entity's state after a write it did not read back.
51+
Rather than report a value the platform may not hold, a write
52+
**invalidates** the instance:
53+
54+
```python
55+
workspace = await sdk.workspaces.get(72678)
56+
57+
await workspace.update(WorkspaceUpdate(name="renamed"))
58+
59+
workspace.id # fine: a write cannot change an entity's identity
60+
workspace.name # raises StaleModelError
61+
```
62+
63+
Call `refresh()` to re-read the server's actual state:
64+
65+
```python
66+
await workspace.refresh()
67+
workspace.name # server-authoritative
68+
```
69+
70+
This applies to `Workspace.update()`, `Domain.update()`,
71+
`Domain.update_workspace_connections()` and `Domain.verify_status()`.
72+
`to_dict()`, `to_json()` and `to_yaml()` raise on a stale model too.
73+
The `Domain` methods return the server's response — prefer that returned
74+
object over re-reading the instance you called them on.
75+
76+
## What the SDK cannot protect you from
77+
78+
The workspace you are reading can be changed at the same moment by the
79+
web IDE, a CI pipeline, a teammate, or another process of your own. The
80+
Codesphere API has **no ETags, no `If-Match`, and no idempotency keys**,
81+
so there is no way for the SDK to detect or reject a conflicting write.
82+
These are real limitations, not oversights, and the SDK does not pretend
83+
otherwise.
84+
85+
### Read-modify-write loses concurrent changes
86+
87+
Any read, edit, write cycle has a window in which someone else's change
88+
is silently overwritten:
89+
90+
```python
91+
# NOT safe against concurrent writers
92+
current = await workspace.env_vars.get()
93+
await workspace.env_vars.set([*current, EnvVar(name="NEW", value="1")])
94+
```
95+
96+
`env_vars.set()` is a full replacement (`PUT`), so a variable added by
97+
someone else between the two calls is erased. The same applies to
98+
`landscape.get_profile()` → edit → `save_profile()`, which writes through
99+
a shell redirect and is not an atomic file replacement.
100+
101+
If you must do this, narrow the window and verify afterwards by reading
102+
back. There is no way to make it atomic.
103+
104+
### Retried writes can execute twice
105+
106+
`PUT` and `DELETE` are idempotent as HTTP methods but not always in
107+
effect. If the platform receives a request and only the response is lost
108+
(a gateway `503`, a dropped connection), the retry runs the operation
109+
again — a landscape teardown can happen twice. Without idempotency keys
110+
the SDK cannot deduplicate this.
111+
112+
The most common symptom is handled: a `404` on a **retried** `DELETE` is
113+
treated as success, since the resource being gone is what you asked for.
114+
A `404` on the first attempt still raises `NotFoundError`.
115+
116+
If duplicate execution is unacceptable, disable retries for those calls:
117+
118+
```python
119+
sdk = CodesphereSDK(retry=RetryConfig(max_retries=0))
120+
```
121+
122+
### Waiting on a pipeline someone else restarted
123+
124+
`wait_for_stage()` pins the run it is watching by its start time. If
125+
someone redeploys mid-wait, it raises
126+
[`ConflictError`][codesphere.ConflictError] instead of reporting the
127+
outcome of a run you never started:
128+
129+
```python
130+
try:
131+
await workspace.landscape.wait_for_stage("run", timeout=600)
132+
except ConflictError:
133+
# Someone else redeployed. Decide whether to wait on the new run.
134+
...
135+
```
136+
137+
Timeouts on `wait_for_stage()` and `wait_until_running()` are wall clock:
138+
time spent inside the status requests counts against your budget.
139+
140+
## Log streams are single-use
141+
142+
`logs.open()` returns a stream backed by one SSE response body, which can
143+
only be read once. Opening or iterating the same stream twice raises
144+
`ClientStateError`. Call `logs.open()` again for a second stream —
145+
concurrent streams over one client are fine:
146+
147+
```python
148+
async def tail(target):
149+
async for entry in workspace.logs.stream(target):
150+
print(entry.message)
151+
152+
await asyncio.gather(
153+
tail(ServerTarget(step=1, server="web")),
154+
tail(ServerTarget(step=1, server="api")),
155+
)
156+
```
157+
158+
## Feature flags
159+
160+
The flags snapshot is fetched once per client and cached. Concurrent
161+
gated calls collapse into a single request, and `invalidate()` is honored
162+
even against a fetch that is already in flight.

docs/llms.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ keyword-only `timeout=` override. Errors derive from
2626
- [Retries & timeouts](https://datata1.github.io/codesphere-python/guides/retries/): default retry behavior, RetryConfig, per-call timeout
2727
- [Feature flags](https://datata1.github.io/codesphere-python/guides/feature-flags/): sdk.flags, operation gating errors
2828
- [Streaming logs](https://datata1.github.io/codesphere-python/guides/streaming-logs/): SSE log streaming, targets, deadlines
29+
- [Concurrency](https://datata1.github.io/codesphere-python/guides/concurrency/): sharing one client, stale models after writes, lost-update windows the API cannot close
2930
- [Sync vs Async](https://datata1.github.io/codesphere-python/guides/sync-vs-async/): choosing a flavor, shared models, caveats
3031
- [API Reference](https://datata1.github.io/codesphere-python/reference/client/): full typed API surface
3132
- [Changelog](https://datata1.github.io/codesphere-python/changelog/): release history

docs/reference/config.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
- RateLimitError
2121
- NetworkError
2222
- TimeoutError
23+
- ClientStateError
24+
- StaleModelError
2325
- FeatureFlagError
2426
- FeatureNotAvailableError
2527
- FeatureNotEnabledError

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ nav:
5454
- Retries & timeouts: guides/retries.md
5555
- Feature flags: guides/feature-flags.md
5656
- Streaming logs: guides/streaming-logs.md
57+
- Concurrency: guides/concurrency.md
5758
- Sync vs Async: guides/sync-vs-async.md
5859
- API Reference:
5960
- Client: reference/client.md

scripts/gen_sync.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ def restore_all_blocks() -> int:
5050

5151

5252
def main() -> None:
53-
run("uv", "run", "unasyncd")
53+
# unasyncd exits non-zero whenever it rewrites a file, the way a
54+
# formatter signals "changed". That is the normal case here, so it
55+
# must not abort the post-processing below.
56+
run("uv", "run", "unasyncd", fatal=False)
5457
restored = restore_all_blocks()
5558
print(f"Restored __all__ in {restored} generated files")
5659
# Remaining findings are silenced via per-file-ignores in ruff.toml;

src/codesphere/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
APIError,
3434
AuthenticationError,
3535
AuthorizationError,
36+
ClientStateError,
3637
CodesphereError,
3738
ConflictError,
3839
FeatureFlagError,
@@ -41,6 +42,7 @@
4142
NetworkError,
4243
NotFoundError,
4344
RateLimitError,
45+
StaleModelError,
4446
TimeoutError,
4547
ValidationError,
4648
)
@@ -73,6 +75,7 @@
7375
"AuthorizationError",
7476
"CategoryFlags",
7577
"Characteristic",
78+
"ClientStateError",
7679
"CodesphereError",
7780
"CodesphereSDK",
7881
"ConflictError",
@@ -93,6 +96,7 @@
9396
"NotFoundError",
9497
"RateLimitError",
9598
"RetryConfig",
99+
"StaleModelError",
96100
"SyncCodesphereSDK",
97101
"Team",
98102
"TeamBase",

src/codesphere/_async/core/base.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from collections.abc import Mapping
2-
from typing import Any, TypeVar
2+
from typing import Any, ClassVar, TypeVar
33

44
import httpx
55
from pydantic import PrivateAttr
66

77
from codesphere.core.models import CamelModel
88
from codesphere.core.models import ResourceList as ResourceList
99
from codesphere.core.operations import APIOperation
10+
from codesphere.exceptions import StaleModelError
1011
from codesphere.feature_flags import FlagRequirement
1112

1213
from ..http_client import APIHttpClient
@@ -66,9 +67,78 @@ class BoundModel(CamelModel):
6667
Instances returned by the SDK get their client attached automatically,
6768
which lets entity methods (e.g. ``workspace.delete()``) make further
6869
API calls.
70+
71+
A write that the SDK cannot verify marks the instance **stale**: its
72+
field values are dropped and reads raise
73+
:class:`~codesphere.StaleModelError` until ``refresh()`` re-reads the
74+
entity. Identity fields survive, so the instance can still be logged
75+
and re-fetched. See :meth:`_mark_stale`.
6976
"""
7077

78+
#: Fields that stay readable on a stale instance. An entity's identity
79+
#: cannot be changed by a write, so it is always safe to report.
80+
_identity_fields: ClassVar[tuple[str, ...]] = ("id",)
81+
7182
_http_client: APIHttpClient | None = PrivateAttr(default=None)
83+
_stale: bool = PrivateAttr(default=False)
84+
85+
def __getattr__(self, item: str) -> Any:
86+
# Pydantic keeps field values in __dict__, so this only runs once a
87+
# lookup has already failed: zero cost until a model goes stale.
88+
if self._is_stale() and item in type(self).__pydantic_fields__:
89+
raise StaleModelError(type(self).__name__, item)
90+
# Delegate to pydantic, which resolves private attributes here.
91+
# It defines __getattr__ only at runtime, so it is fetched
92+
# dynamically rather than called through super() directly.
93+
parent = getattr(super(), "__getattr__", None)
94+
if parent is None: # pragma: no cover - pydantic always defines it
95+
raise AttributeError(item)
96+
return parent(item)
97+
98+
def _is_stale(self) -> bool:
99+
private = object.__getattribute__(self, "__pydantic_private__")
100+
return bool(private and private.get("_stale"))
101+
102+
def _mark_stale(self) -> None:
103+
"""Drop local field values the SDK can no longer vouch for.
104+
105+
Called after a write whose resulting server state is unknown.
106+
Clearing ``__dict__`` (rather than setting a flag beside intact
107+
values) is what routes later reads through ``__getattr__``.
108+
"""
109+
identity = {
110+
name: value
111+
for name, value in self.__dict__.items()
112+
if name in type(self)._identity_fields
113+
}
114+
# Also drops cached_property managers; they rebuild after refresh.
115+
self.__dict__.clear()
116+
self.__dict__.update(identity)
117+
self._stale = True
118+
119+
def _adopt(self, fresh: "BoundModel") -> None:
120+
"""Repopulate from a server-authoritative re-read."""
121+
self.__dict__.clear()
122+
self.__dict__.update(fresh.__dict__)
123+
self._stale = False
124+
125+
def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
126+
# Pydantic serializes straight from __dict__, so without this a
127+
# stale model would quietly dump only its identity fields.
128+
if self._is_stale():
129+
raise StaleModelError(type(self).__name__)
130+
return super().model_dump(*args, **kwargs)
131+
132+
def model_dump_json(self, *args: Any, **kwargs: Any) -> str:
133+
if self._is_stale():
134+
raise StaleModelError(type(self).__name__)
135+
return super().model_dump_json(*args, **kwargs)
136+
137+
def __repr_args__(self) -> Any:
138+
# Keep repr() working for debugging, but say why it looks empty.
139+
if self._is_stale():
140+
return [*super().__repr_args__(), ("stale", True)]
141+
return super().__repr_args__()
72142

73143
def _client(self) -> APIHttpClient:
74144
if self._http_client is None or not hasattr(self._http_client, "request"):

0 commit comments

Comments
 (0)