Platform
Plan limits
Connection and DocBuilder limits of the community and enterprise editions from plans.json, rate limit headers and how to handle 429.
On this page
Every number on this page is generated from plans.json (updated 2026-09-21), the same file the gate and DocBuilder read at run time.
Editing connections#
| Edition | Concurrent connection cap | Notes |
|---|---|---|
| Community | 50 | No license token required. |
| Enterprise | exactly conns from the token | Orders are at least 50 connections. The cap is exactly conns, even when it is below 50. Soft overage: coming soon. |
The 50 cap applies to the community edition only. With a valid enterprise token the cap is exactly the token's conns, even when that number is below 50 (for example a trial or internal key issued by O3O with conns = 10 gives a cap of 10, not 50). O3O_GATE_CONNECTION_CAP can only lower it.
Counting, the sliding peak and what happens at the cap: How connections are counted.
DocBuilder limits#
| Limit | Community | Enterprise |
|---|---|---|
Counted requests per minute (rate_per_minute) | 10 | 60 × ceil(connections / 50), min 60, max 3,000 |
Counted requests per day (rate_per_day) | 200 | unlimited |
Jobs running in parallel (parallel_jobs) | 1 | equal to the worker count (O3O_DOCBUILDER_WORKERS) |
Maximum queued jobs (max_queued_jobs) | 10 | 1,000 |
Maximum input or output file size (max_file_mb) | 10 MB | 300 MB |
Result retention (result_ttl_minutes) | 15 minutes | 1,440 minutes |
Synchronous mode timeout (sync_timeout_seconds) | 60 seconds | 60 seconds |
Maximum job duration (job_timeout_seconds) | 120 seconds | 600 seconds |
Asynchronous mode (async) | yes | yes |
Callback when a job finishes (callback) | no | yes |
Maximum o3oscript units (max_script_units) | 500 | 20,000 |
Maximum outputs per build (max_outputs_per_build) | 1 | 5 |
Enterprise rate by connection count#
Formula: clamp(60 * ceil(conns / 50), 60, 3000).
| Connections in the token | Requests per minute |
|---|---|
| 50 | 60 |
| 120 | 180 |
| 200 | 240 |
| 201 | 300 |
| 500 | 600 |
| 800 | 960 |
| 2,500 | 3,000 |
| 5,000 | 3,000 |
Endpoints per edition#
| Endpoint | Community | Enterprise |
|---|---|---|
GET /v1/status | yes | yes |
GET /v1/formats | yes | yes |
GET /v1/limits | yes | yes |
POST /v1/convert | yes | yes |
GET /v1/jobs/{id} | yes | yes |
GET /v1/files/{id} | yes | yes |
POST /v1/build | yes | yes |
POST /v1/template/render | no | yes |
POST /v1/extract/text | yes | yes |
POST /v1/extract/meta | yes | yes |
POST /v1/extract/thumbnail | yes | yes |
Endpoints outside the edition's list return 403 forbidden_feature. GET /v1/status needs no authentication.
Editor embedding#
Both editions can embed the editor and receive save callbacks. The maximum embedded document size is set by O3O_EMBED_MAX_FILE_MB (100 MB by default); the session lifetime by O3O_EMBED_SESSION_TTL_MINUTES (720 minutes by default).
What the rate limit counts#
- Limits apply to the WHOLE DocBuilder instance, not per API key.
- Counted requests:
POST /v1/convert,POST /v1/build,POST /v1/template/render,POST /v1/extract/text,POST /v1/extract/meta,POST /v1/extract/thumbnail. - Not counted: every
GETrequest, and requests rejected with401or403before queuing. - The minute window is the clock minute in UTC (fixed window).
- The day window resets at 00:00 Vietnam time (UTC+7).
Rate limit headers#
| Header | Content |
|---|---|
X-RateLimit-Limit | Counted requests allowed per minute |
X-RateLimit-Remaining | Remaining in the current minute |
X-RateLimit-Reset | Unix seconds when the minute window resets |
X-RateLimit-Limit-Day | Requests per day; absent when the edition has no daily limit |
X-RateLimit-Remaining-Day | Remaining today; absent when the edition has no daily limit |
Retry-After | Only on 429 and 503: seconds to wait |
HTTP/1.1 200 OK
X-O3O-Request-Id: req_0123456789abcdef
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 7
X-RateLimit-Reset: 1790000060
X-RateLimit-Limit-Day: 200
X-RateLimit-Remaining-Day: 184Handling 429 and 503#
detail.window is minute or day; the Retry-After header is included.{
"error": {
"code": "rate_limited",
"message": "Đã vượt hạn mức số yêu cầu mỗi phút.",
"detail": {
"window": "minute"
},
"request_id": "req_0123456789abcdef"
}
}- Read
Retry-Afterand wait exactly that many seconds; do not hammer. detail.window = "day": the daily quota is used up; move the work past 00:00 Vietnam time instead of holding a thread.503 queue_fullalso hasRetry-After: send fewer parallel requests.- Watch
X-RateLimit-Remainingto slow down before hitting the limit. - Large or many files: use
async = true, then pollGET /v1/jobs/{id}(not counted).
// Node.js 18+, .mjs file
import { readFile } from "node:fs/promises";
export async function callWithRetry(url, options, maxAttempts = 5) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(url, options);
if ((res.status !== 429 && res.status !== 503) || attempt >= maxAttempts) return res;
if (res.status === 429) {
const body = await res.clone().json().catch(() => null);
if (body?.error?.detail?.window === "day") return res; // daily quota used up: do not wait in this thread, reschedule after 00:00 Vietnam time
}
const retryAfter = res.headers.get("Retry-After");
const wait = retryAfter !== null ? Number(retryAfter) : 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}
const form = new FormData();
form.append("file", new Blob([await readFile("bao-cao.docx")]), "bao-cao.docx"); // read as bytes so the upload can be resent on retry
form.append("to", "pdf");
const res = await callWithRetry("http://localhost:8080/v1/convert", {
method: "POST",
headers: { Authorization: "Bearer O3O_DEMO_KEY" },
body: form
});# pip install requests
import time
import requests
def call_with_retry(method: str, url: str, max_attempts: int = 5, **kwargs) -> requests.Response:
for attempt in range(1, max_attempts + 1):
r = requests.request(method, url, timeout=120, **kwargs)
if r.status_code not in (429, 503) or attempt == max_attempts:
return r
if r.status_code == 429 and r.json()["error"]["detail"].get("window") == "day":
return r # daily quota used up: do not wait in this thread, reschedule after 00:00 Vietnam time
time.sleep(int(r.headers.get("Retry-After", 2 ** attempt)))
return r
with open("bao-cao.docx", "rb") as fh:
data = fh.read() # read as bytes so the upload can be resent on retry
r = call_with_retry(
"POST", "http://localhost:8080/v1/convert",
headers={"Authorization": "Bearer O3O_DEMO_KEY"},
files={"file": ("bao-cao.docx", data)},
data={"to": "pdf"},
)using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
byte[] data = await File.ReadAllBytesAsync("bao-cao.docx");
HttpResponseMessage res;
for (int attempt = 1; ; attempt++)
{
using var form = new MultipartFormDataContent(); // build a new form for every attempt
form.Add(new ByteArrayContent(data), "file", "bao-cao.docx");
form.Add(new StringContent("pdf"), "to");
res = await http.PostAsync("http://localhost:8080/v1/convert", form);
bool retryable = res.StatusCode == HttpStatusCode.TooManyRequests || res.StatusCode == HttpStatusCode.ServiceUnavailable;
if (!retryable || attempt >= 5) break;
if (res.StatusCode == HttpStatusCode.TooManyRequests)
{
using JsonDocument err = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (err.RootElement.GetProperty("error").GetProperty("detail").TryGetProperty("window", out JsonElement w) &&
w.GetString() == "day") break; // daily quota used up: do not wait in this thread, reschedule after 00:00 Vietnam time
}
TimeSpan wait = res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, attempt));
await Task.Delay(wait);
}Reading the limits in force#
/v1/limitsDocBuilder's effective limits (after lower-only variables) and current usage. Not counted against the limit.
{
"edition": "community",
"limits": {
"rate_per_minute": 10,
"rate_per_day": 200,
"parallel_jobs": 1,
"max_queued_jobs": 10,
"max_file_mb": 10,
"result_ttl_minutes": 15,
"sync_timeout_seconds": 60,
"job_timeout_seconds": 120,
"async": true,
"callback": false,
"max_script_units": 500,
"max_outputs_per_build": 1
},
"usage": {
"minute": {
"limit": 10,
"used": 3,
"remaining": 7,
"reset_at": "2026-09-21T10:01:00Z"
},
"day": {
"limit": 200,
"used": 16,
"remaining": 184,
"reset_at": "2026-09-21T17:00:00Z"
},
"running_jobs": 0,
"queued_jobs": 0
},
"allowed_endpoints": [
"GET /v1/status",
"GET /v1/formats",
"GET /v1/limits",
"POST /v1/convert",
"GET /v1/jobs/{id}",
"GET /v1/files/{id}",
"POST /v1/build",
"POST /v1/extract/text",
"POST /v1/extract/meta",
"POST /v1/extract/thumbnail"
],
"features": []
}/o3o/limitsCurrent edition limits as seen by the gate: connections, embedding and DocBuilder. No authentication. For the enterprise edition docbuilder.parallel_jobs is null; the real figure is in GET /v1/limits.
{
"edition": "community",
"connections": {
"limit": 50,
"plan_limit": 50,
"cap": null,
"min_per_order": 50
},
"whitelabel": false,
"features": [],
"editor_embed": {
"enabled": true,
"save_callback": true,
"max_file_mb": 100,
"session_ttl_minutes": 720
},
"docbuilder": {
"allowed_endpoints": [
"GET /v1/status",
"GET /v1/formats",
"GET /v1/limits",
"POST /v1/convert",
"GET /v1/jobs/{id}",
"GET /v1/files/{id}",
"POST /v1/build",
"POST /v1/extract/text",
"POST /v1/extract/meta",
"POST /v1/extract/thumbnail"
],
"rate_per_minute": 10,
"rate_per_day": 200,
"parallel_jobs": 1,
"max_queued_jobs": 10,
"max_file_mb": 10,
"result_ttl_minutes": 15,
"sync_timeout_seconds": 60,
"job_timeout_seconds": 120,
"async": true,
"callback": false,
"max_script_units": 500,
"max_outputs_per_build": 1
},
"counting": {
"sample_interval_seconds": 10,
"peak_window_seconds": 300,
"reconnect_grace_seconds": 120
}
}