Docs/Gateway: Demand Control (#6567)

This commit is contained in:
Arda TANRIKULU 2025-03-05 18:15:03 +03:00 committed by GitHub
parent cbc79a47ca
commit 1a5010e051
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 381 additions and 65 deletions

View file

@ -177,18 +177,19 @@ class CustomLogger implements Logger {
To help with debugging and improve error understanding for consumers, Hive Gateway uses error codes
for the following specific types of errors:
| Code | Description |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GRAPHQL_PARSE_FAILED` | Sent GraphQL Operation cannot be parsed |
| `GRAPHQL_VALIDATION_FAILED` | Sent GraphQL Operation is not validated against the schema |
| `BAD_USER_INPUT` | Variable or argument values are not valid in the GraphQL parameters |
| `TIMEOUT_ERROR` | Indicates a timeout in the subgraph execution. Keep in mind that this timeout is not always an HTTP timeout or a timeout specified by you. It might be the subgraph server that timed out. Learn more about upstream reliability to configure timeout based on your needs. |
| `SCHEMA_RELOAD` | When Hive Gateway updates the schema by polling or any other way, all ongoing requests are terminated, including subscriptions and long-running defer/stream operations. In this case, this error is sent to the client to indicate a schema change. Usually, a retry is expected in this case. |
| `SHUTTING_DOWN` | When Hive Gateway is shutting down or restarting, like `SCHEMA_RELOAD`, it aborts all requests and notifies the client with this error code. After a certain amount of time, a retry can be sent. |
| `UNAUTHENTICATED` | The given auth credentials are not valid. Check the logs and documentation of the used auth plugin to learn more. |
| `PERSISTED_QUERY_NOT_FOUND` | Indicates that persisted operation information is not found in the store. Check the related persisted operation plugin docs to learn more about this error. |
| `INTERNAL_SERVER_ERROR` | Indicates that the error is unexpected or unspecified and masked by the gateway. It is probably caused by an unexpected network, connection, or other runtime error. You can see the details of this error in the logs. |
| `DOWNSTREAM_SERVICE_ERROR` | Indicates the error is subgraph-related, and generated by the subgraph, not the gateway |
| Code | Description |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GRAPHQL_PARSE_FAILED` | Sent GraphQL Operation cannot be parsed |
| `GRAPHQL_VALIDATION_FAILED` | Sent GraphQL Operation is not validated against the schema |
| `BAD_USER_INPUT` | Variable or argument values are not valid in the GraphQL parameters |
| `TIMEOUT_ERROR` | Indicates a timeout in the subgraph execution. Keep in mind that this timeout is not always an HTTP timeout or a timeout specified by you. It might be the subgraph server that timed out. Learn more about upstream reliability to configure timeout based on your needs. |
| `SCHEMA_RELOAD` | When Hive Gateway updates the schema by polling or any other way, all ongoing requests are terminated, including subscriptions and long-running defer/stream operations. In this case, this error is sent to the client to indicate a schema change. Usually, a retry is expected in this case. |
| `SHUTTING_DOWN` | When Hive Gateway is shutting down or restarting, like `SCHEMA_RELOAD`, it aborts all requests and notifies the client with this error code. After a certain amount of time, a retry can be sent. |
| `UNAUTHENTICATED` | The given auth credentials are not valid. Check the logs and documentation of the used auth plugin to learn more. |
| `PERSISTED_QUERY_NOT_FOUND` | Indicates that persisted operation information is not found in the store. Check the related persisted operation plugin docs to learn more about this error. |
| `INTERNAL_SERVER_ERROR` | Indicates that the error is unexpected or unspecified and masked by the gateway. It is probably caused by an unexpected network, connection, or other runtime error. You can see the details of this error in the logs. |
| `DOWNSTREAM_SERVICE_ERROR` | Indicates the error is subgraph-related, and generated by the subgraph, not the gateway |
| `COST_ESTIMATED_TOO_EXPENSIVE` | Indicates that the cost of the operation is too expensive and exceeds the configured limit. [See more about cost limiting (demand control)](/docs/gateway/other-features/security/demand-control) |
### Error Masking

View file

@ -3,6 +3,7 @@ export default {
cors: 'CORS',
'csrf-prevention': 'CSRF Prevention',
'rate-limiting': 'Rate Limiting',
'demand-control': 'Demand Control',
'disable-introspection': 'Introspection',
https: 'HTTPS',
'hmac-signature': 'HMAC Signature',
@ -10,7 +11,6 @@ export default {
'audit-documents': 'Audit Documents',
'block-field-suggestions': 'Block Field Suggestions',
'character-limit': 'Character Limit',
'cost-limit': 'Cost Limit',
'max-aliases': 'Max Aliases',
'max-depth': 'Max Depth',
'max-directives': 'Max Directives',

View file

@ -1,52 +0,0 @@
---
searchable: false
---
import { Callout } from '@theguild/components'
# Cost Limit
**Limit** the **complexity** of a GraphQL document by using
[GraphQL Armor](https://escape.tech/graphql-armor/docs/plugins/cost-limit)
## How to use?
```sh npm2yarn
npm install @escape.tech/graphql-armor-cost-limit
```
Then, add it to your plugins:
```ts filename="gateway.config.ts"
import {defineConfig} from '@graphql-hive/gateway';
import { costLimitPlugin } from '@escape.tech/graphql-armor-cost-limit';
export const gatewayConfig = defineConfig({
plugins: () => [
costLimitPlugin({
// Toogle the plugin | default: true
enabled: true,
// Cost allowed for the query | default: 5000
maxCost: 5000,
// Static cost of an object | default: 2
objectCost: 2,
// Static cost of a field | default: 1
scalarCost: 1,
// Factorial applied to nested operator | default: 1.5
depthCostFactor: 1.5,
// Ignore the cost of introspection queries | default: true
ignoreIntrospection: true,
// Do you want to propagate the rejection to the client? | default: true
propagateOnRejection: true,
/* Advanced options (use here on your own risk) */
// Callbacks that are ran whenever a Query is accepted
onAccept: []
// Callbacks that are ran whenever a Query is rejected
onReject: []
}),
]
});
```

View file

@ -0,0 +1,367 @@
---
searchable: false
---
import { Callout } from '@theguild/components'
# Demand Control / Cost Limit
Demand Control a.k.a. Cost Limit is a feature that allows you to limit the cost of operations that
are executed in your gateway against subgraphs. This is useful to prevent abuse of your gateway by
limiting the cost of operations that are executed.
Consumers of your gateway can send operations that are too expensive to execute, which can lead to
performance issues or even a denial of service attack. By setting a cost limit, you can prevent
these operations from being executed.
This feature allows you to customize cost limiting on both gateway and subgraph levels by using
`@cost` and `@listSize` directives specified by IBM.
[Learn more about the specification](https://ibm.github.io/graphql-specs/cost-spec.html)
## How to use?
```ts filename="gateway.config.ts"
import { defineConfig } from '@graphql-hive/gateway'
export const gatewayConfig = defineConfig({
// All of the values below are the default values
// You don't need to set them if you are happy with the defaults
demandControl: {
/**
* If you want to enforce the cost limit, set the maximum allowed cost.
*/
maxCost: 10,
/**
* The assumed maximum size of a list for fields that return lists.
*/
listSize: 0,
/**
* Cost based on the operation type.
* By default, mutations have a cost of 10, queries and subscriptions have a cost of 0.
*/
operationCost: operationType => (operationType === 'mutation' ? 10 : 0),
/**
* Cost based on the field.
* It is called for each field in the operation, and overrides the `@cost` directive.
*/
fieldCost: fieldNode => (fieldNode.name.value === 'expensiveField' ? 10 : 0),
/**
* Cost based on the type.
* It is called for return types of fields in the operation, and overrides the `@cost` directive.
*/
typeCost: type => (isCompositeType(type) ? 1 : 0),
/**
* Include extension values that provide useful information, such as the estimated cost of the operation.
*/
includeExtensionMetadata: process.env.NODE_ENV === 'development'
}
})
```
## Calculation of cost
When a query is planned by the gateway,
- For each operation, the `operationCost` function is called with the operation type. (By default
only mutations have a cost `10`)
- For each field in the operation, the `fieldCost` function is called with the field node. (By
default fields have a cost of `0`)
- For each type in the operation, the `typeCost` function is called with the type. (By default
composite types have a cost of `1`)
- The cost of the operation is the sum of the costs of the fields in the operation.
### Basic Example without any customizations
The following query with default will have a cost of `4`:
```graphql
query BookQuery {
# Query operation has cost of `0`
movie(id: 1) {
# Field `movie` returns a composite type `Movie` with cost of `1`
title # Field `title` is a leaf type with cost of `0`
author {
# Field `author` returns a composite type `Author` with cost of `1`
name # Field `name` is a leaf type with cost of `0`
}
publisher {
# Field `publisher` returns a composite type `Publisher` with cost of `1`
name # Field `name` is a leaf type with cost of `0`
address {
# Field `address` returns a composite type `Address` with cost of `1`
zipCode # Field `zipCode` is a leaf type with cost of `0`
}
}
}
}
```
## Customization cost
Even if you can set defaults and customize the cost calculation programmatically in the gateway, it
can also be customized on the subgraph level by using `@cost` and `@listSize` directives.
### `@cost` directive on the subgraph level
If you want to override the default cost calculations, you can use `@cost` directive on the subgraph
level.
```graphql
directive @cost(
"""
Assumes the cost of the annotated component
"""
weight: Int!
) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR
```
Let's say we annotated the `Address` type with a cost of `5` like below;
```graphql filename="subgraph.graphql" {20}
type Query {
book(id: ID): Book
}
type Book {
title: String
author: Author
publisher: Publisher
}
type Author {
name: String
}
type Publisher {
name: String
address: Address
}
type Address @cost(weight: 5) {
zipCode: Int!
}
extend schema @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@cost"]) {
query: Query
}
```
Then this will increase the `BookQuery`'s cost by `8`;
```graphql
query BookQuery {
# Query operation has cost of `0`
movie(id: 1) {
# Field `movie` returns a composite type `Movie` with cost of `1`
title # Field `title` is a leaf type with cost of `0`
author {
# Field `author` returns a composite type `Author` with cost of `1`
name # Field `name` is a leaf type with cost of `0`
}
publisher {
# Field `publisher` returns a composite type `Publisher` with cost of `1`
name # Field `name` is a leaf type with cost of `0`
address {
# Field `address` returns a composite type `Address` with cost of `5`
zipCode # Field `zipCode` is a leaf type with cost of `0`
}
}
}
}
```
### Handling list fields with `@listSize`
While analyzing the cost of the operation, the gateway doesn't know about the size of the lists. It
needs to be estimated by some means. You can use the `@listSize` directive to provide this
information.
```graphql
directive @listSize(
"""
Tells the gateway that the field returns a list with the specified size most of the time.
"""
assumedSize: Int
"""
Tells the gateway that the field returns a list with the size determined by the specified argument.
If multiple arguments are provided, the highest value will be used.
If `assumedSize` is also provided, this argument will take precedence.
"""
slicingArguments: [String!]
"""
In case of cursor-based pagination, this argument tells the gateway that these fields return lists with the calculated cost under each
"""
sizedFields: [String!]
"""
If `slicingArguments` is provided, this argument tells the gateway that at least one slicing argument is required.
If multiple arguments are provided, it causes an error because the gateway cannot determine the size of the list.
"""
requireOneSlicingArgument: Boolean = true
) on FIELD_DEFINITION
```
#### Assumed size with `@listSize(assumedSize:)`
We tell the gateway that the `bestsellers` field returns a list of `Book` with an assumed size of
`5`.
```graphql filename="subgraph.graphql" {4}
# The rest of the subgraph schema is the same with the example above
type Query {
book(id: ID): Book
bestsellers: [Book] @listSize(assumedSize: 5)
}
# The rest of the subgraph schema is the same with the example above
```
Then the cost of the `bestsellers` field will be calculated as `5 * cost of Book` which is `40` in
total.
```graphql
query BestsellersQuery {
bestsellers {
# Field `bestsellers` returns a list of `Book` with assumed size of `5`
title
author {
# Field `author` returns a composite type `Author` with cost of `1` but it is multiplied by `5`
name
}
publisher {
# Field `publisher` returns a composite type `Publisher` with cost of `1` but it is multiplied by `5`
name
address {
# Field `address` returns a composite type `Address` with cost of `5` but it is multiplied by `5` equals to `25`
zipCode
}
}
}
}
```
#### Estimation with arguments with `@listSize(slicingArguments:)`
If the size of the list is determined by the arguments of the field, you can use the
`slicingArguments` option. It is useful when you have paginated fields.
```graphql filename="subgraph.graphql" {5}
# The rest of the subgraph schema is the same with the example above
type Query {
book(id: ID): Book
bestsellers: [Book] @listSize(assumedSize: 5)
newestAdditions(after: ID, limit: Int!): [Book] @listSize(slicingArguments: ["limit"])
}
```
Then the gateway will receive the number of `Book` objects that will be returned by the
`newestAdditions` using the `limit` argument.
```graphql
query NewestAdditions {
# Query operation has cost of `0`
newestAdditions(limit: 3) {
# Field `newestAdditions` returns a list of `Book` with assumed size of `3`
title # Field `title` is a leaf type with cost of `0`
author {
# Field `author` returns a composite type `Author` with cost of `1` but it is multiplied by `3`
name # Field `name` is a leaf type with cost of `0`
}
publisher {
# Field `publisher` returns a composite type `Publisher` with cost of `1` but it is multiplied by `3`
name # Field `name` is a leaf type with cost of `0`
address {
# Field `address` returns a composite type `Address` with cost of `5` but it is multiplied by `3` equals to `15`
zipCode # Field `zipCode` is a leaf type with cost of `0`
}
}
}
}
```
The total cost of the query above will be `3 * 1 + 3 * 1 + 3 * 5 = 24`.
##### Multiple arguments with `@listSize(slicingArguments:, requireOneSlicingArgument:false)`
If you have multiple pagination parameters, the length of the list can be calculated by multiple
input values. You can pass multiple arguments to `slicingArguments` and the gateway will use the
highest value. In this case `requireOneSlicingArgument` should be set to `false`.
```graphql filename="subgraph.graphql" {6}
# The rest of the subgraph schema is the same with the example above
type Query {
book(id: ID): Book
bestsellers: [Book] @listSize(assumedSize: 5)
newestAdditions(first: ID, last: Int!): [Book]
@listSize(slicingArguments: ["first", "last"], requireOneSlicingArgument: false)
}
```
Here you can see the calculation of the cost which is `40` in total;
```graphql
query NewestAdditions {
# Query operation has cost of `0`
newestAdditions(first: 3, last: 5) {
# Field `newestAdditions` returns a list of `Book` with assumed size of `5` because `5` is the highest value between `3` and `5`
title # Field `title` is a leaf type with cost of `0`
author {
# Field `author` returns a composite type `Author` with cost of `1` but it is multiplied by `5`
name # Field `name` is a leaf type with cost of `0`
}
publisher {
# Field `publisher` returns a composite type `Publisher` with cost of `1` but it is multiplied by `5`
name # Field `name` is a leaf type with cost of `0`
address {
# Field `address` returns a composite type `Address` with cost of `5` but it is multiplied by `5` equals to `25`
zipCode # Field `zipCode` is a leaf type with cost of `0`
}
}
}
}
```
#### Cursor-based pagination with `@listSize(sizedFields:)`
If you have cursor-based pagination, you can use the `sizedFields` option to provide the cost of the
fields under each cursor.
```graphql filename="subgraph.graphql" {4}
# The rest of the subgraph schema is the same with the example above
type Query {
newestAdditionsByCursor(after: ID, limit: Int!): Cursor!
@listSize(slicingArguments: ["limit"], sizedFields: ["page"])
}
type Cursor {
page: [Book!] # The cost of this field is calculated with `limit` argument of the parent `newestAdditionsByCursor`
nextPage: ID
}
```
Here you can see the calculation of the cost which is `41` in total;
```graphql
query NewestAdditionsByCursor {
# Query operation has cost of `0`
newestAdditionsByCursor(limit: 5) {
# Field `newestAdditionsByCursor` returns a composite type `Cursor` with cost of `1`
page {
# Field `page` returns a list of `Book` with assumed size of `5`
title # Field `title` is a leaf type with cost of `0`
author {
# Field `author` returns a composite type `Author` with cost of `1` but it is multiplied by `5`
name # Field `name` is a leaf type with cost of `0`
}
publisher {
# Field `publisher` returns a composite type `Publisher` with cost of `1` but it is multiplied by `5`
name # Field `name` is a leaf type with cost of `0`
address {
# Field `address` returns a composite type `Address` with cost of `5` but it is multiplied by `5` equals to `25`
zipCode # Field `zipCode` is a leaf type with cost of `0`
}
}
}
nextPage
}
}
```