Skip to content

Commit 3381b12

Browse files
address self-review findings on PR #60
- **Reject `max_concurrent` with non-concurrent strategy**: if a user sets `ConcurrencyConfig(strategy="queue", max_concurrent=5)`, they have a misconfig — the field is only honored under `"concurrent"`. Upstream accepts silently but we fail-fast so it's never invisible. New test `test_max_concurrent_with_non_concurrent_strategy_raises`. - **Tighten error message**: raw input via `!r` repr instead of private `self._concurrency_max_concurrent` attribute; message now also tells the user how to fix ("pass None for unbounded"). - **Test robustness**: replace `for _ in range(20): await asyncio.sleep(0)` yield-counting loops with `asyncio.wait_for(_reach_cap(), timeout=1.0)` polling until the condition holds. Fixed counts can be flaky on slow CI when handlers have multiple internal await points. - **Document divergence in UPSTREAM_SYNC.md**: added row to Known Non-Parity table with rationale (upstream accepts the config field but never enforces it; we do). - **Inline breadcrumb comment** `# Divergence from upstream — see docs/UPSTREAM_SYNC.md` at the `_concurrent_semaphore` construction site, matching the divergence discipline in CLAUDE.md.
1 parent d74453e commit 3381b12

3 files changed

Lines changed: 58 additions & 12 deletions

File tree

