Skip to content

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#

EditionConcurrent connection capNotes
Community50No license token required.
Enterpriseexactly conns from the tokenOrders 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#

LimitCommunityEnterprise
Counted requests per minute (rate_per_minute)1060 × ceil(connections / 50), min 60, max 3,000
Counted requests per day (rate_per_day)200unlimited
Jobs running in parallel (parallel_jobs)1equal to the worker count (O3O_DOCBUILDER_WORKERS)
Maximum queued jobs (max_queued_jobs)101,000
Maximum input or output file size (max_file_mb)10 MB300 MB
Result retention (result_ttl_minutes)15 minutes1,440 minutes
Synchronous mode timeout (sync_timeout_seconds)60 seconds60 seconds
Maximum job duration (job_timeout_seconds)120 seconds600 seconds
Asynchronous mode (async)yesyes
Callback when a job finishes (callback)noyes
Maximum o3oscript units (max_script_units)50020,000
Maximum outputs per build (max_outputs_per_build)15

Enterprise rate by connection count#

Formula: clamp(60 * ceil(conns / 50), 60, 3000).

Connections in the tokenRequests per minute
5060
120180
200240
201300
500600
800960
2,5003,000
5,0003,000

Endpoints per edition#

EndpointCommunityEnterprise
GET /v1/statusyesyes
GET /v1/formatsyesyes
GET /v1/limitsyesyes
POST /v1/convertyesyes
GET /v1/jobs/{id}yesyes
GET /v1/files/{id}yesyes
POST /v1/buildyesyes
POST /v1/template/rendernoyes
POST /v1/extract/textyesyes
POST /v1/extract/metayesyes
POST /v1/extract/thumbnailyesyes

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 GET request, and requests rejected with 401 or 403 before 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#

HeaderContent
X-RateLimit-LimitCounted requests allowed per minute
X-RateLimit-RemainingRemaining in the current minute
X-RateLimit-ResetUnix seconds when the minute window resets
X-RateLimit-Limit-DayRequests per day; absent when the edition has no daily limit
X-RateLimit-Remaining-DayRemaining today; absent when the edition has no daily limit
Retry-AfterOnly on 429 and 503: seconds to wait
HTTPHeaders of a community-edition response
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: 184

Handling 429 and 503#

429Minute limit exceeded. 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"
  }
}
  1. Read Retry-After and wait exactly that many seconds; do not hammer.
  2. detail.window = "day": the daily quota is used up; move the work past 00:00 Vietnam time instead of holding a thread.
  3. 503 queue_full also has Retry-After: send fewer parallel requests.
  4. Watch X-RateLimit-Remaining to slow down before hitting the limit.
  5. Large or many files: use async = true, then poll GET /v1/jobs/{id} (not counted).
Calling with Retry-After aware retries
// 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#

GET/v1/limits

DocBuilder's effective limits (after lower-only variables) and current usage. Not counted against the limit.

Auth: BearerCommunityEnterprise
200Community edition example; usage numbers are illustrative.
{
  "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": []
}
GET/o3o/limits

Current 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.

Auth: noneCommunityEnterprise
200Community edition example.
{
  "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
  }
}