Skip to content

Commit e580df9

Browse files
update Open Source Docs from Roblox internal teams
1 parent 41a3bbe commit e580df9

3 files changed

Lines changed: 290 additions & 36 deletions

File tree

content/en-us/cloud-services/data-stores/error-codes-and-limits.md

Lines changed: 270 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -478,38 +478,44 @@ Data stores are subject to both <b>experience and server-level limits</b>. Exper
478478

479479
Each experience is allowed a certain number of data store requests based on the data store type, request type, and number of concurrent users. For each data store type and request type, the limit is shared among all listed functions.
480480

481-
Note that `Class.GlobalDataStore:UpdateAsync()|UpdateAsync()` consumes from both the read and write request budgets. A single call will decrement both limits.
481+
- `Class.GlobalDataStore:UpdateAsync()|UpdateAsync()` consumes from both the read and write request budgets. A single call will decrement both limits.
482+
- Game server and Open Cloud **share** a budget; Open Cloud traffic can be throttled by in-experience usage (and vice versa). See [Control rate limits](#control-rate-limits) for further guidance.
482483

483484
<h5>Standard data stores</h5>
484485

485486
<table>
486487
<thead>
487488
<tr>
488489
<th>Request type</th>
489-
<th>Functions</th>
490-
<th>Requests per minute</th>
490+
<th>Game server API</th>
491+
<th>Open Cloud API</th>
492+
<th>Shared limits (requests per minute)</th>
491493
</tr>
492494
</thead>
493495
<tbody>
494496
<tr>
495497
<td><b>Read</b></td>
496498
<td>`Class.DataStore:GetAsync()|GetAsync()`<br></br>`Class.DataStore:GetVersionAsync()|GetVersionAsync()`<br></br>`Class.DataStore:GetVersionAtTimeAsync()|GetVersionAtTimeAsync()`<br></br>`Class.DataStore:UpdateAsync()|UpdateAsync()`</td>
497-
<td>250 + concurrentUsers × 40</td>
499+
<td>Get Data Store Entry</td>
500+
<td>300 + concurrentUsers × 40</td>
498501
</tr>
499502
<tr>
500503
<td><b>Write</b></td>
501504
<td>`Class.DataStore:SetAsync()|SetAsync()`<br></br>`Class.DataStore:IncrementAsync()|IncrementAsync()`<br></br>`Class.DataStore:UpdateAsync()|UpdateAsync()`</td>
502-
<td>250 + concurrentUsers × 20</td>
505+
<td>Create, Update, Increment Data Store Entry</td>
506+
<td>300 + concurrentUsers × 20</td>
503507
</tr>
504508
<tr>
505509
<td><b>List</b></td>
506510
<td>`Class.DataStoreService:ListDataStoresAsync()|ListDataStoresAsync()`<br></br>`Class.DataStore:ListKeysAsync()|ListKeysAsync()`<br></br>`Class.DataStore:ListVersionsAsync()|ListVersionsAsync()`</td>
507-
<td>10 + concurrentUsers × 2</td>
511+
<td>List Data Stores, List Data Store Entries, List Data Store Entry Revisions</td>
512+
<td>300 + concurrentUsers × 2</td>
508513
</tr>
509514
<tr>
510515
<td><b>Remove</b></td>
511516
<td>`Class.DataStore:RemoveAsync()|RemoveAsync()`</td>
512-
<td>100 + concurrentUsers × 40</td>
517+
<td>Delete Data Store Entry, Delete Data Store, Undelete Data Store</td>
518+
<td>300 + concurrentUsers × 40</td>
513519
</tr>
514520
</tbody>
515521
</table>
@@ -520,34 +526,272 @@ Note that `Class.GlobalDataStore:UpdateAsync()|UpdateAsync()` consumes from both
520526
<thead>
521527
<tr>
522528
<th>Request type</th>
523-
<th>Functions</th>
524-
<th>Requests per minute</th>
529+
<th>Game server API</th>
530+
<th>Open Cloud API</th>
531+
<th>Shared limits (requests per minute)</th>
525532
</tr>
526533
</thead>
527534
<tbody>
528535
<tr>
529536
<td><b>Read</b></td>
530537
<td>`Class.OrderedDataStore:GetAsync()|GetAsync()`<br></br>`Class.OrderedDataStore:UpdateAsync()|UpdateAsync()`</td>
531-
<td>250 + concurrentUsers × 40</td>
538+
<td>Get Ordered Data Store Entry</td>
539+
<td>300 + concurrentUsers × 40</td>
532540
</tr>
533541
<tr>
534542
<td><b>Write</b></td>
535543
<td>`Class.OrderedDataStore:SetAsync()|SetAsync()`<br></br>`Class.OrderedDataStore:IncrementAsync()|IncrementAsync()`<br></br>`Class.OrderedDataStore:UpdateAsync()|UpdateAsync()`</td>
536-
<td>250 + concurrentUsers × 20</td>
544+
<td>Create, Update, Increment Ordered Data Store Entry</td>
545+
<td>300 + concurrentUsers × 20</td>
537546
</tr>
538547
<tr>
539548
<td><b>List</b></td>
540549
<td>`Class.OrderedDataStore:GetSortedAsync()|GetSortedAsync()`</td>
541-
<td>100 + concurrentUsers × 2</td>
550+
<td>List Ordered Data Store Entries</td>
551+
<td>300 + concurrentUsers × 2</td>
542552
</tr>
543553
<tr>
544554
<td><b>Remove</b></td>
545555
<td>`Class.OrderedDataStore:RemoveAsync()|RemoveAsync()`</td>
546-
<td>100 + concurrentUsers × 40</td>
556+
<td>Delete Ordered Data Store Entry</td>
557+
<td>300 + concurrentUsers × 40</td>
547558
</tr>
548559
</tbody>
549560
</table>
550561

562+
<h5 id="control-rate-limits">Control rate limits</h5>
563+
564+
Because Open Cloud and game server requests are shared, it is important to independently control how much each can consume from the budget.
565+
566+
<h6>Game server</h6>
567+
568+
Individual servers have built-in limits, as described above. Use a combination of `Class.DataStoreService:SetRateLimitForRequestType()|SetRateLimitForRequestType()` and `Class.DataStoreService:GetRequestBudgetForRequestType()|GetRequestBudgetForRequestType()` to maintain fine-grained control over individual servers' contribution to the total budget.
569+
570+
<h6>Open Cloud</h6>
571+
572+
Open Cloud requests require an external rate limiting solution. We recommend one of the following approaches:
573+
574+
- (Simple) Add a short timeout after each request, especially if calling the same API in a continuous loop. Set this timeout equal to `60 / (desired budget consumption per minute)` seconds, as an upper bound. Note that this approach does not allow requests to be sent in bursts.
575+
- (Robust) Implement a local rate limiter using the leaky bucket strategy.
576+
577+
The following Node.js code samples include reference implementations.
578+
579+
<Tabs>
580+
<TabItem key = "1" label="Timeout rate limiting">
581+
582+
```js
583+
const apiKey = process.env.API_KEY;
584+
if (!apiKey) {
585+
throw new Error('The API_KEY environment variable is not set.');
586+
}
587+
588+
const apiHeaderKey = 'x-api-key';
589+
590+
const universeId = '';
591+
const dataStoreId = 'Inventory';
592+
593+
const baseUrl = 'https://apis.roblox.com/cloud/v2/';
594+
595+
// --- Per-operation rate limiting config (requests per minute) ---
596+
const LIST_RATE_PER_MIN = 60;
597+
const GET_RATE_PER_MIN = 120;
598+
const UPDATE_RATE_PER_MIN = 60;
599+
600+
// Upper-bound spacing per operation: 60 / (requests per min) seconds.
601+
const LIST_INTERVAL_MS = (60 / LIST_RATE_PER_MIN) * 1000;
602+
const GET_INTERVAL_MS = (60 / GET_RATE_PER_MIN) * 1000;
603+
const UPDATE_INTERVAL_MS = (60 / UPDATE_RATE_PER_MIN) * 1000;
604+
605+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
606+
607+
// Performs the request, then waits this operation's own interval before returning.
608+
async function throttledFetch(url, options, intervalMs) {
609+
const response = await fetch(url, options);
610+
await sleep(intervalMs);
611+
return response;
612+
}
613+
614+
async function listEntries(universe, dataStore) {
615+
const listPath = `universes/${universe}/data-stores/${dataStore}/entries`;
616+
const url = baseUrl + listPath;
617+
const response = await throttledFetch(url, {
618+
headers: { [apiHeaderKey]: apiKey }
619+
}, LIST_INTERVAL_MS);
620+
return response.json();
621+
}
622+
623+
async function getEntry(path) {
624+
const url = baseUrl + path;
625+
const response = await throttledFetch(url, {
626+
headers: { [apiHeaderKey]: apiKey }
627+
}, GET_INTERVAL_MS);
628+
return response.json();
629+
}
630+
631+
async function updateEntry(path, payload) {
632+
const url = baseUrl + path;
633+
const response = await throttledFetch(url, {
634+
method: 'PATCH',
635+
headers: {
636+
[apiHeaderKey]: apiKey,
637+
'Content-Type': 'application/json'
638+
},
639+
body: JSON.stringify(payload) // The body must be a string
640+
}, UPDATE_INTERVAL_MS);
641+
return response;
642+
}
643+
644+
(async () => {
645+
try {
646+
const entries = await listEntries(universeId, dataStoreId);
647+
648+
for (const entry of entries.dataStoreEntries) {
649+
const path = entry.path;
650+
console.log(`\nProcessing entry: ${path}`);
651+
652+
const currentData = await getEntry(path);
653+
654+
currentData.value.currency += 10;
655+
656+
const payload = { value: currentData.value };
657+
658+
const updateResponse = await updateEntry(path, payload);
659+
660+
console.log(`Status: ${updateResponse.status}`);
661+
console.log(`Response: ${await updateResponse.text()}`);
662+
}
663+
} catch (error) {
664+
console.error('An error occurred during execution:', error);
665+
}
666+
})();
667+
```
668+
669+
</TabItem>
670+
<TabItem key = "2" label="Leaky bucket rate limiting">
671+
672+
```js
673+
const apiKey = process.env.API_KEY;
674+
if (!apiKey) {
675+
throw new Error('The API_KEY environment variable is not set.');
676+
}
677+
678+
const apiHeaderKey = 'x-api-key';
679+
680+
const universeId = '';
681+
const dataStoreId = 'Inventory';
682+
683+
const baseUrl = 'https://apis.roblox.com/cloud/v2/';
684+
685+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
686+
687+
// --- Leaky bucket rate limiter ---
688+
// `capacity` : max burst size (how full the bucket can get).
689+
// `leakRatePerSec`: steady drain rate == sustained requests/sec allowed.
690+
class LeakyBucket {
691+
constructor({ capacity, leakRatePerSec }) {
692+
this.capacity = capacity;
693+
this.leakRatePerSec = leakRatePerSec;
694+
this.level = 0;
695+
this.lastLeak = Date.now();
696+
// Serialize acquisitions so concurrent callers queue fairly.
697+
this.tail = Promise.resolve();
698+
}
699+
700+
_leak() {
701+
const now = Date.now();
702+
const elapsedSec = (now - this.lastLeak) / 1000;
703+
this.level = Math.max(0, this.level - elapsedSec * this.leakRatePerSec);
704+
this.lastLeak = now;
705+
}
706+
707+
// Resolves once there is room in the bucket for one more request.
708+
acquire() {
709+
const attempt = this.tail.then(async () => {
710+
this._leak();
711+
while (this.level + 1 > this.capacity) {
712+
const overflow = this.level + 1 - this.capacity;
713+
const waitMs = (overflow / this.leakRatePerSec) * 1000;
714+
await sleep(waitMs);
715+
this._leak();
716+
}
717+
this.level += 1;
718+
});
719+
// Keep the chain alive even if a caller rejects.
720+
this.tail = attempt.catch(() => {});
721+
return attempt;
722+
}
723+
}
724+
725+
// --- One bucket per operation ---
726+
// Example: list bursts of 5 @ 1/sec, get bursts of 10 @ 2/sec, update bursts of 5 @ 1/sec.
727+
const listBucket = new LeakyBucket({ capacity: 5, leakRatePerSec: 1 });
728+
const getBucket = new LeakyBucket({ capacity: 10, leakRatePerSec: 2 });
729+
const updateBucket = new LeakyBucket({ capacity: 5, leakRatePerSec: 1 });
730+
731+
// Waits for the given bucket's capacity before each request.
732+
async function rateLimitedFetch(bucket, url, options) {
733+
await bucket.acquire();
734+
return fetch(url, options);
735+
}
736+
737+
async function listEntries(universe, dataStore) {
738+
const listPath = `universes/${universe}/data-stores/${dataStore}/entries`;
739+
const url = baseUrl + listPath;
740+
const response = await rateLimitedFetch(listBucket, url, {
741+
headers: { [apiHeaderKey]: apiKey }
742+
});
743+
return response.json();
744+
}
745+
746+
async function getEntry(path) {
747+
const url = baseUrl + path;
748+
const response = await rateLimitedFetch(getBucket, url, {
749+
headers: { [apiHeaderKey]: apiKey }
750+
});
751+
return response.json();
752+
}
753+
754+
async function updateEntry(path, payload) {
755+
const url = baseUrl + path;
756+
const response = await rateLimitedFetch(updateBucket, url, {
757+
method: 'PATCH',
758+
headers: {
759+
[apiHeaderKey]: apiKey,
760+
'Content-Type': 'application/json'
761+
},
762+
body: JSON.stringify(payload) // The body must be a string
763+
});
764+
return response;
765+
}
766+
767+
(async () => {
768+
try {
769+
const entries = await listEntries(universeId, dataStoreId);
770+
771+
for (const entry of entries.dataStoreEntries) {
772+
const path = entry.path;
773+
console.log(`\nProcessing entry: ${path}`);
774+
775+
const currentData = await getEntry(path);
776+
777+
currentData.value.currency += 10;
778+
779+
const payload = { value: currentData.value };
780+
781+
const updateResponse = await updateEntry(path, payload);
782+
783+
console.log(`Status: ${updateResponse.status}`);
784+
console.log(`Response: ${await updateResponse.text()}`);
785+
}
786+
} catch (error) {
787+
console.error('An error occurred during execution:', error);
788+
}
789+
})();
790+
```
791+
792+
</TabItem>
793+
</Tabs>
794+
551795
<h4>Server limits</h4>
552796

553797
Each server has a configurable rate limit for each request type, based on the number of players in that server. Servers receive a one-time startup burst of additional request budget when they are first created. Use `Class.DataStoreService:GetRequestBudgetForRequestType()|GetRequestBudgetForRequestType()` to confirm the number of data store requests that the current server can make at any given time.
@@ -564,7 +808,7 @@ The following **default rate limits** apply if the API is not called:
564808
<tr>
565809
<th>Request type</th>
566810
<th>`DataStoreRequestType` Enum</th>
567-
<th>Functions</th>
811+
<th>Game server API</th>
568812
<th>Requests per minute</th>
569813
</tr>
570814
</thead>
@@ -609,7 +853,7 @@ The following **default rate limits** apply if the API is not called:
609853
<tr>
610854
<th>Request type</th>
611855
<th>`DataStoreRequestType` Enum</th>
612-
<th>Functions</th>
856+
<th>Game server API</th>
613857
<th>Requests per minute</th>
614858
</tr>
615859
</thead>
@@ -721,16 +965,22 @@ Roblox examines the usage of quota associated with the key over the last 60 seco
721965
<thead>
722966
<tr>
723967
<th>Request type</th>
968+
<th>Game server API</th>
969+
<th>Open Cloud API</th>
724970
<th>Limit</th>
725971
</tr>
726972
</thead>
727973
<tbody>
728974
<tr>
729975
<td>Read</td>
976+
<td>`Class.DataStore:GetAsync()|GetAsync()`<br></br>`Class.DataStore:GetVersionAsync()|GetVersionAsync()`<br></br>`Class.DataStore:GetVersionAtTimeAsync()|GetVersionAtTimeAsync()`<br></br>`Class.DataStore:ListVersionsAsync()|ListVersionsAsync()`<br></br>`Class.DataStore:UpdateAsync()|UpdateAsync()`</td>
977+
<td>Get Data Store Entry</td>
730978
<td>25 MB per minute</td>
731979
</tr>
732980
<tr>
733981
<td>Write</td>
982+
<td>`Class.DataStore:SetAsync()|SetAsync()`<br></br>`Class.DataStore:IncrementAsync()|IncrementAsync()`<br></br>`Class.DataStore:UpdateAsync()|UpdateAsync()`<br></br>`Class.DataStore:RemoveAsync()|RemoveAsync()`</td>
983+
<td>Create, Update, Increment, Remove Data Store Entry</td>
734984
<td>4 MB per minute</td>
735985
</tr>
736986
</tbody>
@@ -739,15 +989,15 @@ Roblox examines the usage of quota associated with the key over the last 60 seco
739989
In addition to the above throughput limits, Roblox organizes data into partitions based on an internal schema. As a result, when the backend server receives a high volume of requests to the same data store, it can result in further throttling. Regardless of the cause, throttling manifests as either `DatastoreThrottled` or `KeyThrottled` errors, depending on whether the throughput limit was exceeded for a single data store or a key. These error messages apply to both ordered and standard data stores.
740990

741991
<Alert severity="info">
742-
For every request, Roblox rounds throughput up to the next kilobyte. For example, if you write 800 bytes and 1.2 KB in two requests, Roblox counts that as 3 KB total throughput (1 KB and 2 KB, respectively).
992+
For every request, Roblox rounds throughput up to the next kilobyte. For example, if you write 800 bytes and 1.2 KB in two requests, Roblox counts that as 3 KB total throughput (1 KB and 2 KB, respectively).
743993
</Alert>
744994

745995
### Storage limits
746996

747-
In the future, to provide a scalable and stable storage experience, data stores will implement an experience-level storage limit on your storage usage.
997+
To keep storage stable and scalable, data stores use a game-level limit on your storage usage.
748998

749-
This limit will be the sum of a base limit for each of your experiences and a per-user limit based on the number of lifetime users in your experience. A lifetime user is any user who has joined your experience at least once.
999+
This limit is the sum of a base limit for each game and a per-user limit based on the number of lifetime users in your game. A lifetime user is any user who has joined your game at least once.
7501000

751-
The storage limit will be calculated using the formula `Total latest version storage limit = 100 MB + 1 MB * lifetime user count`.
1001+
The storage limit is calculated using the formula `Total latest version storage limit = 500 MB + 1 MB * lifetime user count`.
7521002

753-
Any keys that you delete or replace, even if still accessible through version APIs, do not count towards your experience's storage usage. However, entire data stores deleted via the Open Cloud [`DeleteDataStore`](/cloud/reference/DataStore#Cloud_DeleteDataStore) method continue to count towards your storage usage for the duration of their 30-day processing period, until they are permanently deleted.
1003+
Any keys that you delete or replace, even they still accessible through version APIs, do not count towards your experience's storage usage. However, entire data stores deleted via the Open Cloud [`DeleteDataStore`](/cloud/reference/features/storage#Cloud_DeleteDataStore) method continue to count towards your storage usage for the duration of their 30-day processing period, until they are permanently deleted.

0 commit comments

Comments
 (0)