docs/UPSTREAM_SYNC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ stay explicit instead of being rediscovered in code review.
455455
| Google Chat image rendering | Images emit as `{alt} ({url})` or bare `url` | No image branch — falls through to default which concatenates children only, dropping the URL | Upstream silently drops image URLs when rendering to Google Chat text. We preserve the URL so the message content isn't lost. |
456456
| Fallback streaming stream-exception capture | `_fallback_stream` captures exceptions from the stream iterator, flushes whatever content was already rendered, awaits `pending_edit`, and re-raises after cleanup | `try/finally` only — exception propagates immediately, `pendingEdit` is un-awaited, and the placeholder is stranded as `"..."` | Upstream leaves a hard UX failure when streams crash mid-flight (common: LLM connection drops): placeholder visible forever, orphan background task. We flush + clean up before re-raising so the caller still sees the original error and users see the partial content instead of a spinner. |
457457
| Fallback streaming final SentMessage content | SentMessage + final edit carry `final_content` (remend'd — inline markers auto-closed) | SentMessage + final edit carry raw `accumulated` | Narrow UX refinement. If a stream ends with an unclosed `*`/`~~`/etc., upstream ships the unclosed marker; we run `_remend` so the user sees a clean final message. Not observable in the common case where streams close their own markers. |
458+
| `ConcurrencyConfig.max_concurrent` | Enforced via `asyncio.Semaphore` in the `"concurrent"` strategy path; rejects `<= 0` and rejects the field under non-`"concurrent"` strategies | Accepted into the config type with docstring "Default: Infinity" but never read (3 writes, 0 reads) | Silent correctness bug upstream — consumers setting `max_concurrent=N` with `strategy="concurrent"` reasonably expect an N-way bound on in-flight handlers. We honor the documented contract via a semaphore and fail-fast on misconfiguration so it's never silent. |
458459

459460
### Platform-specific gaps
460461

src/chat_sdk/chat.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
Author,
3939
ChannelVisibility,
4040
ChatConfig,
41+
ConcurrencyConfig,
4142
ConcurrencyStrategy,
4243
EmojiValue,
4344
Lock,
@@ -297,19 +298,33 @@ def __init__(self, config: ChatConfig | None = None, **kwargs: Any) -> None:
297298
self._concurrency_queue_entry_ttl_ms = concurrency.queue_entry_ttl_ms
298299

299300
# -- Concurrent-strategy semaphore ------------------------------------
301+
# Divergence from upstream — see docs/UPSTREAM_SYNC.md.
300302
# `max_concurrent` bounds in-flight handler dispatches when using the
301303
# `"concurrent"` strategy. `None` means unbounded (matches the upstream
302304
# TS default of `Infinity`). A positive integer caps parallel handler
303-
# runs. Divergence from upstream: upstream accepts the config field
304-
# but doesn't enforce it; we do.
305+
# runs. Upstream accepts the config field but never enforces it
306+
# (3 writes, 0 reads); we enforce it via `asyncio.Semaphore`.
307+
#
308+
# Only construct the semaphore when the strategy actually uses it —
309+
# if a user sets `max_concurrent=5` with `strategy="queue"`, they
310+
# have a misconfiguration that we surface as a `ValueError` rather
311+
# than silently allocating an unused primitive.
305312
#
306313
# Reject `<= 0` explicitly rather than silently ignoring — a user
307314
# passing `max_concurrent=0` likely means "pause all processing"
308315
# (not supported) or has a typo. Either way, silently falling back
309316
# to unbounded concurrency would surprise them.
317+
raw_max = concurrency.max_concurrent if isinstance(concurrency, ConcurrencyConfig) else None
310318
if self._concurrency_max_concurrent is not None and self._concurrency_max_concurrent <= 0:
311319
raise ValueError(
312-
f"ConcurrencyConfig.max_concurrent must be > 0 or None, got {self._concurrency_max_concurrent}"
320+
f"ConcurrencyConfig.max_concurrent must be a positive integer or None; "
321+
f"got {raw_max!r}. Pass None for unbounded concurrency."
322+
)
323+
if self._concurrency_max_concurrent is not None and self._concurrency_strategy != "concurrent":
324+
raise ValueError(
325+
f"ConcurrencyConfig.max_concurrent is only honored when strategy='concurrent'; "
326+
f"got strategy={self._concurrency_strategy!r}. Either switch to strategy='concurrent' "
327+
"or drop max_concurrent."
313328
)
314329
self._concurrent_semaphore: asyncio.Semaphore | None = (
315330
asyncio.Semaphore(self._concurrency_max_concurrent)

tests/test_chat_faithful.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2303,11 +2303,19 @@ async def handler(thread, message, context=None):
23032303
for i in range(5)
23042304
]
23052305

2306-
# Let the first two handlers enter and block on the gate. Others
2307-
# must be blocked on the semaphore, so `in_flight` is capped at 2
2308-
# and the observed peak is <= 2 (not `== 2` — we'd narrow to 2
2309-
# only after the final drain below, when the peak is meaningful).
2310-
for _ in range(20):
2306+
# Poll until the first 2 handlers have reached the gate. Fixed
2307+
# yield counts can be flaky on slow CI — a handler that needs to
2308+
# traverse a few internal `await`s before reaching `gate.wait()`
2309+
# may not show up in N cycles. Poll with a short timeout instead.
2310+
async def _reach_cap() -> None:
2311+
while in_flight < 2:
2312+
await asyncio.sleep(0.001)
2313+
2314+
await asyncio.wait_for(_reach_cap(), timeout=1.0)
2315+
2316+
# Let any would-be extra tasks finish racing to the gate; if the
2317+
# bound leaks, in_flight would climb above 2 here.
2318+
for _ in range(10):
23112319
await asyncio.sleep(0)
23122320
assert in_flight == 2
23132321
assert max_observed <= 2
@@ -2330,13 +2338,30 @@ async def test_max_concurrent_zero_or_negative_raises(self):
23302338
for bad_value in (0, -1, -100):
23312339
import pytest
23322340

2333-
with pytest.raises(ValueError, match="max_concurrent must be > 0 or None"):
2341+
with pytest.raises(ValueError, match="max_concurrent must be a positive integer or None"):
23342342
await _init_chat(
23352343
adapter=adapter,
23362344
state=state,
23372345
concurrency=ConcurrencyConfig(strategy="concurrent", max_concurrent=bad_value),
23382346
)
23392347

2348+
# Python-specific: setting `max_concurrent` with a non-concurrent strategy
2349+
# is a misconfiguration — the field is only honored under `"concurrent"`.
2350+
# Fail loudly instead of silently allocating an unused semaphore.
2351+
async def test_max_concurrent_with_non_concurrent_strategy_raises(self):
2352+
import pytest
2353+
2354+
state = create_mock_state()
2355+
adapter = create_mock_adapter("slack")
2356+
2357+
for bad_strategy in ("queue", "debounce", "drop"):
2358+
with pytest.raises(ValueError, match="only honored when strategy='concurrent'"):
2359+
await _init_chat(
2360+
adapter=adapter,
2361+
state=state,
2362+
concurrency=ConcurrencyConfig(strategy=bad_strategy, max_concurrent=5),
2363+
)
2364+
23402365
# Python-specific: None / missing max_concurrent must keep the
23412366
# unbounded behavior (matches upstream TS default of Infinity).
23422367
async def test_max_concurrent_none_allows_unbounded(self):
@@ -2367,9 +2392,14 @@ async def handler(thread, message, context=None):
23672392
)
23682393
for i in range(5)
23692394
]
2370-
for _ in range(20):
2371-
await asyncio.sleep(0)
2372-
# All 5 should be in flight simultaneously (no bound).
2395+
2396+
# Poll until all 5 are in flight; with no semaphore they should
2397+
# all reach the gate.
2398+
async def _reach_five() -> None:
2399+
while in_flight < 5:
2400+
await asyncio.sleep(0.001)
2401+
2402+
await asyncio.wait_for(_reach_five(), timeout=1.0)
23732403
assert in_flight == 5
23742404
gate.set()
23752405
await asyncio.gather(*tasks)

0 commit comments

Comments
 (0)