Browse Source

[quotas] configurable rate limits (#4732)

* [quotas] configurable rate limits

- add per-hour account (org) limits
- add configurable windows to quota implementations
- remove deprecated project quotas

* remove useless check

* invalid comment

* fix 1hr window
David Cramer 8 years ago
parent
commit
e7124a9a59

+ 3 - 0
CHANGES

@@ -4,6 +4,9 @@ Version 8.13 (Unreleased)
 
 - Support for setting a custom security header for javascript fetching.
 
+- Project quotas are no longer available, and must now be configured via the organizational rate limits.
+- Quotas implementation now requires a tuple of maximum rate and interval window.
+
 Version 8.12
 ------------
 

+ 8 - 1
src/sentry/api/endpoints/organization_details.py

@@ -49,7 +49,8 @@ def update_organization_scenario(runner):
 
 
 class OrganizationSerializer(serializers.ModelSerializer):
-    projectRateLimit = serializers.IntegerField(min_value=1, max_value=100)
+    accountRateLimit = serializers.IntegerField(min_value=0, max_value=1000000)
+    projectRateLimit = serializers.IntegerField(min_value=50, max_value=100)
     slug = serializers.RegexField(r'^[a-z0-9_\-]+$', max_length=50,
                                   required=False)
 
@@ -73,6 +74,12 @@ class OrganizationSerializer(serializers.ModelSerializer):
                 key='sentry:project-rate-limit',
                 value=int(self.init_data['projectRateLimit']),
             )
+        if 'accountRateLimit' in self.init_data:
+            OrganizationOption.objects.set_value(
+                organization=self.object,
+                key='sentry:account-rate-limit',
+                value=int(self.init_data['accountRateLimit']),
+            )
         return rv
 
 

+ 8 - 1
src/sentry/api/serializers/models/organization.py

@@ -77,8 +77,15 @@ class DetailedOrganizationSerializer(OrganizationSerializer):
 
         context = super(DetailedOrganizationSerializer, self).serialize(
             obj, attrs, user)
