Merge changes Ideb18201,I0ac9d4de

* changes:
  Add REST API quota checks
  Add QuotaBackend and QuotaEnforcer extension point
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 82a9527..e84effd 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2670,6 +2670,94 @@
 are met, but marked as `OK`. If the requirements were not displayed, reviewers
 would need to use their precious time to manually check that they were met.
 
+[[quota-enforcer]]
+== Quota Enforcer
+
+Gerrit provides an extension point that allows a plugin to enforce quota.
+link:quota.html[This documentation page] has a list of all quota requests that
+Gerrit core issues. Plugins can choose to respond to all or just a subset of
+requests. Some implementations might want to keep track of user quota in buckets,
+others might just check against instance or project state to enforce limits on how
+many projects can be created or how large a repository can become.
+
+Checking against instance state can be racy for concurrent requests as the server does not
+refill tokens if the action fails in a later stage (e.g. database failure). If
+plugins want to guarantee an absolute maximum on a resource, they have to do their own
+book-keeping.
+
+[source, java]
+----
+import com.google.server.quota.QuotaEnforcer;
+
+class ProjectLimiter implements QuotaEnforcer {
+  private final long maxNumberOfProjects = 100;
+  @Override
+  QuotaResponse requestTokens(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    if (!"/projects/create".equals(quotaGroup)) {
+      return QuotaResponse.noOp();
+    }
+    // No deduction because we always check against the instance state (racy but fine for
+    // this plugin)
+    if (currentNumberOfProjects() + numTokens > maxNumberOfProjects) {
+      return QuotaResponse.error("too many projects");
+    }
+    return QuotaResponse.ok();
+  }
+
+  @Override
+  QuotaResponse dryRun(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    // Since we are not keeping any state in this enforcer, we can simply call requestTokens().
+    return requestTokens(quotaGroup, ctx, numTokens);
+  }
+
+  void refill(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    // No-op
+  }
+}
+----
+
+[source, java]
+----
+import com.google.server.quota.QuotaEnforcer;
+
+class ApiQpsEnforcer implements QuotaEnforcer {
+  // AutoRefillingPerUserBuckets is a imaginary bucket implementation that could be based on
+  // a loading cache or a commonly used bucketing algorithm.
+  private final AutoRefillingPerUserBuckets<CurrentUser, Long> buckets;
+  @Override
+  QuotaResponse requestTokens(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    if (!quotaGroup.startsWith("/restapi/")) {
+      return QuotaResponse.noOp();
+    }
+    boolean success = buckets.deduct(ctx.user(), numTokens);
+    if (!success) {
+      return QuotaResponse.error("user sent too many qps, please wait for 5 minutes");
+    }
+    return QuotaResponse.ok();
+  }
+
+  @Override
+  QuotaResponse dryRun(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    if (!quotaGroup.startsWith("/restapi/")) {
+      return QuotaResponse.noOp();
+    }
+    boolean success = buckets.checkOnly(ctx.user(), numTokens);
+    if (!success) {
+      return QuotaResponse.error("user sent too many qps, please wait for 5 minutes");
+    }
+    return QuotaResponse.ok();
+  }
+
+  @Override
+  void refill(String quotaGroup, QuotaRequestContext ctx, long numTokens) {
+    if (!quotaGroup.startsWith("/restapi/")) {
+      return;
+    }
+    buckets.add(ctx.user(), numTokens);
+  }
+}
+----
+
 
 == SEE ALSO
 
diff --git a/Documentation/index.txt b/Documentation/index.txt
index 6011158..557cf90 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -85,6 +85,7 @@
 .. link:js-api.html[JavaScript Plugin API]
 .. link:config-validation.html[Validation Interfaces]
 .. link:dev-stars.html[Starring Changes]
+.. link:quota.html[Quota Enforcement]
 . link:dev-design.html[System Design]
 . link:i18n-readme.html[i18n Support]
 
