Make the rate limiting (429) response message admin-configurable with Markdown support - #1357
Conversation
SimpleChat returned 429 from four unrelated places, each with its own hard-coded string. In chat it was worse: retries absorbed transient 429s, but once they ran out the failure fell through to the generic "Something went wrong while streaming the response" error, which is indistinguishable from a real fault. That matters most in APIM-fronted deployments where throttling is a deliberate capacity decision and the admin knows something useful to say about it. Adds a single admin-configurable, Markdown-capable message resolved from one place and used by every 429 the app returns. - functions_rate_limit.py: leaf module holding the default, the normalizer, and the payload builder, so no surface reimplements the fallback rules and nothing risks a circular import - Two settings keys, enable_custom_rate_limit_message and rate_limit_message, both non-sensitive so they survive sanitize_settings_for_user - Global @app.errorhandler(429) with content negotiation: JSON for API/XHR callers, rendered HTML for browser navigations, and a plain-text fallback if the shell fails to render - Chat classifies an exhausted throttle via is_rate_limit_error and emits rate_limited/status_code 429 instead of the generic stream error - TTS, Swagger spec endpoints and inbound MCP now share the message; inbound MCP keeps its structured limit/window/reset data so clients can still back off - chat-streaming.js renders the Markdown through DOMPurify.sanitize(marked.parse(...)) only for rate limits; every other stream error keeps its createTextNode rendering - New Security > Rate Limiting admin tab with a SimpleMDE editor Version is locked at 0.261.001 for this work. Fixes #1354 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Development had moved on to the fact memory work and the v0.261.001 relabel. Only two files conflicted, both docs: - docs/explanation/release_notes.md: both sides added entries under the same v0.261.001 heading. Kept both, merged by category so the fact memory and rate limiting entries sit together under New Features and User Interface Enhancements rather than duplicating the headings. - docs/_data/app_surface.yml: generated file, regenerated from the merged application rather than hand-resolved. config.py needed no resolution; Development had already relabeled to 0.261.001, which matches the version this branch locked to. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Merge conflict resolvedRebased onto the latest
Re-validated after the merge, including the suites this branch does not own, to confirm the merge did not disturb the incoming work:
About the red
|
Summary
"Something went wrong while streaming the response. Please try again."— indistinguishable from a real fault. That is precisely the case an APIM-fronted deployment wants to explain, because throttling there is a deliberate capacity decision.functions_rate_limit.py), so the fallback rules are defined exactly once and every 429 surface agrees.What a user sees now
rate_limited: true"Service temporarily unavailable due to high load.""Too many requests for swagger.json..."Retry-After"Inbound MCP tool rate limit exceeded."data@app.errorhandler(429)Linked issue
Fixes #1354
Release Notes & Latest Features
Is this visible to end users?
Is this admin-facing (Admin Settings, governance, deployment, config)?
Should this become a Latest Feature card?
Screenshot needed for the card?
Version bump
VERSIONis locked at0.261.001for this work by request, rather than taking a patch bump from0.260.028. Please keep it there for follow-up work targeting this release.application/single_app/config.pyVERSIONset to0.261.001deployers/version.txtbumped, or not needed becausedeployers/was not changedTesting / validation
Beyond the suites, the 429 handler was exercised against a live Flask test client to confirm behavior that static assertions cannot prove:
/api/path returns JSON withrate_limited: trueand raw Markdown<h2>produced,rel="noopener noreferrer"added to links, no raw##leaked)X-Requested-With: XMLHttpRequeston a non-API path negotiates JSONAlso verified: all changed Python byte-compiles, both changed JS files pass
node --check, and the new Jinja templates parse.Documentation
docs/explanation/features/RATE_LIMIT_RESPONSE_MESSAGE.mddocs/admin/security.mdgained the{#rate-limiting}tab anchor required bytest_admin_tab_coverage,docs/_data/features.ymlclaimsenable_custom_rate_limit_message, anddocs/_data/app_surface.ymlwas regenerated.Security checklist
@swagger_route(security=get_auth_security())— no new routes; this adds an error handler onlysanitize_settings_for_user()— both new keys are non-sensitive and pass through unchanged, which the test asserts explicitlymarked,DOMPurify, and SimpleMDEMarkdown is sanitized on both sides:
bleachserver-side via the existingmarkdown_filter, andDOMPurify.sanitize(marked.parse(...))client-side. The client-side rendering is scoped to therate_limitedbranch only — every other stream error keeps its existingcreateTextNoderendering, so this cannot widen the blast radius of other error messages.Notes for reviewers
is_rate_limit_errormatches\b429\brather than a bare429substring, so an unrelated number such as14290cannot be misread as throttling.messagefield is the human-readable field per spec, so putting the admin message there is a correct fit; the machine-readabledatais untouched.