+        max_rate = quotas.get_maximum_quota(obj)
         context['quota'] = {
-            'maxRate': quotas.get_organization_quota(obj),
+            'maxRate': max_rate[0],
+            'maxRateInterval': max_rate[1],
+            'accountLimit': int(OrganizationOption.objects.get_value(
+                organization=obj,
+                key='sentry:account-rate-limit',
+                default=0,
+            )),
             'projectLimit': int(OrganizationOption.objects.get_value(
                 organization=obj,
                 key='sentry:project-rate-limit',

+ 0 - 1
src/sentry/conf/server.py

@@ -682,7 +682,6 @@ SENTRY_FEATURES = {
     'organizations:sso': True,
     'organizations:callsigns': False,
     'projects:global-events': False,
-    'projects:quotas': True,
     'projects:plugins': True,
     'projects:dsym': False,
     'workflow:release-emails': False,

+ 0 - 1
src/sentry/features/__init__.py

@@ -14,7 +14,6 @@ default_manager.add('organizations:onboarding', OrganizationFeature)  # NOQA
 default_manager.add('organizations:callsigns', OrganizationFeature)  # NOQA
 default_manager.add('organizations:repos', OrganizationFeature)  # NOQA
 default_manager.add('projects:global-events', ProjectFeature)  # NOQA
-default_manager.add('projects:quotas', ProjectFeature)  # NOQA
 default_manager.add('projects:plugins', ProjectPluginFeature)  # NOQA
 default_manager.add('workflow:release-emails')
 

+ 40 - 37
src/sentry/quotas/base.py

@@ -52,15 +52,7 @@ class Quota(object):
         return int(quota or 0)
 
     def get_project_quota(self, project):
-        from sentry.models import (
-            ProjectOption, Organization, OrganizationOption
-        )
-
-        # DEPRECATED: Will likely be removed in a future version unless Sentry
-        # team is convinced otherwise.
-        legacy_quota = ProjectOption.objects.get_value(project, 'quotas:per_minute', '')
-        if legacy_quota == '':
-            legacy_quota = settings.SENTRY_DEFAULT_MAX_EVENTS_PER_MINUTE
+        from sentry.models import Organization, OrganizationOption
 
         org = getattr(project, '_organization_cache', None)
         if not org:
@@ -70,40 +62,51 @@ class Quota(object):
         max_quota_share = int(OrganizationOption.objects.get_value(
             org, 'sentry:project-rate-limit', 100))
 
-        if not legacy_quota and max_quota_share == 100:
-            return 0
-
-        org_quota = self.get_organization_quota(org)
+        if max_quota_share == 100:
+            return (0, 60)
 
-        quota = self.translate_quota(
-            legacy_quota,
-            org_quota,
-        )
+        org_quota, window = self.get_organization_quota(org)
 
-        # if we have set a max project quota percentage and there's actually
-        # a quota set for the org, lets calculate the maximum by using the min
-        # of the two quotas
         if max_quota_share != 100 and org_quota:
-            if quota:
-                quota = min(quota, self.translate_quota(
-                    '{}%'.format(max_quota_share),
-                    org_quota,
-                ))
-            else:
-                quota = self.translate_quota(
-                    '{}%'.format(max_quota_share),
-                    org_quota,
-                )
-
-        return quota
+            quota = self.translate_quota(
+                '{}%'.format(max_quota_share),
+                org_quota,
+            )
+        else:
+            quota = 0
+
+        return (quota, window)
 
     def get_organization_quota(self, organization):
-        system_rate_limit = options.get('system.rate-limit')
+        from sentry.models import OrganizationOption
+
+        account_limit = int(OrganizationOption.objects.get_value(
+            organization=organization,
+            key='sentry:account-rate-limit',
+            default=0,
+        ))
+
+        system_limit = options.get('system.rate-limit')
+
         # If there is only a single org, this one org should
         # be allowed to consume the entire quota.
         if settings.SENTRY_SINGLE_ORGANIZATION:
-            return system_rate_limit
-        return self.translate_quota(
+            if system_limit < account_limit:
+                return (system_limit, 60)
+            return (account_limit, 3600)
+
+        # an account limit is enforced, which is set as a fixed value and cannot
+        # utilize percentage based limits
+        elif account_limit:
+            return (account_limit, 3600)
+
+        return (self.translate_quota(
             settings.SENTRY_DEFAULT_MAX_EVENTS_PER_MINUTE,
-            system_rate_limit,
-        )
+            system_limit,
+        ), 60)
+
+    def get_maximum_quota(self, organization):
+        """
+        Return the maximum capable rate for an organization.
+        """
+        return (options.get('system.rate-limit'), 60)

+ 2 - 2
src/sentry/quotas/redis.py

@@ -38,8 +38,8 @@ class RedisQuota(Quota):
 
     def get_quotas(self, project):
         return (
-            ('p:{}'.format(project.id), self.get_project_quota(project), 60),
-            ('o:{}'.format(project.organization.id), self.get_organization_quota(project.organization), 60),
+            ('p:{}'.format(project.id),) + self.get_project_quota(project),
+            ('o:{}'.format(project.organization.id),) + self.get_organization_quota(project.organization),
         )
 
     def get_redis_key(self, key, timestamp, interval):

+ 1 - 1
src/sentry/static/sentry/app/components/forms/formField.jsx

@@ -8,9 +8,9 @@ class FormField extends React.Component {
 }
 
 FormField.propTypes = {
-  label: React.PropTypes.string.isRequired,
   name: React.PropTypes.string.isRequired,
 
+  label: React.PropTypes.string,
   defaultValue: React.PropTypes.any,
   disabled: React.PropTypes.bool,
   error: React.PropTypes.string,

+ 13 - 4
src/sentry/static/sentry/app/components/forms/inputField.jsx

@@ -50,6 +50,10 @@ class InputField extends FormField {
     return 'id-' + this.props.name;
   }
 
+  getAttributes() {
+    return {};
+  }
+
   getField() {
     return (
       <input id={this.getId()}
@@ -60,7 +64,9 @@ class InputField extends FormField {
           disabled={this.props.disabled}
           ref="input"
           required={this.props.required}
-          value={this.state.value} />
+          value={this.state.value}
+          style={this.props.inputStyle}
+          {...this.getAttributes()} />
     );
   }
 
@@ -76,13 +82,16 @@ class InputField extends FormField {
     return (
       <div className={className}>
         <div className="controls">
-          <label htmlFor={this.getId()} className="control-label">{this.props.label}</label>
+          {this.props.label &&
+            <label htmlFor={this.getId()} className="control-label">{this.props.label}</label>
+          }
+          {this.getField()}
           {this.props.disabled && this.props.disabledReason &&
-            <span className="disabled-indicator tip" title={this.props.disabledReason}>
+            <span className="disabled-indicator tip"
+                  title={this.props.disabledReason}>
               <span className="icon-question" />
             </span>
           }
-          {this.getField()}
           {defined(this.props.help) &&
             <p className="help-block">{this.props.help}</p>
           }

+ 7 - 0
src/sentry/static/sentry/app/components/forms/numberField.jsx

@@ -4,4 +4,11 @@ export default class NumberField extends InputField {
   getType() {
     return 'number';
   }
+
+  getAttributes() {
+    return {
+        min: this.props.min || undefined,
+        max: this.props.max || undefined,
+    };
+  }
 }

Some files were not shown because too many files changed in this diff