diff --git a/Documentation/quota.txt b/Documentation/quota.txt
new file mode 100644
index 0000000..a647e33
--- /dev/null
+++ b/Documentation/quota.txt
@@ -0,0 +1,50 @@
+= Gerrit Code Review - Quota
+
+Gerrit does not provide out of the box quota enforcement. However, it does
+support an extension mechanism for plugins to hook into to provide this
+functionality. The most prominent plugin is the
+link:https://gerrit.googlesource.com/plugins/quota/[Quota Plugin].
+
+This documentation is intended to be read by plugin developers. It contains all
+quota requests implemented in Gerrit-core as well as the metadata that they have
+associated.
+
+== Quota Groups
+
+The following quota groups are defined in core Gerrit:
+
+=== REST API
+[[rest-api]]
+
+The REST API enforces quota after the resource was parsed (if applicable) and before the
+endpoint's logic is executed. This enables quota enforcer implementations to throttle calls
+to specific endpoints while knowing the general context (user and top-level entity such as
+change, project or account).
+
+If the quota enforcer wants to throttle HTTP requests, they should use
+link:quota.html#http-requests[HTTP Requests] instead.
+
+The quota groups used for checking follow the exact definition of the endoint in the REST
+API, but remove all IDs. The schema is:
+
+/restapi/<ENDPOINT>:<HTTP-METHOD>
+
+Examples:
+
+[options="header",cols="1,6"]
+|=======================
+|HTTP call                                 |Quota Group                    |Metadata
+|GET /a/changes/1/revisions/current/detail |/changes/revisions/detail:GET  |CurrentUser, Change.Id, Project.NameKey
+|POST /a/changes/                          |/changes/:POST                 |CurrentUser
+|GET /a/accounts/self/detail               |/accounts/detail:GET           |CurrentUser, Account.Id
+|=======================
+
+The user provided in the check's metadata is always the calling user (having the
+impersonation bit and real user set in case the user is impersonating another user).
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 8f6a47b..a8ab353 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -191,6 +191,11 @@
 "`422 Unprocessable Entity`" is returned if the ID of a resource that is
 specified in the request body cannot be resolved.
 
