<!-- Agent Datasets docs · Errors and the envelope · canonical: https://www.agentdatasets.com/docs/errors · rendered from https://www.agentdatasets.com -->

# Errors and the envelope

The response contract as a lookup: what a successful payload is shaped like, and what every failure an agent can provoke means.

Nothing on this page is written by hand. The envelope and the taxonomy below are the same generated sections that make up [Conventions](/docs/conventions), re-presented for the moment you actually need them — when a call just came back and you have to decide what to do next. Read the conventions document end to end for the reasoning; read this page to look one thing up.

Two rules carry most of the weight. **Branch on `code`, never on message text:** the code is stable and the message is written for a human reading an agent transcript. And **an empty result is not an error:** a valid entity with nothing to report is a normal success with an empty list, which is why "no rows" and "no such entity" are things you can tell apart.

## Every error code

One row per machine code, with the HTTP status the REST surface returns and the form the same failure takes over MCP.

| Code | HTTP | Exception | MCP rendering |
| --- | --- | --- | --- |
| `unauthorized` | 401 | UnauthorizedError | emitted by auth middleware before tool dispatch |
| `bad_parameter` | 422 | BadParameterError | [bad_parameter] <message> |
| `unknown_entity` | 404 | UnknownEntityError | [unknown_entity] <message> (+ Did you mean: …? when suggestions set) |
| `out_of_coverage` | 404 | OutOfCoverageError | [out_of_coverage] <message> |
| `out_of_license` | 403 | OutOfLicenseError | [out_of_license] <message> |
| `rate_limited` | 429 | RateLimitedError | emitted by auth middleware before tool dispatch (+ Retry-After) |
| `quota_exceeded` | 429 | QuotaExceededError | emitted by auth middleware before tool dispatch (+ Retry-After, reset_at) |
| `upstream_stale` | 503 | UpstreamStaleError | [upstream_stale] <message> |
| `upstream_rate_limited` | 503 | UpstreamRateLimitedError | [upstream_rate_limited] <message> (+ Retry after Ns.) |
| `unavailable` | 503 | (infra: OperationalError) | [unavailable] database unreachable — … |

## The envelope

Every payload is an `Envelope[DataT, MetaT: Meta]`
(`conventions/envelope.py`) with three standardized regions:

- **`data`** — the answer, plus the identity/echo context an agent needs to
  confirm what it got (`ticker` in canonical form, `cik`, `company_name`,
  requested `period_type`). Bounded (see below), newest-first for time-ordered
  results.
- **`meta`** — provenance uniform for the whole result. The base `Meta` carries
  `source` (a stable connector identifier, e.g. `sec_edgar_companyfacts`) and
  `as_of` (the freshest value-level `as_of` on the page; `None` when the page is
  empty). Per-tool `Meta` subclasses add the *constant* attribution fields —
  `currency`, `unit`, `delay`, `non_redistributable` — where they hold for every
  value in the result.
- **`pagination`** — a `Pagination` (`limit`, `has_more`, `next_cursor`), or
  `null` for scalar tools that return a single object rather than a bounded list.

A concrete tool declares a **named subclass** so the schema keeps a stable model
name for FastMCP output-schema and FastAPI response-model generation:

```python
class IncomeStatementsResult(Envelope[IncomeStatementsData, Meta]): ...
```

Per-value attribution (`unit`/`currency`/`as_of`/provenance such as
`accession_number`) still rides on the **individual values inside `data`** —
`meta` carries only what is constant across the result. Numeric values remain
`Decimal` serialized as **JSON strings** (exact, no float rounding; consumers
parse them), and absence is a **missing key** — never zero, never a
meaning-bearing null.

### Multi-connector canonical datasets

Some tools serve a canonical dataset assembled from more than one connector.
The first case is `corporate_actions`: `meta.source` is the stable dataset
identifier (`corporate_actions`), while each value's `source` carries connector
attribution and is the licensing authority. The #80 redistribution gate must
evaluate per-value sources for these tools. `meta.non_redistributable` is true
when any value on the page is restricted, matching the conservative direction
used by `stocks_get_financial_metrics` (#25). Typed-event models serialize
inapplicable fields as null, `FilingSummary`-style; the "absence is a missing
key" rule governs the `lines`-dict numeric-value idiom, not typed-record fields.

Example (`stocks_get_income_statements`):

```json
{
  "data": {
    "ticker": "AAPL",
    "cik": 320193,
    "company_name": "Apple Inc.",
    "period_type": "annual",
    "statements": [
      {
        "fiscal_year": 2024,
        "fiscal_period": "FY",
        "period_type": "annual",
        "period_start": "2023-10-01",
        "period_end": "2024-09-28",
        "lines": {
          "revenue": {
            "value": "391035000000.000000",
            "unit": "USD",
            "currency": "USD",
            "as_of": "2024-11-01T00:00:00Z",
            "accession_number": "0000320193-24-000123"
          }
        }
      }
    ]
  },
  "meta": { "source": "sec_edgar_companyfacts", "as_of": "2024-11-01T00:00:00Z" },
  "pagination": { "limit": 4, "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjpbLi4uXX0" }
}
```

### Partial results for batch tools

Batch tools that accept multiple entities resolve each input independently
rather than discarding the whole response when one item is unknown. Unresolved
items are reported inside `data` — for line-item search, as
`unresolved_tickers` (including suggestions when available) and
`unknown_line_items`. Even an all-unresolved call is a successful envelope with
empty results and the complete per-item report (#256).

## Errors

Every tool-surface failure an agent can provoke is one of a small, fixed set of
categories (`conventions/errors.py`), each with a stable machine `code` and an
agent-actionable message. **The same exception hierarchy feeds both surfaces:**
`conventions.mcp.tool_error_boundary` maps it to a FastMCP `ToolError`, and
`conventions.http.register_error_handlers` maps it to a REST response, so an
agent sees the same information whichever surface it called.

| Exception | `code` | HTTP | MCP rendering |
|---|---|---|---|
| `UnauthorizedError` | `unauthorized` | 401 | emitted by auth middleware before tool dispatch |
| `BadParameterError` | `bad_parameter` | 422 | `[bad_parameter] <message>` |
| `UnknownEntityError` | `unknown_entity` | 404 | `[unknown_entity] <message>` (+ `Did you mean: …?` when `suggestions` set) |
| `OutOfCoverageError` | `out_of_coverage` | 404 | `[out_of_coverage] <message>` |
| `OutOfLicenseError` | `out_of_license` | 403 | `[out_of_license] <message>` |
| `RateLimitedError` | `rate_limited` | 429 | emitted by auth middleware before tool dispatch (+ `Retry-After`) |
| `QuotaExceededError` | `quota_exceeded` | 429 | emitted by auth middleware before tool dispatch (+ `Retry-After`, `reset_at`) |
| `UpstreamStaleError` | `upstream_stale` | 503 | `[upstream_stale] <message>` |
| `UpstreamRateLimitedError` | `upstream_rate_limited` | 503 | `[upstream_rate_limited] <message>` (+ `Retry after Ns.`) |
| *(infra: `OperationalError`)* | `unavailable` | 503 | `[unavailable] database unreachable — …` |

Notes on the rows:

- `unauthorized`, `rate_limited`, and `quota_exceeded` are emitted by the
  API-key auth middleware on both REST and hosted streamable-HTTP MCP requests
  before the request reaches either surface's handlers. The body still uses the
  canonical `{"error": {...}}` envelope because outer middleware is not covered
  by the normal FastAPI exception handlers.
- `unknown_entity` is "well-formed identifier, nothing we hold" (wrong symbol);
  `out_of_coverage` is "entity known, but we don't ingest that *class* of data
  for it" — kept distinct so an agent can tell the two apart. `suggestions`
  (did-you-mean) lives on the base error and is reserved for entity search (#23).
- `rate_limited` is our-surface throttling (per API key). It is distinct from
  `upstream_rate_limited`, which means a data provider throttled one of our
  connectors.
- `quota_exceeded` is a per-account request budget across all owned API keys
  and attributed OAuth traffic, not a per-key burst limit. Its window is the
  UTC calendar month; `reset_at` is the first instant of the next UTC month.
  It is distinct from `rate_limited`, which controls short-lived per-key or
  per-subject request bursts.
- `upstream_rate_limited` subclasses `upstream_stale` and carries `retry_after`
  (seconds); REST also emits a `Retry-After` header, MCP appends
  `Retry after Ns.` to the tool-error line.
- `unavailable` has no exception class of its own — it is the code the REST
  handler stamps on an infrastructure `OperationalError` (database unreachable).
  It is translated for agents rather than masked because the stdio server and
  the REST app both start fine without a database, so a dead database first
  surfaces as a query-time error the agent can act on.

The REST error body is
`{"error": {code, message, suggestions?, retry_after?, reset_at?}}`
(serialized with `exclude_none`, so an error only shows the optional fields it
uses). MCP folds the **same** fields into the `ToolError` string via
`render_tool_error` — the `code` in brackets, then the message, then any
`suggestions`/`retry_after`/`reset_at` context.

Two carve-outs are deliberate:

- **FastAPI's own request-schema 422s** keep their native `{"detail": [...]}`
  shape. They are rejected by the protocol layer before our handlers ever run —
  symmetric with MCP's schema layer rejecting the same class of input.
- **A service-raised `pydantic.ValidationError` gets no handler** and 500s
  loudly. A row failing its result model is a data-integrity failure, not caller
  error; the MCP boundary re-raises `ValidationError` *before* its `ValueError`
  arm could mask it (and the server's `mask_error_details=True` keeps internals
  out of the agent context). Integrity failures stay loud.

An empty result set for a *valid* entity (e.g. no TTM rows yet) is a normal
200/success with an empty list — honest, not an error.
