Skip to content

Fix #4056: keep blank query values, add token bucket regression test#4069

Open
MukundaKatta wants to merge 1 commit intoPrefectHQ:mainfrom
MukundaKatta:fix/issue-4056-query-and-rate-limit
Open

Fix #4056: keep blank query values, add token bucket regression test#4069
MukundaKatta wants to merge 1 commit intoPrefectHQ:mainfrom
MukundaKatta:fix/issue-4056-query-and-rate-limit

Conversation

@MukundaKatta
Copy link
Copy Markdown
Contributor

Why

Issue #4056 reports three minor bugs. After investigation, only one is a real defect that needs a code fix; the other two are addressed in the PR text below.

Closes #4056.

What

Bug 1 (fixed): parse_qs drops blank query values

src/fastmcp/resources/template.py calls parse_qs(query_string) without keep_blank_values=True, so URIs like resource://data/42?format= silently lose the format key. The fix adds keep_blank_values=True and a brief comment explaining why.

Bug 2 (no code change needed): env_nested_delimiter with nested models

The issue claims FASTMCP__DOCKET__CONCURRENCY should work. With env_prefix="FASTMCP_" on the parent and env_nested_delimiter="__", the correct pydantic-settings syntax is FASTMCP_DOCKET__CONCURRENCY (single underscore between the prefix and the nested key, double underscore for nesting). This is exactly what docs/more/settings.mdx already documents:

When setting Docket values in a .env file, use a double underscore: FASTMCP_DOCKET__URL (not FASTMCP_DOCKET_URL). This is because .env values are resolved through the parent Settings class, which uses __ as its nested delimiter.

So the documented form works; the form in the issue is not standard pydantic-settings syntax and would not work with the configured prefix. Happy to add a regression test or adjust the docs further if a maintainer wants to nail this down.

Bug 3 (proposed fix is incorrect; defensive test added)

The issue proposes moving self.last_refill = now inside the if self.tokens >= tokens: branch in TokenBucketRateLimiter.consume(). That fix is wrong: it freezes the clock during denied requests. A client that retries quickly after a denial would re-count the same elapsed window on each retry, accumulating tokens far faster than the configured refill_rate and bypassing the rate limit. This was independently flagged by the Codex reviewer on PR #4057 and the proposer agreed (#4057 (comment)).

The current implementation is the standard token bucket algorithm (advance the clock on every consume() call). To prevent regression, this PR adds test_denied_consumes_do_not_freeze_clock which drains the bucket, hammers it with denied calls over ~0.2s, and asserts that no extra burst capacity has accrued.

Tested

  • New: test_query_param_with_blank_value_is_preserved — verifies ?format= round-trips as {"format": ""}.
  • New: test_query_param_with_blank_and_present_values — mixed blank + non-blank query values.
  • New: test_denied_consumes_do_not_freeze_clock — locks in correct token bucket semantics so the wrong fix from the issue can't be reintroduced.
  • Existing tests in tests/resources/test_resource_template.py and tests/server/middleware/test_rate_limiting.py continue to apply.

Addresses three bugs reported in issue PrefectHQ#4056:

1. parse_qs in match_uri_template now passes keep_blank_values=True so
   that URIs like resource://data/42?format= no longer silently drop the
   format key. Adds tests covering blank-value and mixed-value cases.

2. Adds a regression test that locks in the correct token-bucket
   semantics: last_refill must advance on every consume() call,
   including denied ones. The fix proposed in the issue (move
   last_refill update inside the success branch) would let a client
   that retries quickly bypass the configured refill rate by
   re-counting the same elapsed window on each retry.

The env_nested_delimiter concern from the issue is not addressed here:
`FASTMCP_DOCKET__CONCURRENCY` (single underscore between prefix and the
nested key, double for nesting) already works as documented in
docs/more/settings.mdx. The form `FASTMCP__DOCKET__CONCURRENCY` is not
the correct pydantic-settings syntax when the parent has env_prefix="FASTMCP_".

Closes PrefectHQ#4056.
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. too-long Excessively verbose or unedited LLM output. Condense before triage. labels Apr 26, 2026
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Thanks for the report. This issue goes beyond what our contributor guidelines ask for — we just need a short problem description and an MRE. Please see our contributing guidelines and condense this issue. We'll triage it once it's trimmed down.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0fae7daa83

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

# implementation, last_refill advances on each call, so total
# accumulated tokens after 0.2s is ~2 (10/s * 0.2s).
for _ in range(20):
await limiter.consume(1)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Assert retry consumes are denied in regression loop

This regression test does not actually fail if last_refill is only advanced on successful consumes, because the loop never checks whether consume(1) was denied. Under that buggy behavior, retries start succeeding intermittently (and draining tokens), so the final consume(5) is False assertion can still pass, leaving the clock-freeze regression undetected. Add an assertion on each loop iteration (or control time deterministically) so the test distinguishes the intended behavior.

Useful? React with 👍 / 👎.

MukundaKatta added a commit to MukundaKatta/fastmcp that referenced this pull request Apr 28, 2026
The HeadlessOAuth callback handler in src/fastmcp/utilities/tests.py
called parse_qs() without keep_blank_values=True, so an explicitly-empty
query parameter (e.g. ?state= or ?error_description=) was silently
dropped. Real OAuth providers can emit empty values, and downstream code
uses .get("state", [None])[0] to distinguish "not present" (None) from
"present but empty" (""). Without the flag, both cases collapsed to None.

This is the same defect class that PR PrefectHQ#4069 fixed in
src/fastmcp/resources/template.py; this commit applies the same fix to
the remaining parse_qs call site so behavior is consistent across the
code base.

Adds three regression tests in tests/utilities/test_tests.py covering
blank state, missing state, and blank error_description.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. too-long Excessively verbose or unedited LLM output. Condense before triage.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Query params, env delimiter, and token bucket timing bugs

1 participant