Skip to content

feat(api): double API throttles for membership promotion - #7508

Merged
albertisfu merged 10 commits into
mainfrom
1050-feat-double-membership-api-throttles
Jul 1, 2026
Merged

feat(api): double API throttles for membership promotion#7508
albertisfu merged 10 commits into
mainfrom
1050-feat-double-membership-api-throttles

Conversation

@ERosendo

@ERosendo ERosendo commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes

This PR fixes https://github.com/freelawproject/internal/issues/1050

Summary

This PR adds a time-boxed x2 boost to API rate limits, controlled by a new double_api_throttles waffle switch. This lets us enable the promotion and disable it when it ends without requiring a redeploy.

Who gets doubled

Group Doubled?
Paid memberships (TIER_1–4, GROUP_T*, HEYCOUNSEL_T*)
Non-members (default user rate)
LSO (Included in 9772253)
EDU members
MANUAL / commercial overrides

The promotion is implemented as shared eligibility logic that's reused throughout the codebase:

  • Throttle enforcement: ExceptionalUserRateThrottle.allow_request doubles the resolved rates for eligible users.
  • No retroactive penalties: promo traffic is stored in a separate _promo2x throttle cache window (via get_cache_key). When the promotion ends, users return to the normal limits with a clean slate instead of having a window full of requests counted against the lower limits.
  • Consistent reporting: the API usage page and get_recent_api_request_count use the same eligibility check, so the
    displayed limits always match the enforced limits.

Lifetime and daily usage stats are untouched, they're tracked on a separate Redis path (_log_request), independent of the throttle history.

Deployment

This PR should:

  • skip-deploy (skips everything below)
    • skip-web-deploy
    • skip-celery-deploy
    • skip-cronjob-deploy
    • skip-daemon-deploy

Comment thread cl/users/tests.py
@classmethod
def setUpTestData(cls) -> None:
cls.user = UserProfileWithParentsFactory.create().user
cls.user.set_password("password")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Semgrep identified an issue in your code:
The password on 'cls.user' is being set without validating the password. Call django.contrib.auth.password_validation.validate_password() with validation functions before setting the password. See https://docs.djangoproject.com/en/3.0/topics/auth/passwords/ for more information.

To resolve this comment:

💡 Follow autofix suggestion

Suggested change
cls.user.set_password("password")
if django.contrib.auth.password_validation.validate_password("password", user=cls.user):
cls.user.set_password("password")
💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by unvalidated-password.

You can view more details about this finding in the Semgrep AppSec Platform.

@ERosendo
ERosendo force-pushed the 1050-feat-double-membership-api-throttles branch 3 times, most recently from e756951 to 941eb6d Compare June 27, 2026 15:10
Adds a time-boxed x2 boost to user API rate limits, gated by the `double_api_throttles` waffle switch.

Lifetime and daily usage stats are unaffected (separate Redis path).
@ERosendo
ERosendo force-pushed the 1050-feat-double-membership-api-throttles branch from 941eb6d to 7c7d79c Compare June 27, 2026 15:18
@ERosendo
ERosendo marked this pull request as ready for review June 27, 2026 16:20
@ERosendo ERosendo moved this to To Do in Sprint (Web Team) Jun 27, 2026
@ERosendo
ERosendo requested a review from albertisfu June 27, 2026 16:28

@albertisfu albertisfu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @ERosendo this looks good!

I just a couple of suggestions before we merge it.

Comment thread cl/api/utils.py Outdated

def promo_doubling_applies(user: User) -> bool:
"""Return whether the x2 API promotion applies to this user."""
if not switch_is_active(DOUBLE_API_THROTTLES_SWITCH):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This will hit the Redis cache on each request. Could we cache the switch_is_active status in memory to avoid hitting Redis too often? Maybe with a 10-minute cache?

It’s fine if it takes up to 10 minutes for the increase to be enabled or disabled after the switch is activated.

Comment thread cl/api/utils.py Outdated

overrides = get_all_throttle_overrides(self.throttle_type)
rates = overrides.get(request.user.username) or self.default_rates
if promo_doubling_applies(request.user):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In get_cache_key, we’re doing:

if self.scope == "user" and promo_doubling_applies(request.user):

But here, we only check:

if promo_doubling_applies(request.user):

To be consistent, could we use the same condition?

if self.scope == "user" and promo_doubling_applies(request.user):

Maybe we can create a helper method for it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch! I extracted a _promo_applies(request) helper on ExceptionalUserRateThrottle that encapsulates self.scope == "user" and promo_doubling_applies(request.user), then used it in both get_cache_key() and allow_request(). That way the eligibility check is defined in one place and stays consistent across both methods.

@albertisfu albertisfu assigned ERosendo and unassigned albertisfu Jun 30, 2026
ERosendo added 4 commits June 30, 2026 19:58
Extract `_promo_applies(request)` into `ExceptionalUserRateThrottle` to centralize the x2 promo eligibility logic.

Previously, `get_cache_key()` checked both `self.scope == "user"` and `promo_doubling_applies(...)`, while `allow_request()` only checked `promo_doubling_applies(...)`. Using a shared helper ensures the eligibility condition is defined once and applied consistently.
@ERosendo
ERosendo requested a review from albertisfu July 1, 2026 01:02
@ERosendo ERosendo assigned albertisfu and unassigned ERosendo Jul 1, 2026
@albertisfu albertisfu moved this from To Do to In progress in Sprint (Web Team) Jul 1, 2026

@albertisfu albertisfu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks @ERosendo this looks ready to go!

@albertisfu
albertisfu merged commit 4a851bc into main Jul 1, 2026
9 checks passed
@albertisfu
albertisfu deleted the 1050-feat-double-membership-api-throttles branch July 1, 2026 19:14
@github-project-automation github-project-automation Bot moved this from In progress to Done in Sprint (Web Team) Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants