# 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](https://api.genomicintelligence.ai/redoc); for how to handle each error, see [Errors](./errors.md).

## 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`](https://api.genomicintelligence.ai/v1/openapi.json); per-model context windows (`context_window_bp`) are served by [`GET /v1/tasks/{task}/models`](../tasks.md#model-selection).

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`](../tasks.md#model-selection) 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 `g0-expression-8192` and for sliding-window models). Expression models also carry **`recommended_flank_bp`**, how much sequence to send on each side of the TSS (see [below](#expression-sequence-length-and-tss_index)). 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](#hard-sync-cap)) |
| Expression: `tss_index` missing on a sequence that is not exactly 9,198 bp, or the TSS closer than 4,599 bp to either end | `422 validation_failed`, `loc` `["body"]` (see [Expression](#expression-sequence-length-and-tss_index)) |
| Expression: too little real (non-`N`, non-repeat) sequence on one side of the TSS | `422 validation_failed` (see [Windows with too little real sequence](#windows-with-too-little-real-sequence)) |
| Splice response would carry more than 20,000 sites | `422 validation_failed` (see [Response size](#response-size-the-splice-site-cap)) |
| Unknown `{task}` segment | `404 not_found` |
| Unsupported `?format=` value | `415 unsupported_format` — never a silent fallback to JSON |

<a id="expression-the-9198-bp-window-and-tss_index"></a>

### Expression: sequence length and `tss_index`

Every expression model takes the same request body and the same length rule:

- Submit **at least 9,198 bp**, with the TSS **at least 4,599 bp from each end** of the whitespace-stripped sequence. The maximum is 500,000 bp.
- **`tss_index` is required unless the sequence is exactly 9,198 bp**, where it defaults to `4599`, the only legal value at that length.
- There is no `tes_index`. The body is closed, so sending one is a `422`.

9,198 bp is a **floor, not a target**. Longer TSS-centred sequence is better: the server cuts what the model reads out of whatever you submit, around `tss_index`, so extra flank costs one string slice and no async threshold applies. How much to fetch is published per model as **`bio_spec.recommended_flank_bp`** by [`GET /v1/tasks/expression/models`](../tasks.md#model-selection) (public, no key). Never hard-code a window, a flank or a model id; the list changes as models are added:

1. **Model.** Use the one your user named; otherwise the listing's `default_model`. Resolve it once per run and pass it as `model` on every call, so a ref/alt pair or a screen never straddles a change of default.
2. **Flank.** `F` = the largest `recommended_flank_bp` among the listed models, so one fetch serves any of them (flank beyond what a model reads is ignored). Never below 4,599, and capped so `2F` is within `request_max_bp`.
3. **Fetch** gene-sense with the TSS at offset `F` (table below), and send `tss_index = F`. Near a chromosome end, use the largest symmetric `F` that fits, down to 4,599, and say the flank was shortened.

Mind the strand when you turn that into genomic coordinates. With `tss` the **0-based** genomic position of the TSS base (subtract 1 from a 1-based coordinate such as a GTF's or Ensembl's), fetch:

| Gene strand | Genomic interval to fetch (0-based) | Then |
|---|---|---|
| `+` | `[tss − F, tss + F)` | send as is |
| `−` | `(tss − F, tss + F]`, i.e. `[tss − F + 1, tss + F + 1)` | reverse-complement it |

The minus-strand interval is shifted by one base, not mirrored: reverse-complementing `[tss − F, tss + F)` puts the TSS at offset `F − 1`, which is still a legal `tss_index` and so scores the wrong window without an error.

How much of the submission a model reads differs by model: some read a fixed window around the TSS and ignore the rest, others read as much flank on each side as they can take. A floor-sized submission is accepted by every model, but a model that would read more then scores less than it was trained on and its number drifts. `meta.task_specific_counts.scored_window` reports what was read and `data.model` which model read it; log both rather than asserting a width.

Client-side gates written as `len(seq) != 9198` are wrong: the check is `len(seq) >= 9198` on the whitespace-stripped string, plus the TSS position rule below. There is no flag, header, or query parameter that pads or truncates a short submission.

| Submitted `sequence` | `tss_index` |
|---|---|
| exactly 9,198 bp | optional, defaults to `4599` |
| 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 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`, the half-open `[start, end]` of your submitted sequence the model read, to confirm the server read the region you meant.

#### Windows with too little real sequence

A submission that passes the length rule can still be refused with `422 validation_failed` when the sequence around the TSS is dominated by `N` or by low-complexity repeats: each model needs a minimum amount of real sequence on each side of the TSS. As a rule of thumb, at typical genomic density:

| Model | Real (non-`N`) sequence upstream of the TSS | Downstream of the TSS |
|---|---|---|
| `g0-expression` | roughly 2 kb | roughly 2.6 kb |
| `g0-expression-8192` | roughly 3.3 kb | roughly 2.6 kb |

These are not exact bp cutoffs: repeat-rich sequence needs more, and the point moves from gene to gene by a few hundred bp. The `message` says which side fell short. Ordinary reference-genome TSS windows clear it with room to spare; the usual cause is an N-masked or assembly-gap region. The fix is real sequence in place of the `N` or repeat run; for `g0-expression-8192`, more real flank also helps. (`g0-expression` always reads the same 9,198 bp around the TSS, so extra flank cannot fix it.)

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](../tasks.md#find-genes-and-predict-expression) workflow instead. It finds the genes and cuts a window around each TSS itself, so it takes no `tss_index` and its floor is annotation's 1,000 bp rather than 9,198. It scores each gene with the API's default expression model, using as much flank around each TSS as the submitted region holds, so longer regions give better predictions.

## 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`:

```json
{
  "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](./errors.md#reading-errordetails).

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.5` default 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](#expression-sequence-length-and-tss_index)) | 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`](./errors.md#notable-codes) 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`](https://api.genomicintelligence.ai/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](#expression-sequence-length-and-tss_index)) |
| 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](./errors.md)). |

The `÷ 6` burst divisor above is also published structurally as
`x-rate-limit-burst-divisor` at the root of [`/v1/openapi.json`](https://api.genomicintelligence.ai/v1/openapi.json) —
read it from there rather than hardcoding `6`.

### `RateLimit-*` headers (every authenticated response)

The application emits the IETF
[httpapi-ratelimit-headers](https://datatracker.ietf.org/doc/draft-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
`429`s:

| 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-Limit` is a **burst capacity, not your per-minute budget**. It is
  your tier's `rate_per_minute` divided by 6 (a 10-second burst), so a 300 rpm
  key advertises `RateLimit-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; use `RateLimit-Remaining` to avoid emptying the
  bucket in a burst.
- Pace at ~80% of `RateLimit-Limit` for 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 a `Retry-After` calibrated against refill time; the
  concurrency 429 uses `Retry-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](../tasks.md#genomic-variant-interpretation).

Back to the workflow: [REST API guide](../rest-api.md) · [Tasks](../tasks.md).
