Errors, HTTP status codes, and idempotency
Every API response carries an HTTP status code; the body either holds your data (on a 2xx) or an error envelope (on a 4xx or 5xx). This chapter walks through the error envelopes you will actually see, what each one means, and how to handle each in client code.
The error envelope
For every 4xx response from the API — 401, 403, 404, 422, 429
alike — the body is a JSON object with a single detail
field whose value is a plain human-readable string:
{ "detail": "Human-readable message about what went wrong." }
{
"detail": "Cannot parse the filter value '2024-13-99' as Date"
}
:::caution Gotcha
This is a deliberate deviation from FastAPI's default 422 response
shape, which would normally be
{"detail":[{"type","loc","msg","ctx"},...]} (a list
of structured records, one per offending parameter). The Algoseek
deployment flattens those into a single string before returning, so
your error parser needs only one branch. If your code was written
against "standard FastAPI" assumptions and tries to iterate
detail as a list, it will crash on the live response. Treat
detail as a string, log it verbatim, and grep for the
canonical message text when you need to switch on the failure mode.
:::
Status codes you will see in practice
| Code | What it means and how to react |
|---|---|
200 | Success. Body has your data. Read pagination.next_offset if you need to walk pages (terminal when null). |
401 | Unauthorized. The X-API-KEY header is missing, malformed, or the key has been deactivated. No retry will help; fix the credential. Body example: {"detail":"Invalid API key"}. |
403 | Forbidden. Two distinct sub-cases, distinguishable by the detail string: (a) IP not on the allow-list for this key (client must change IP or ask for the IP to be added); (b) account not authorised for this dataset (client must use a different endpoint or buy the subscription). |
404 | The path does not exist. Almost always a typo in the URL — compare against the endpoints appendix. |
422 | A parameter is malformed or rejected. Body's detail is a one-liner ("invalid date", "filter required", "column does not exist", etc.). |
429 | Per-minute quota exceeded. Sleep ~90 s before retrying; do not retry tightly. See the quotas chapter for the full explanation. |
500 | Internal server error. Retry with backoff; if persistent, send the x-amzn-requestid header value to support. |
502, 503, 504 | Gateway / upstream / timeout errors. Usually transient — retry with exponential backoff and jitter. |
Real captured examples
The following sections show actual response bodies from the intentionally-malformed probes that were used while writing this guide. The bodies are useful both as a reference for the shapes you will encounter, and as concrete examples of what each error message looks like in practice.
401 — bad key
{
"detail": "Invalid API key"
}
The body is short, almost terse. The takeaway: a 401 is
unambiguous — the credential failed. There is no benefit to retrying.
403 — forbidden dataset
{
"detail": "The account is not authorized to access the dataset US Equities Basic Shares Outstanding"
}
A useful diagnostic loop on 403 for dataset access: cross
reference the URL path against the
GET api/v1/account/my/data-access-rules response (see the
first-request chapter). Any
of the three flag-style parameters — adjusted=true,
aggregation_logic=industry_std, or the dataset variants
that come from path conventions — can route a request to a different
dataset_text_id. Match the granted text-ids against the
URL family and try the variants.
422 — malformed path parameter
{
"detail": "Cannot parse the filter value '2024-13-99' as Date"
}
The body is a single detail string identifying the bad
filter value and the field it failed to parse against. Grep for
"Cannot parse the filter value" to recognise this class of
422.
422 — nonexistent column
{
"detail": "The following columns do not exist in the dataset: ColumnThatDoesNotExist"
}
When the engine rejects a column name (because the dataset does not
have that column), the detail string lists the missing
column(s). This is the message to grep for in your diagnostics.
422 — missing required filter
{
"detail": "The filter expression for the field 'Ticker' should be specified"
}
Some endpoints require a filter on a particular column (typically
Ticker for futures and high-cardinality datasets). The
detail string names the field.
422 — date out of entitlement
{
"detail": "The date value '2006-12-31' is outside of the allowed range for this account"
}
429 — per-minute quota exceeded
The captured 429 envelope, recovered from a deliberate over-burst against a data endpoint:
{
"detail": "Per-minute request limit exceeded: 13/10 requests."
}
Content-Type: application/json
Content-Length: 63
Connection: keep-alive
Date: Fri, 08 May 2026 20:11:56 GMT
x-amzn-Remapped-Date: Fri, 08 May 2026 20:11:56 GMT
x-amzn-RequestId: 448b5ef5-0af2-46da-9b5e-48fba07bb47f
x-amzn-Remapped-Content-Length: 63
x-amzn-Remapped-Connection: keep-alive
x-ratelimit-remaining: 0
x-amz-apigw-id: dD9-AFztIAMEU9Q=
x-amzn-Remapped-Server: nginx/1.18.0 (Ubuntu)
x-ratelimit-limit: 10
X-Cache: Error from cloudfront
Via: 1.1 2c3ac5ba97d2c3d68d25c205ad2ae258.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: GRU3-P3
X-Amz-Cf-Id: cnsHIWIttPQTOUKWyqLVRZ1eCCIHMMW0FeBcVLHYMzpYW0aWBAdWpg==
Two things to notice in the response above: the headers carry
x-ratelimit-limit: 10 and x-ratelimit-remaining: 0
but no Retry-After (clients reading it
unconditionally will throw KeyError), and the body is a
single detail string of the form
"Per-minute request limit exceeded: N/10 requests.". The
~90 s sleep rather than 60 s, and the rule against polling
GET /api/v1/account/my/quotas during the cooldown, are both
explained in the quotas chapter.
Idempotency: when (and how) to retry
Because every data endpoint is a GET, every data request is
idempotent by definition: re-sending the same request can never
change server-side state. That makes a transient-error retry strategy
almost trivially safe.
Retry budget arithmetic.
For a backfill job of $N$ pages, budget your retry strategy with the worst case in mind: at five attempts per page with capped 30s exponential backoff, the worst-case wall-clock cost of a single transient error is around 60 seconds. For $N = 10,000$ pages, even a 0.1% transient rate burns 10 000 seconds (~2.8 hours) on retries alone — meaningful for a nightly batch but not for an ad-hoc query.
Don't retry at the wrong layer.
If your HTTP client and
your job orchestrator both retry on 5xx, transient errors
multiply: with a baseline 0.1% ($10^-3$) transient-error rate,
two independent retries reduce the user-visible failure rate to
$(10^-3)^3 = 10^-9$ — but only if errors are truly
independent, which they typically are not during a degraded backend,
where 5xx episodes cluster temporally. The realistic concern is the
opposite: each layer's retry budget is consumed by requests the
other layer already handled, multiplying request rate against the
per-minute quota and prolonging the cluster of failures rather than
shortening it. Pick one layer to own retry semantics.
Don't retry 429 too soon.
On a 429, sleep a fixed ~90 seconds before the first retry, then exponentially back off subsequent retries. See the quotas chapter for the full explanation.
Idempotency keys are not needed.
Some APIs require an
Idempotency-Key header to safely retry mutations; this API
exposes only GET on data endpoints, so the question does not
arise.
Always log the request id
Every 2xx response from the deployed API carries
two request-id headers, with different UUIDs:
x-amzn-RequestId— the AWS API Gateway request id, generated at the edge. This is the one Algoseek support can match against their server logs; always include it in a support ticket.x-request-id— the application-layer request id, generated by the FastAPI backend behind the gateway. Useful for cross-correlating an edge log line with a backend log line, but not primary for support.
:::caution Gotcha
The two UUIDs are not the same value. Log
x-amzn-RequestId as your primary id and include
x-request-id as a secondary field if your downstream
log schema has space for it. On 4xx responses, only
x-amzn-RequestId is reliably present (the 429 capture
in this guide, for example, omits x-request-id); plan for
both being present on 2xx and only the AWS one on
4xx/5xx.
:::
import logging
import requests
log = logging.getLogger(__name__)
class AlgoseekHTTPError(requests.HTTPError):
"""HTTPError that preserves both request-id headers across the raise."""
def __init__(self, *args, aws_rid="", app_rid="", **kw):
super().__init__(*args, **kw)
self.aws_request_id = aws_rid
self.app_request_id = app_rid
def safe_call(session, url, params):
resp = session.get(url, params=params, timeout=30)
aws_rid = resp.headers.get("x-amzn-requestid", "(none)") # case-insensitive
app_rid = resp.headers.get("x-request-id", "(none)")
if resp.status_code >= 400:
log.error("HTTP %d on %s; aws_rid=%s; app_rid=%s; body=%s",
resp.status_code, url, aws_rid, app_rid, resp.text[:300])
raise AlgoseekHTTPError(
f"{resp.status_code} {resp.reason} for url {url} "
f"(aws_rid={aws_rid}, app_rid={app_rid})",
response=resp, aws_rid=aws_rid, app_rid=app_rid)
return resp # caller decides format