+==== 429 Too Many Requests
+"`429 Too Many Requests`" is returned if the request exhausted any set
+quota limits. Depending on the exhausted quota, the request may be retried
+with exponential backoff.
+
 [[tracing]]
 === Request Tracing
 For each REST endpoint tracing can be enabled by setting the
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiQuotaEnforcer.java b/java/com/google/gerrit/httpd/restapi/RestApiQuotaEnforcer.java
new file mode 100644
index 0000000..c7678c2
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/RestApiQuotaEnforcer.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.quota.QuotaBackend;
+import com.google.gerrit.server.quota.QuotaException;
+import com.google.gerrit.util.http.RequestUtil;
+import javax.inject.Inject;
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Enforces quota on specific REST API endpoints.
+ *
+ * <p>Examples:
+ *
+ * <ul>
+ *   <li>GET /a/accounts/self/detail => /restapi/accounts/detail:GET
+ *   <li>GET /changes/123/revisions/current/detail => /restapi/changes/revisions/detail:GET
+ *   <li>PUT /changes/10/reviewed => /changes/reviewed:PUT
+ * </ul>
+ *
+ * <p>Adds context (change, project, account) to the quota check if the call is for an existing
+ * entity that was successfully parsed. This quota check is generally enforced after the resource
+ * was parsed, but before the view is executed. If a quota enforcer desires to throttle earlier,
+ * they should consider quota groups in the {@code /http/*} space.
+ */
+public class RestApiQuotaEnforcer {
+  private final QuotaBackend quotaBackend;
+
+  @Inject
+  RestApiQuotaEnforcer(QuotaBackend quotaBackend) {
+    this.quotaBackend = quotaBackend;
+  }
+
+  /** Enforce quota on a request not tied to any {@code RestResource}. */
+  void enforce(HttpServletRequest req) throws QuotaException {
+    String pathForQuotaReporting = RequestUtil.getRestPathWithoutIds(req);
+    quotaBackend
+        .currentUser()
+        .requestToken(quotaGroup(pathForQuotaReporting, req.getMethod()))
+        .throwOnError();
+  }
+
+  /** Enforce quota on a request for a given resource. */
+  void enforce(RestResource rsrc, HttpServletRequest req) throws QuotaException {
+    String pathForQuotaReporting = RequestUtil.getRestPathWithoutIds(req);
+    // Enrich the quota request we are operating on an interesting collection
+    QuotaBackend.WithResource report = quotaBackend.currentUser();
+    if (rsrc instanceof ChangeResource) {
+      ChangeResource changeResource = (ChangeResource) rsrc;
+      report =
+          quotaBackend.currentUser().change(changeResource.getId(), changeResource.getProject());
+    } else if (rsrc instanceof AccountResource) {
+      AccountResource accountResource = (AccountResource) rsrc;
+      report = quotaBackend.currentUser().account(accountResource.getUser().getAccountId());
+    } else if (rsrc instanceof ProjectResource) {
+      ProjectResource accountResource = (ProjectResource) rsrc;
+      report = quotaBackend.currentUser().account(accountResource.getUser().getAccountId());
+    }
+
+    report.requestToken(quotaGroup(pathForQuotaReporting, req.getMethod())).throwOnError();
+  }
+
+  private static String quotaGroup(String path, String method) {
+    return "/restapi" + path + ":" + method;
+  }
+}
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index ec71d8f..c9f3a77 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -114,6 +114,7 @@
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.quota.QuotaException;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.util.http.CacheHeaders;
@@ -227,6 +228,7 @@
     final AuditService auditService;
     final RestApiMetrics metrics;
     final Pattern allowOrigin;
+    final RestApiQuotaEnforcer quotaChecker;
 
     @Inject
     Globals(
@@ -236,6 +238,7 @@
         PermissionBackend permissionBackend,
         AuditService auditService,
         RestApiMetrics metrics,
+        RestApiQuotaEnforcer quotaChecker,
         @GerritServerConfig Config cfg) {
       this.currentUser = currentUser;
       this.webSession = webSession;
@@ -243,6 +246,7 @@
       this.permissionBackend = permissionBackend;
       this.auditService = auditService;
       this.metrics = metrics;
+      this.quotaChecker = quotaChecker;
       allowOrigin = makeAllowOrigin(cfg);
     }
 
@@ -317,6 +321,7 @@
         viewData = new ViewData(null, null);
 
         if (path.isEmpty()) {
+          globals.quotaChecker.enforce(req);
           if (rc instanceof NeedsParams) {
             ((NeedsParams) rc).setParams(qp.params());
           }
@@ -339,6 +344,7 @@
           IdString id = path.remove(0);
           try {
             rsrc = rc.parse(rsrc, id);
+            globals.quotaChecker.enforce(rsrc, req);
             if (path.isEmpty()) {
               checkPreconditions(req);
             }
@@ -346,6 +352,7 @@
             if (!path.isEmpty()) {
               throw e;
             }
+            globals.quotaChecker.enforce(req);
 
             if (isPost(req) || isPut(req)) {
               RestView<RestResource> createView = rc.views().get(PluginName.GERRIT, "CREATE./");
@@ -602,6 +609,9 @@
           status = SC_INTERNAL_SERVER_ERROR;
           responseBytes = handleException(e, req, res);
         }
+      } catch (QuotaException e) {
+        responseBytes =
+            replyError(req, res, status = 429, messageOr(e, "Quota limit reached"), e.caching(), e);
       } catch (Exception e) {
         status = SC_INTERNAL_SERVER_ERROR;
         responseBytes = handleException(e, req, res);
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index b4f9cc7..b26e875 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -170,6 +170,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
+import com.google.gerrit.server.quota.QuotaEnforcer;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
@@ -394,6 +395,7 @@
     DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
     DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
     DynamicSet.setOf(binder(), SubmitRule.class);
+    DynamicSet.setOf(binder(), QuotaEnforcer.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
diff --git a/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java b/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
new file mode 100644
index 0000000..8aa86b1
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/DefaultQuotaBackend.java
@@ -0,0 +1,149 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.quota;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.plugincontext.PluginSetEntryContext;
+import java.util.ArrayList;
+import java.util.List;
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.inject.Singleton;
+
+@Singleton
+public class DefaultQuotaBackend implements QuotaBackend {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Provider<CurrentUser> userProvider;
+  private final PluginSetContext<QuotaEnforcer> quotaEnforcers;
+
+  @Inject
+  DefaultQuotaBackend(
+      Provider<CurrentUser> userProvider, PluginSetContext<QuotaEnforcer> quotaEnforcers) {
+    this.userProvider = userProvider;
+    this.quotaEnforcers = quotaEnforcers;
+  }
+
+  @Override
+  public WithUser currentUser() {
+    return new WithUser(quotaEnforcers, userProvider.get());
+  }
+
+  @Override
+  public WithUser user(CurrentUser user) {
+    return new WithUser(quotaEnforcers, user);
+  }
+
+  private static QuotaResponse.Aggregated request(
+      PluginSetContext<QuotaEnforcer> quotaEnforcers,
+      String quotaGroup,
+      QuotaRequestContext requestContext,
+      long numTokens,
+      boolean deduct) {
+    checkState(numTokens > 0, "numTokens must be a positive, non-zero long");
+
+    // PluginSets can change their content when plugins (de-)register. Copy the currently registered
+    // plugins so that we can iterate twice on a stable list.
+    List<PluginSetEntryContext<QuotaEnforcer>> enforcers = ImmutableList.copyOf(quotaEnforcers);
+    List<QuotaResponse> responses = new ArrayList<>(enforcers.size());
+    for (PluginSetEntryContext<QuotaEnforcer> enforcer : enforcers) {
+      try {
+        if (deduct) {
+          responses.add(enforcer.call(p -> p.requestTokens(quotaGroup, requestContext, numTokens)));
+        } else {
+          responses.add(enforcer.call(p -> p.dryRun(quotaGroup, requestContext, numTokens)));
+        }
+      } catch (RuntimeException e) {
+        logger.atSevere().withCause(e).log("exception while enforcing quota");
+        responses.add(QuotaResponse.error(e.getMessage()));
+      }
+    }
+
+    if (deduct && responses.stream().anyMatch(r -> r.status().isError())) {
+      // Roll back the quota request for all enforcers that deducted the quota (= the request
+      // succeeded). Don't touch failed enforcers as the interface contract said that failed
+      // requests should not be deducted.
+      for (int i = 0; i < responses.size(); i++) {
+        if (responses.get(i).status().isOk()) {
+          enforcers.get(i).run(p -> p.refill(quotaGroup, requestContext, numTokens));
+        }
+      }
+    }
+
+    logger.atFine().log(
+        "Quota request for %s with %s (deduction=%s) for %s token returned %s",
+        quotaGroup,
+        requestContext,
+        deduct ? "(deduction=yes)" : "(deduction=no)",
+        numTokens,
+        responses);
+    return new AutoValue_QuotaResponse_Aggregated(ImmutableList.copyOf(responses));
+  }
+
+  static class WithUser extends WithResource implements QuotaBackend.WithUser {
+    WithUser(PluginSetContext<QuotaEnforcer> quotaEnforcers, CurrentUser user) {
+      super(quotaEnforcers, QuotaRequestContext.builder().user(user).build());
+    }
+
+    @Override
+    public QuotaBackend.WithResource account(Account.Id account) {
+      QuotaRequestContext ctx = requestContext.toBuilder().account(account).build();
+      return new WithResource(quotaEnforcers, ctx);
+    }
+
+    @Override
+    public QuotaBackend.WithResource project(NameKey project) {
+      QuotaRequestContext ctx = requestContext.toBuilder().project(project).build();
+      return new WithResource(quotaEnforcers, ctx);
+    }
+
+    @Override
+    public QuotaBackend.WithResource change(Change.Id change, NameKey project) {
+      QuotaRequestContext ctx = requestContext.toBuilder().change(change).project(project).build();
+      return new WithResource(quotaEnforcers, ctx);
+    }
+  }
+
+  static class WithResource implements QuotaBackend.WithResource {
+    protected final QuotaRequestContext requestContext;
+    protected final PluginSetContext<QuotaEnforcer> quotaEnforcers;
+
+    private WithResource(
+        PluginSetContext<QuotaEnforcer> quotaEnforcers, QuotaRequestContext quotaRequestContext) {
+      this.quotaEnforcers = quotaEnforcers;
+      this.requestContext = quotaRequestContext;
+    }
+
+    @Override
+    public QuotaResponse.Aggregated requestTokens(String quotaGroup, long numTokens) {
+      return DefaultQuotaBackend.request(
+          quotaEnforcers, quotaGroup, requestContext, numTokens, true);
+    }
+
+    @Override
+    public QuotaResponse.Aggregated dryRun(String quotaGroup, long numTokens) {
+      return DefaultQuotaBackend.request(
+          quotaEnforcers, quotaGroup, requestContext, numTokens, false);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaBackend.java b/java/com/google/gerrit/server/quota/QuotaBackend.java
new file mode 100644
index 0000000..d4dc46d
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaBackend.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.quota;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.inject.ImplementedBy;
+
+/**
+ * Backend interface to perform quota requests on. By default, this interface is backed by {@link
+ * DefaultQuotaBackend} which calls all plugins that implement {@link QuotaEnforcer}. A different
+ * implementation might be bound in tests. Plugins are not supposed to implement this interface, but
+ * bind a {@link QuotaEnforcer} implementation instead.
+ *
+ * <p>All quota requests require a quota group and a user. Enriching them with a top-level entity
+ * {@code Change, Project, Account} is optional but should be done if the request is targeted.
+ *
+ * <p>Example usage:
+ *
+ * <pre>
+ *   quotaBackend.currentUser().project(projectName).requestToken("/projects/create").throwOnError();
+ *   quotaBackend.user(user).requestToken("/restapi/config/put").throwOnError();
+ *   QuotaResponse.Aggregated result = quotaBackend.currentUser().account(accountId).requestToken("/restapi/accounts/emails/validate");
+ *   QuotaResponse.Aggregated result = quotaBackend.currentUser().project(projectName).requestTokens("/projects/git/upload", numBytesInPush);
+ * </pre>
+ *
+ * <p>All quota groups must be documented in {@code quota.txt} and detail the metadata that is
+ * provided (i.e. the parameters used to scope down the quota request).
+ */
+@ImplementedBy(DefaultQuotaBackend.class)
+public interface QuotaBackend {
+  /** Constructs a request for the current user. */
+  WithUser currentUser();
+
+  /**
+   * See {@link #currentUser()}. Use this method only if you can't guarantee that the request is for
+   * the current user (e.g. impersonation).
+   */
+  WithUser user(CurrentUser user);
+
+  /**
+   * An interface capable of issuing quota requests. Scope can be futher reduced by providing a
+   * top-level entity.
+   */
+  interface WithUser extends WithResource {
+    /** Scope the request down to an account. */
+    WithResource account(Account.Id account);
+
+    /** Scope the request down to a project. */
+    WithResource project(Project.NameKey project);
+
+    /** Scope the request down to a change. */
+    WithResource change(Change.Id change, Project.NameKey project);
+  }
+
+  /** An interface capable of issuing quota requests. */
+  interface WithResource {
+    /** Issues a single quota request for {@code 1} token. */
+    default QuotaResponse.Aggregated requestToken(String quotaGroup) {
+      return requestTokens(quotaGroup, 1);
+    }
+
+    /** Issues a single quota request for {@code numTokens} tokens. */
+    QuotaResponse.Aggregated requestTokens(String quotaGroup, long numTokens);
+
+    /**
+     * Issues a single quota request for {@code numTokens} tokens but signals the implementations
+     * not to deduct any quota yet. Can be used to do pre-flight requests where necessary
+     */
+    QuotaResponse.Aggregated dryRun(String quotaGroup, long tokens);
+  }
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaEnforcer.java b/java/com/google/gerrit/server/quota/QuotaEnforcer.java
new file mode 100644
index 0000000..9c55e11
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaEnforcer.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.quota;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Allows plugins to enforce different types of quota.
+ *
+ * <p>Enforcing quotas can be helpful in many scenarios. For example:
+ *
+ * <ul>
+ *   <li>Reducing the number of QPS a user can send to Gerrit on the REST API
+ *   <li>Limiting the size of a repository (project)
+ *   <li>Limiting the number of changes in a repository
+ *   <li>Limiting the number of actions that have the potential for spam, abuse or flooding if not
+ *       limited
+ * </ul>
+ *
+ * This endpoint gives plugins the capability to enforce any of these limits. The server will ask
+ * all plugins that registered this endpoint and collect all results. In case {@link
+ * #requestTokens(String, QuotaRequestContext, long)} was called and one or more plugins returned an
+ * erroneous result, the server will call {@link #refill(String, QuotaRequestContext, long)} on all
+ * plugins with the same parameters. Plugins that deducted tokens in the {@link
+ * #requestTokens(String, QuotaRequestContext, long)} call can refill them so that users don't get
+ * charged any quota for failed requests.
+ *
+ * <p>Not all implementations will need to deduct quota on {@link #requestTokens(String,
+ * QuotaRequestContext, long)}}. Implementations that work on top of instance-attributes, such as
+ * the number of projects per instance can choose not to keep any state and always check how many
+ * existing projects there are and if adding the inquired number would exceed the limit. In this
+ * case, {@link #requestTokens(String, QuotaRequestContext, long)} and {@link #dryRun(String,
+ * QuotaRequestContext, long)} share the same implementation and {@link #refill(String,
+ * QuotaRequestContext, long)} is a no-op.
+ */
+@ExtensionPoint
+public interface QuotaEnforcer {
+  /**
+   * Checks if there is at least {@code numTokens} quota to fulfil the request. Bucket-based
+   * implementations can deduct the inquired number of tokens from the bucket.
+   */
+  QuotaResponse requestTokens(String quotaGroup, QuotaRequestContext ctx, long numTokens);
+
+  /**
+   * Checks if there is at least {@code numTokens} quota to fulfil the request. This is a pre-flight
+   * request, implementations should not deduct tokens from a bucket, yet.
+   */
+  QuotaResponse dryRun(String quotaGroup, QuotaRequestContext ctx, long numTokens);
+
+  /**
+   * A previously requested and deducted quota has to be refilled (if possible) because the request
+   * failed other quota checks. Implementations can choose to leave this a no-op in case they are
+   * the first line of defence (e.g. always deduct HTTP quota even if the request failed for other
+   * quota issues so that the user gets throttled).
+   *
+   * <p>Will not be called if the {@link #requestTokens(String, QuotaRequestContext, long)} call
+   * returned {@link QuotaResponse.Status#NO_OP}.
+   */
+  void refill(String quotaGroup, QuotaRequestContext ctx, long numTokens);
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaException.java b/java/com/google/gerrit/server/quota/QuotaException.java
new file mode 100644
index 0000000..56877b2
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaException.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.quota;
+
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+/**
+ * Exception that was encountered while checking if there is sufficient quota to fulfil the request.
+ * Can be propagated directly to the REST API.
+ */
+public class QuotaException extends RestApiException {
+  public QuotaException(String reason) {
+    super(reason);
+  }
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaRequestContext.java b/java/com/google/gerrit/server/quota/QuotaRequestContext.java
new file mode 100644
index 0000000..90b501c
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaRequestContext.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.quota;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import java.util.Optional;
+
+@AutoValue
+public abstract class QuotaRequestContext {
+
+  public static Builder builder() {
+    return new AutoValue_QuotaRequestContext.Builder().user(new AnonymousUser());
+  }
+
+  public abstract CurrentUser user();
+
+  public abstract Optional<Project.NameKey> project();
+
+  public abstract Optional<Change.Id> change();
+
+  public abstract Optional<Account.Id> account();
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract QuotaRequestContext.Builder user(CurrentUser user);
+
+    public abstract QuotaRequestContext.Builder account(Account.Id account);
+
+    public abstract QuotaRequestContext.Builder project(Project.NameKey project);
+
+    public abstract QuotaRequestContext.Builder change(Change.Id change);
+
+    public abstract QuotaRequestContext build();
+  }
+}
diff --git a/java/com/google/gerrit/server/quota/QuotaResponse.java b/java/com/google/gerrit/server/quota/QuotaResponse.java
new file mode 100644
index 0000000..c239aaf
--- /dev/null
+++ b/java/com/google/gerrit/server/quota/QuotaResponse.java
@@ -0,0 +1,113 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.quota;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Streams;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@AutoValue
+public abstract class QuotaResponse {
+  public enum Status {
+    /** The quota requests succeeded. */
+    OK,
+
+    /**
+     * The quota succeeded, but was a no-op because the plugin does not enforce this quota group
+     * (equivalent to OK, but relevant for debugging).
+     */
+    NO_OP,
+
+    /**
+     * The requested quota could not be allocated. This status code is not used to indicate
+     * processing failures as these are propagated as {@code RuntimeException}s.
+     */
+    ERROR;
+
+    public boolean isOk() {
+      return this == OK;
+    }
+
+    public boolean isError() {
+      return this == ERROR;
+    }
+  }
+
+  public static QuotaResponse ok() {
+    return new AutoValue_QuotaResponse.Builder().status(Status.OK).build();
+  }
+
+  public static QuotaResponse noOp() {
+    return new AutoValue_QuotaResponse.Builder().status(Status.NO_OP).build();
+  }
+
+  public static QuotaResponse error(String message) {
+    return new AutoValue_QuotaResponse.Builder().status(Status.ERROR).message(message).build();
+  }
+
+  public abstract Status status();
+
+  public abstract Optional<String> message();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract QuotaResponse.Builder status(Status status);
+
+    public abstract QuotaResponse.Builder message(String message);
+
+    public abstract QuotaResponse build();
+  }
+
+  @AutoValue
+  public abstract static class Aggregated {
+    protected abstract ImmutableList<QuotaResponse> responses();
+
+    public boolean hasError() {
+      return responses().stream().anyMatch(r -> r.status().isError());
+    }
+
+    public ImmutableList<QuotaResponse> all() {
+      return responses();
+    }
+
+    public ImmutableList<QuotaResponse> ok() {
+      return responses().stream().filter(r -> r.status().isOk()).collect(toImmutableList());
+    }
+
+    public ImmutableList<QuotaResponse> error() {
+      return responses().stream().filter(r -> r.status().isError()).collect(toImmutableList());
+    }
+
+    public String errorMessage() {
+      return error()
+          .stream()
+          .map(QuotaResponse::message)
+          .flatMap(Streams::stream)
+          .collect(Collectors.joining(", "));
+    }
+
+    public void throwOnError() throws QuotaException {
+      String errorMessage = errorMessage();
+      if (!Strings.isNullOrEmpty(errorMessage)) {
+        throw new QuotaException(errorMessage);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/util/http/RequestUtil.java b/java/com/google/gerrit/util/http/RequestUtil.java
index 2a359ca..2543ada 100644
--- a/java/com/google/gerrit/util/http/RequestUtil.java
+++ b/java/com/google/gerrit/util/http/RequestUtil.java
@@ -56,5 +56,38 @@
     return pathInfo;
   }
 
+  /**
+   * Trims leading '/' and 'a/'. Removes the context path, but keeps the servlet path. Removes all
+   * IDs from the rest of the URI.
+   *
+   * <p>The returned string is a good fit for cases where one wants the full context of the request
+   * without any identifiable data. For example: Logging or quota checks.
+   *
+   * <p>Examples:
+   *
+   * <ul>
+   *   <li>/a/accounts/self/detail => /accounts/detail
+   *   <li>/changes/123/revisions/current/detail => /changes/revisions/detail
+   *   <li>/changes/ => /changes
+   * </ul>
+   */
+  public static String getRestPathWithoutIds(HttpServletRequest req) {
+    String encodedPathInfo = req.getRequestURI().substring(req.getContextPath().length());
+    if (encodedPathInfo.startsWith("/")) {
+      encodedPathInfo = encodedPathInfo.substring(1);
+    }
+    if (encodedPathInfo.startsWith("a/")) {
+      encodedPathInfo = encodedPathInfo.substring(2);
+    }
+
+    String[] parts = encodedPathInfo.split("/");
+    StringBuilder result = new StringBuilder(parts.length);
+    for (int i = 0; i < parts.length; i = i + 2) {
+      result.append("/");
+      result.append(parts[i]);
+    }
+    return result.toString();
+  }
+
   private RequestUtil() {}
 }
diff --git a/javatests/com/google/gerrit/util/http/RequestUtilTest.java b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
index 0bf34e7..adda5e7 100644
--- a/javatests/com/google/gerrit/util/http/RequestUtilTest.java
+++ b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.util.http;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.util.http.RequestUtil.getEncodedPathInfo;
+import static com.google.gerrit.util.http.RequestUtil.getRestPathWithoutIds;
 
 import com.google.gerrit.testing.GerritBaseTests;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
@@ -22,36 +24,45 @@
 
 public class RequestUtilTest extends GerritBaseTests {
   @Test
-  public void emptyContextPath() {
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar")))
-        .isEqualTo("/foo/bar");
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/s", "/foo%2Fbar")))
-        .isEqualTo("/foo%2Fbar");
+  public void getEncodedPathInfo_emptyContextPath() {
+    assertThat(getEncodedPathInfo(fakeRequest("", "/s", "/foo/bar"))).isEqualTo("/foo/bar");
+    assertThat(getEncodedPathInfo(fakeRequest("", "/s", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
   }
 
   @Test
-  public void emptyServletPath() {
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/c", "/foo/bar")))
-        .isEqualTo("/foo/bar");
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("", "/c", "/foo%2Fbar")))
-        .isEqualTo("/foo%2Fbar");
+  public void getEncodedPathInfo_emptyServletPath() {
+    assertThat(getEncodedPathInfo(fakeRequest("", "/c", "/foo/bar"))).isEqualTo("/foo/bar");
+    assertThat(getEncodedPathInfo(fakeRequest("", "/c", "/foo%2Fbar"))).isEqualTo("/foo%2Fbar");
   }
 
   @Test
-  public void trailingSlashes() {
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar/")))
-        .isEqualTo("/foo/bar/");
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar///")))
-        .isEqualTo("/foo/bar/");
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar/")))
-        .isEqualTo("/foo%2Fbar/");
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar///")))
+  public void getEncodedPathInfo_trailingSlashes() {
+    assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar/"))).isEqualTo("/foo/bar/");
+    assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo/bar///"))).isEqualTo("/foo/bar/");
+    assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar/"))).isEqualTo("/foo%2Fbar/");
+    assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", "/foo%2Fbar///")))
         .isEqualTo("/foo%2Fbar/");
   }
 
   @Test
   public void emptyPathInfo() {
-    assertThat(RequestUtil.getEncodedPathInfo(fakeRequest("/c", "/s", ""))).isNull();
+    assertThat(getEncodedPathInfo(fakeRequest("/c", "/s", ""))).isNull();
+  }
+
+  @Test
+  public void getRestPathWithoutIds_emptyContextPath() {
+    assertThat(getRestPathWithoutIds(fakeRequest("", "/a/accounts", "/123/test")))
+        .isEqualTo("/accounts/test");
+    assertThat(getRestPathWithoutIds(fakeRequest("", "/accounts", "/123/test")))
+        .isEqualTo("/accounts/test");
+  }
+
+  @Test
+  public void getRestPathWithoutIds_nonEmptyContextPath() {
+    assertThat(getRestPathWithoutIds(fakeRequest("/c", "/a/accounts", "/123/test")))
+        .isEqualTo("/accounts/test");
+    assertThat(getRestPathWithoutIds(fakeRequest("/c", "/accounts", "/123/test")))
+        .isEqualTo("/accounts/test");
   }
 
   private FakeHttpServletRequest fakeRequest(