Limits
This page is the single owner of the API's numeric caps and rate quotas. For per-endpoint request/response schemas and the error.code list, see ReDoc; for how to handle each error, see Errors.
Input length per task
Each task carries its own minimum, published as minLength on that task's request schema and enforced at request validation — before any model is loaded. The maximum is 500,000 bp everywhere. Bounds are minLength/maxLength on each task's request schema in /v1/openapi.json; per-model context windows (context_window_bp) are served by GET /v1/tasks/{task}/models.
A task's floor is the strictest its models need — it is not per-model, and every listed model stays selectable. Sequences must contain only A/C/G/T/N (case-insensitive); oversized, undersized, or out-of-alphabet inputs are rejected with 422 validation_failed before any GPU work. Request body cap: 16 MiB.
All lengths are counted after ASCII whitespace is stripped, so a line-wrapped FASTA sequence body can be pasted verbatim (a > header line cannot — it fails the alphabet check).
That cuts both ways, and it is the one place the published schema and the server disagree in a way worth knowing: minLength/maxLength are checked by a generated client against the raw string you hand it, and by the server against the stripped one. 9,000 bases wrapped over 200 lines is 9,200 raw characters — it satisfies expression's published 9,198 minimum as a string and is still rejected once stripped. Count bases, not characters.
Since contract revision 5 the alphabet is machine-readable too: every sequence field publishes pattern as ^\s*(?:[ACGNTacgnt]\s*)+$, so a generated client enforces it without you retyping the rule. It admits lowercase and inline whitespace deliberately — that is what the server accepts, and a stricter pattern would make your client refuse sequence the API scores.
The floor is admission control, not regime
A sequence at or above the task floor but shorter than the selected model's context_window_bp is accepted and scored — against a window padded out to the context window. Nothing in the response flags this. Enhancer is the sharpest case: the bound is 50 bp, but g0-deepstarr scores 249 bp windows, so a 60 bp submission is ~75% padding.
Compare your length against context_window_bp — served per model by GET /v1/tasks/{task}/models as part of bio_spec — to know whether the model saw real sequence or padding. Longer-than-context input is never a problem: the scanner steps one prediction window at a time and pads only the final partial window.
bio_spec also carries request_max_bp (the enforced ceiling — 500,000 for every model, g0-expression included) and trained_window_bp (a fixed receptive field where one exists: 9,198 for g0-expression, null for sliding-window models). Use request_max_bp as the cap. Older responses may also carry max_seq_length_bp; it is ambiguous — the request cap for every model except g0-expression, where it is instead the trained window — so do not read it, and do not depend on it being present.
Which status you get
| Situation | Response |
|---|---|
sequence below the task floor or above 500,000 bp | 422 validation_failed (not 413), loc ["body","sequence"] |
Unknown key inside options | 422 validation_failed, loc ["body","options","<key>"], type extra_forbidden |
| Raw request body above 16 MiB | 413 payload_too_large, refused before parsing |
| Composite submitted synchronously above 50,000 bp, or annotation above 200,000 bp | 413 sync_too_large (see Hard sync cap) |
| Splice response would carry more than 20,000 sites | 422 validation_failed (see Response size) |
Unknown {task} segment | 404 not_found |
Unsupported ?format= value | 415 unsupported_format — never a silent fallback to JSON |
Expression: the 9,198 bp window and tss_index
9,198 bp is a floor, not a fixed size: the endpoint accepts up to 500,000 bp and windows server-side. What is fixed is what the model scores — exactly one 9,198 bp TSS-centered window (±4,599 bp), cut out of whatever you submit. A sequence shorter than that window is rejected with 422 validation_failed; there is no flag, header, or query parameter that restores padding or truncation. (Client-side gates written as len(seq) != 9198 are wrong — the check is len(seq) >= 9198 on the whitespace-stripped string.)
You may submit anywhere from one exact window up to a 500,000 bp locus and let the server cut the window for you:
Submitted sequence | tss_index |
|---|---|
| exactly 9,198 bp | optional — defaults to 4599, the only legal value at that length |
| 9,199–500,000 bp | required |
tss_index is the 0-based offset of the transcription start site into the whitespace-stripped sequence — not a file character offset, and not a genomic coordinate. Compute it after removing newlines, or a line-wrapped FASTA will be off by its newline count. It must satisfy 4599 <= tss_index <= len(sequence) - 4599; violations are rejected with 422, never clamped or padded. The server then scores the half-open slice sequence[tss_index - 4599 : tss_index + 4599].
Windowing happens before tokenization, so a 500,000 bp submission costs one string slice — there is no forced-async threshold on this endpoint.
The legal tss_index range is wide, so an index that is merely wrong (computed against un-stripped FASTA characters, or against a locus start rather than the submitted slice) does not error — it returns a confident 200 for the wrong window. Assert on the echoed meta.task_specific_counts.scored_window (also data.input.scored_window) to confirm the server scored the window you meant.
The endpoint does not discover the TSS for you and does not reverse-complement: submit the gene's coding (sense) strand. If you have raw genomic input where the TSS isn't known, use the composite find-genes-and-predict-expression workflow instead — it finds the genes and centres each window itself, so it takes no tss_index and its floor is annotation's 1,000 bp rather than 9,198.
Response size: the splice site cap
Input bounds admit a request; one cap bounds what a request may emit. POST /v1/tasks/splice/predict refuses any request whose response would carry more than 20,000 splice sites, with 422 validation_failed:
{
"error": {
"code": "validation_failed",
"message": "splice prediction exceeded the 20000 record response cap at threshold 0.0 on a 60000 bp sequence. Raise options.threshold and retry — a threshold at or below 0 marks nearly every token as a site regardless of the biology.",
"request_id": "a375d0cf-4ffd-4317-b3c5-fcbf4292c98e",
"details": {
"task": "splice",
"record_count": 20002,
"maximum_records": 20000,
"sequence_length": 60000,
"threshold": 0.0
}
}
}
details carries record_count, maximum_records, sequence_length and threshold, so you can compute the retry without parsing the message. Note this is the one validation_failed whose details is not the {errors: [...]} array — see Errors.
This is the only response-size cap in the API, and splice is the only task with one. It exists because splice is token-level: at a low options.threshold the site count tracks sequence length rather than biology, and every site is serialised three times (in sites, in tracks, and in the BED/GFF3 strings under formats). At threshold: 0 a 500,000 bp input would emit roughly 176,000 sites and ~64 MB.
Three things worth knowing about how it fires:
- Only a low threshold reaches it. At the
0.5default a 500,000 bp input returns a few hundred sites — the cap sits ~66x above any plausible real result. Normal traffic never sees it. - It is not admission control. The check runs while sites accumulate during inference, not before the model loads, so it costs a few seconds rather than failing instantly.
- The request is rejected, not truncated. A silently short site list would read as a complete one.
The fix is always a higher options.threshold, never a shorter sequence.
Don't use threshold: 0 — use 1e-3
If you are lowering the threshold to "get everything", set it to 1e-3, not 0. 0 marks nearly every token a site regardless of the biology, and it is what the cap exists to reject.
1e-3 is not a compromise. In our SGE splice benchmark, scoring at 1e-3 instead of 0 moved no score by more than 4.6e-4 — entirely inside the noise band you would discard anyway — and ran about 4x faster, because the bottleneck is serialising the response, not GPU inference. On a 60,000 bp input, threshold: 0 is a 422 while 1e-3 and 0.5 both return in ~2 s.
Latency
Rough sync latency at the recommended input size, on a warm model.
| Task | Sync latency at recommended size | When to go async |
|---|---|---|
| Promoter / enhancer / chromatin | 0.3–10 s | inputs above 100,000 bp |
| Splice | 0.3–10 s | inputs above 250,000 bp |
| Annotation | 1–60 s | inputs above 30,000 bp |
| Expression (see below) | 0.5–3 s | n/a — sync is always safe |
Cold start
If a task's model isn't already loaded into GPU memory, the first request
pays a model-load cost. The response carries meta.cold_start: true and
meta.model_load_time_ms. Cold start adds 5–15 s for the smaller models
and 30–90 s for expression and annotation. Subsequent calls are warm.
Sync delivery: timeout and guidance
Sync delivery (the default, no Prefer header) is best-effort within the
upstream HTTP read timeout of 300 seconds. A request that takes longer than
that is terminated by the edge proxy and surfaces to your client as a
connection reset or 504 gateway_timeout. The body in this case is the
proxy's, not the unified {error: {...}} envelope; this is the only place
where that happens. Pick async whenever you expect a request to push past
~60 s.
Hard sync cap
Two operations enforce a hard sync cap, and both return
413 sync_too_large to a synchronous request above
their threshold:
| Operation | Sync cap |
|---|---|
POST /v1/workflows/find-genes-and-predict-expression (the composite) | 50,000 bp |
POST /v1/tasks/annotation/predict | 200,000 bp |
Resend the identical body with Prefer: respond-async (as the MCP server's
find_genes_and_predict_expression and find_genes tools always do) to lift
the cap. The other five predict endpoints — promoter, splice, enhancer,
chromatin, expression — have no sync cap at all: each accepts sync up to its
per-task max (see "Input length per task" above), and none of them emits
sync_too_large.
The cap governs JSON delivery. Async jobs return JSON, so a request that
asks for ?format=bed or ?format=gff3 is not capped: annotation still serves
those synchronously above 200,000 bp, which means a large text-format request
can run long enough to hit the 300 s proxy timeout described above. Size those
calls yourself.
Both thresholds are published structurally, not just here in prose: each is
x-sync-limit-bp on its own operation in /v1/openapi.json,
so a client can read them instead of hardcoding them — and the absence of that
extension on the other five predict operations is the contract saying they are
uncapped.
Recommended async opt-in (client-side guidance, not enforced)
Use Prefer: respond-async when your input exceeds the threshold below.
These are calibrated against typical inference times. Sync still works
under them, but bursty traffic plus GPU contention can push individual
requests past the 300 s proxy window without warning.
| Task | Recommended async above |
|---|---|
| Promoter | 100,000 bp |
| Splice | 250,000 bp |
| Enhancer | 100,000 bp |
| Chromatin | 100,000 bp |
| Annotation | 30,000 bp — advisory; above 200,000 bp it becomes enforced, sync JSON delivery is 413 sync_too_large |
| Expression | n/a — sync is always safe regardless of submission size (see above) |
| Find genes + predict expression | 50,000 bp — and this one is enforced, not advisory: above it, sync delivery is 413 sync_too_large |
If sync is critical for your workload and these guidelines push more traffic to async than you'd like, contact us; we can profile your distribution and tune the proxy timeout for your account.
Per-key quotas
Two limiters run per key — concurrent in-flight requests and requests per
minute — plus a fixed cap at the edge. Caps are issued per key; your
account owner tells you the values for yours. You can also read your
current limit off the RateLimit-Limit response header (your rpm ÷ 6) on
any authenticated response — see below.
| Setting | Enforced? |
|---|---|
| Concurrent in-flight requests | Yes. Exceeding the cap returns 429 too_many_requests with Retry-After: 1. |
| Per-minute request rate | Yes. Token bucket; capacity = rate / 6 (10-second burst). An empty bucket returns 429 too_many_requests with Retry-After set to approximately the seconds until the next token. |
| Edge per-IP cap | 10 r/s burst 20 on api.*. Yes, at the edge; returns 429 too_many_requests with the unified {error: {...}} envelope (see errors). |
The ÷ 6 burst divisor above is also published structurally as
x-rate-limit-burst-divisor at the root of /v1/openapi.json —
read it from there rather than hardcoding 6.
RateLimit-* headers (every authenticated response)
The application emits the IETF
httpapi-ratelimit-headers
draft set on every authenticated 2xx and on every 429 from this
service, so you can pace from header state without inferring rate from
429s:
| Header | Meaning |
|---|---|
RateLimit-Limit | Token-bucket capacity (= rate_per_minute / 6). |
RateLimit-Remaining | Tokens left after this request, integer. |
RateLimit-Reset | Seconds until the bucket refills to full. |
RateLimit-Policy | <capacity>;w=60. The w=60 is the draft's window syntax, not a statement that capacity is your per-minute allowance — see below. |
Retry-After | On 429 only, seconds until at least one token is available. |
Pacing guidance
RateLimit-Limitis a burst capacity, not your per-minute budget. It is your tier'srate_per_minutedivided by 6 (a 10-second burst), so a 300 rpm key advertisesRateLimit-Limit: 50. Sizing a minute's work from the header alone leaves you pacing 6x slower than your tier allows. Pace sustained work against your issued rpm; useRateLimit-Remainingto avoid emptying the bucket in a burst.- Pace at ~80% of
RateLimit-Limitfor burst headroom, and serialize a small worker pool against your concurrency cap rather than firing N parallel requests at it. - The token bucket and concurrency semaphore are independent: a 429 can
come from either. Both carry the same
RateLimit-*spine, but only the rate-bucket 429 has aRetry-Aftercalibrated against refill time; the concurrency 429 usesRetry-After: 1.
Async result store
Async job results are retained for 24 h from last activity, then expire.
A fetch after the TTL (or after a service restart) returns 410 job_expired,
so retrieve results promptly and re-submit if a job has expired.
Genomic variant interpretation
This workflow takes a VCF rather than a sequence, so the bp caps above do not apply to it. Its own bounds:
| Bound | Value |
|---|---|
| Input VCF | 2 GiB maximum, and it must not be empty |
| Input | One VCF object, named directly; the key must end .vcf, .vcf.gz or .vcf.bgz |
options.window | 1–100,000 bp, default 5,000 |
options.tissues | 1–16 entries, default ["heart", "liver", "brain"] |
options.genes | Up to 1,000 HGNC symbols or Ensembl gene ids; omit to score every nearby gene |
client_ref | 128 characters |
| Delivery | Asynchronous only — Prefer: respond-async is required |
| Result retention | 24 hours, then 410. The annotated file in your bucket is not on that clock |
Uncompressed, bgzip-compressed and plain gzip-compressed VCFs are all accepted. Full contract: Tasks.
Back to the workflow: REST API guide · Tasks.