# 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 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](#hard-sync-cap)) |
| 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 |

### 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](../tasks.md#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`:

```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-the-9198-bp-window-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-the-9198-bp-window-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).
