Skip to content

Give a clear error for non-positive concurrency max_running#6282

Merged
adtyavrdhn merged 7 commits into
pydantic:mainfrom
VectorPeak:fix-concurrency-limiter-max-running
Jul 7, 2026
Merged

Give a clear error for non-positive concurrency max_running#6282
adtyavrdhn merged 7 commits into
pydantic:mainfrom
VectorPeak:fix-concurrency-limiter-max-running

Conversation

@VectorPeak

@VectorPeak VectorPeak commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Eagerly reject non-positive concurrency max_running with a clear UserError at construction, across every path: ConcurrencyLimit, ConcurrencyLimiter, from_limit, normalize_to_limiter, and Agent(..., max_concurrency=...). None remains the explicit no-limit value. Wording matches the OnlineEvaluator.max_concurrency validation from #6267.

Why

Since anyio 4.12, CapacityLimiter(0) is allowed by design (agronholm/anyio#1019total_tokens is a mutable pause/resume "valve"; allowed in sync construction too since 4.14, agronholm/anyio#1183). pydantic-ai never mutates the limiter, so Agent(..., max_concurrency=0) constructs silently and the first run() hangs forever — reproduced on main with anyio 4.13. On anyio <= 4.11 the failure is anyio's opaque total_tokens must be >= 1 instead. This PR turns both into an immediate, actionable error, uniform across the supported range (anyio>=4.5.0).

Tests

tests/test_concurrency.py covers 0 and negatives through every path above, plus None still meaning no limiting.

Checklist

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • No breaking changes in accordance with the version policy.
  • PR title is fit for the release changelog.

@github-actions github-actions Bot added size: S Small PR (≤100 weighted lines) bug Report that something isn't working, or PR implementing a fix labels Jul 5, 2026
@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change adds eager validation for non-positive max_running values in ConcurrencyLimit and ConcurrencyLimiter, raising UserError during construction. The tests now cover direct construction, from_limit, normalize_to_limiter, Agent(max_concurrency=0|-1), and the None no-limiter path.

Changes

Related issues: #6281
Suggested labels: bug, tests

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ConcurrencyLimit
  participant ConcurrencyLimiter
  Caller->>ConcurrencyLimit: __init__(max_running)
  ConcurrencyLimit->>ConcurrencyLimit: __post_init__ validates max_running
  ConcurrencyLimit-->>Caller: raise UserError (if max_running < 1)
  Caller->>ConcurrencyLimiter: __init__(max_running)
  ConcurrencyLimiter->>ConcurrencyLimiter: validate max_running before CapacityLimiter
  ConcurrencyLimiter-->>Caller: raise UserError (if max_running < 1)
Loading
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation eagerly rejects max_running < 1 with UserError across the documented construction paths and preserves None as no limiting.
Out of Scope Changes check ✅ Passed The changes stay focused on concurrency validation and tests, with no obvious unrelated additions.
Title check ✅ Passed The title is concise and accurately summarizes the main change: clearer validation for non-positive concurrency limits.
Description check ✅ Passed The description covers the issue link, rationale, tests, and checklist, with only one checklist item left unchecked.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pydantic_ai_slim/pydantic_ai/concurrency.py`:
- Around line 78-81: The validation for max_running < 1 is duplicated in both
ConcurrencyLimit.__post_init__ and ConcurrencyLimiter.__init__, so consolidate
it into a shared helper or delegation point and reuse the same error message
from both places. Keep the eager validation behavior unchanged, but remove the
repeated inline checks so the logic lives in one identifiable function or method
used by both classes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e089df48-c793-4648-8af1-9c4707f2763b

📥 Commits

Reviewing files that changed from the base of the PR and between d7e3995 and 914e3e4.

📒 Files selected for processing (2)
  • pydantic_ai_slim/pydantic_ai/concurrency.py
  • tests/test_concurrency.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • pydantic/logfire (manual)
  • pydantic/pydantic-ai (manual)
  • pydantic/pydantic (auto-detected)

Comment thread pydantic_ai_slim/pydantic_ai/concurrency.py
UserError is pydantic-ai's convention for a developer-misconfiguration of
a public API (see the model/output-type checks in agent/__init__.py and
_output.py); a bare ValueError reads as an internal error and is what
users would otherwise get opaquely from anyio. The message now names the
remedy (max_concurrency=None) for the exact 0-vs-None confusion the issue
calls out. Add a regression test that None still means no limiting.
@dsfaccini

Copy link
Copy Markdown
Contributor

David's AICA here: 🔍 Local review suite (automated, pre-merge)

Wave Status Blocking Required (do) Warnings (defer/skip)
1 · code-correctness ✅ closed 0 0 0
2 · tests/cassettes/docs ✅ closed 0 0 0
3 · cross-cutting/siblings ✅ closed 0 0 0

Verdict: ✅ all 3 waves passed — 0 blocking, 0 required (8 reviewers ran; independent runtime probe confirmed the bug and the fix)
Reviewed at 2026-07-05T20:34Z · diff 27bfa1d07fc3

⚠️ 3 informational (skip / maintainer-decision)

Automated review — flagged by Claude, not yet confirmed by a maintainer.

1. Exception type + message differ from evals siblings · pydantic_ai_slim/pydantic_ai/concurrency.py · _validate_max_running
This PR raises UserError ('max_running must be a positive integer, got N. Use None for no concurrency limiting.'); the sibling guards in pydantic_evals#6228 (merged) and #6267 (open) — raise ValueError ('max_concurrency must be >= 1, got N'), and issue #6281 requested ValueError. The precedent reviewer's verdict: not a defect — correct per-package idiom. UserError is pydantic_ai_slim's convention for developer-misconfiguration; pydantic_evals doesn't import UserError at all. Deliberate per commit d33c6099f. Flagged as a maintainer alignment call, not changed.

2. "Use None" remedy not literally actionable at the ConcurrencyLimiter.__init__ boundary · pydantic_ai_slim/pydantic_ai/concurrency.py · _validate_max_running
The message is accurate at the outer API (Agent(max_concurrency=None), normalize_to_limiter(None)) but ConcurrencyLimiter(max_running: int) can't accept None. Minor wording — skip.

3. max_queued (0 / negative) left unvalidated · pydantic_ai_slim/pydantic_ai/concurrency.py · ConcurrencyLimit
Pre-existing, outside this PR's reproduced max_running scope; max_queued=0 may be legitimate intent. Correct narrow-fix scoping — skip (own issue if ever pursued). Maintainer already documented this as out of scope.

💡 1 optional docs nit (skip-safe)

Automated review — flagged by Claude, not yet confirmed by a maintainer.

Docstrings don't state the positive-integer constraint · pydantic_ai_slim/pydantic_ai/agent/__init__.py (max_concurrency) + concurrency.py (ConcurrencyLimit.max_running, ConcurrencyLimiter.__init__)
The three docstrings for this param describe it but don't say the integer must be positive / that 0 is rejected. One-line docstring additions would close it; the docs reviewer rates it skip-safe (the eager UserError self-documents). Not applied here — this review ran read-only while another maintainer edit was in flight; left for that pass.

@pydantic pydantic deleted a comment from faccini-bs Jul 5, 2026
@dsfaccini

Copy link
Copy Markdown
Contributor

David's AICA here: thanks for the fix (and for reporting the issue). I pushed one commit to this branch as a maintainer edit with three small refinements:

  • ValueError -> UserError. UserError is pydantic-ai's convention for a developer-misconfiguration of a public API surface (see the model / output-type validations in agent/__init__.py and _output.py). A bare ValueError reads as an internal error, and it's also what users already get opaquely from anyio today -- the point of this fix is to replace that with a typed, self-explanatory pydantic-ai error.
  • Message points to the remedy. Now max_running must be a positive integer, got {n}. Use None for no concurrency limiting. -- naming None addresses the exact 0-vs-None confusion the issue calls out.
  • Regression test that None still means "no limiting" (via normalize_to_limiter(None) and Agent(max_concurrency=None)), plus a -5 case.

The shared _validate_max_running helper and the two guard sites (ConcurrencyLimit.__post_init__ + ConcurrencyLimiter.__init__) were already right -- every live-limiter path funnels through __init__, and the __post_init__ guard fail-fasts a standalone config. Lint, pyright, and tests/test_concurrency.py (44) pass locally.

Note: max_queued (0 / negative) is unvalidated and remains a separate latent gap -- intentionally left out of scope here, since it's a backpressure-semantics decision rather than part of this hang fix.

@VectorPeak

Copy link
Copy Markdown
Contributor Author

@dsfaccini Thanks for the review and the follow-up commit. The UserError change makes sense to me. This is a bad user config value, not really an internal limiter error.

For future PRs, is there already a short doc or rule for this kind of case? For example: if a public pydantic-ai API gets a config value that would create invalid internal state, should we fail early with UserError?

If there is no rule yet, would it be useful to write it down somewhere small, maybe in the contributing docs, a simple check where possible, or the repo context used by AI assistants? That would make future PRs easier to keep consistent.

Since the PR is green now and CodeRabbit is happy, do you think it is ready to merge?

@adtyavrdhn

Copy link
Copy Markdown
Member

anyio rejects it anyway so I don't think this is needed apart from maybe a friendlier error message?

Match the '>= 1, got N' phrasing used by OnlineEvaluator (pydantic#6267), note
the constraint in the ConcurrencyLimit and ConcurrencyLimiter docstrings,
and drop the stale 'before a run can hang' test wording.
@adtyavrdhn adtyavrdhn changed the title fix: reject non-positive concurrency limiter max_running Give a clear error for non-positive concurrency max_running Jul 6, 2026
@VectorPeak

Copy link
Copy Markdown
Contributor Author

@adtyavrdhn Thanks a lot for taking a look, and for tightening the wording/docstrings here. I agree with your framing that this is mainly about giving a clearer public API error instead of letting lower-level limiter behavior leak through.

I rechecked one detail locally though: anyio.CapacityLimiter(0) does not seem to reject at construction time. With real AnyIO, I get:

CapacityLimiter(0) constructed: total_tokens= 0 available_tokens= 0
CapacityLimiter(-1) construct ERROR: ValueError total_tokens must be >= 0
CapacityLimiter(1) constructed: total_tokens= 1 available_tokens= 1

So negative values are rejected by AnyIO, but 0 appears to create a zero-capacity limiter. A later acquire() then waits because no token can become available.

Could you reproduce a different result on your side? If not, I think the >= 1 check you added is the right public API boundary: positive integers mean a bounded concurrency limit, and None is the explicit no-limit value.

Thanks again for the maintainer edits. CI is green now after the formatting fix, and I don't think there is anything else I need to change on my side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Report that something isn't working, or PR implementing a fix size: S Small PR (≤100 weighted lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrency limiter should reject non-positive max_running

3 participants