# Timeouts and what counts in the ledger

> Who gives up first, what happens to a slate nobody saw, and which slates the ledger reads.

Source: https://www.patterna.nl/docs/timeouts-and-the-ledger

Short version: send your deadline, report every slate you gave up on, and never retry a recommendation. The ledger counts every slate Patterna served in the arm it was served in, whether or not your user saw it. The SDKs send the deadline and report abandonment for you.

## The budgets

| Budget | Value | Where |
|---|---|---|
| SDK timeout for `Recommend` | 150 ms | Go, TypeScript and Python SDKs. `Recommend` is never retried, because every call logs an impression. Other calls retry once inside a 5 s budget. |
| SDK deadline sent to the engine | 120 ms | `X-Deadline-Ms`, inside the 150 ms timeout (`RecommendDeadline` in Go). |
| Engine serve budget | 120 ms by default | `SERVE_BUDGET` on the engine. Past it the engine answers with the editorial list, flagged `fallback: true`. |
| Target p99, from cache | 60 ms | measured at the gateway |
| Target p99, live | 250 ms | measured at the gateway; see the SLO |

The engine answers within its own budget or your deadline minus a tenth for the network, whichever is shorter. Because both are below the SDK's 150 ms, a slate the engine logs as served is a slate your client received.

## Deadline and abandonment

**A deadline.** The client sends the time it has left, in milliseconds, as the `X-Deadline-Ms` header or the `deadline_ms` body field (10 to 10,000; when both are sent, the shorter wins). The engine falls back itself rather than finishing a slate nobody will wait for.

```curl
curl -sS -X POST "https://patterna-api.orven.am/v1/recommendations" \
  -H "Authorization: Bearer $PATTERNA_SERVE_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Request-Id: 6f1c2a9e-3b7d-4c55-9a21-0d8e4f7b1c33" \
  -H "X-Deadline-Ms: 120" \
  -d '{"surface": "more_to_read", "mode": "non_profiled", "session_id": "s_8c1f…", "context": {"locale": "nl", "anchor_item_id": "doc_1042"}}'
```

**Abandon.** When your client gives up anyway (network trouble, a `5xx`, or it hid a block it had received), it tells Patterna:

```curl
curl -sS -X POST "https://patterna-api.orven.am/v1/recommendations/6f1c2a9e-3b7d-4c55-9a21-0d8e4f7b1c33/abandon" \
  -H "Authorization: Bearer $PATTERNA_SERVE_KEY"
```

The answer is `200` with `request_id`, `arm`, `fallback: true` and `client_abandoned: true`. The call is idempotent. The impression keeps its arm (intention to treat) and counts in the fallback rate. A client-minted UUID in `X-Request-Id` becomes the slate's request id, so the client knows the id even when no answer arrives. The SDKs mint it, and `RecommendWithFallback` reports the abandonment itself on a timeout, a network error or a `5xx` (`Abandon` / `AbandonSync` for your own cases).

Do not call abandon for a slate the engine answered with `shown: false`. The engine has already logged it as not shown.

## What counts where

| Case | Arm | In the ledger? | In the fallback rate? |
|---|---|---|---|
| Ranked slate, user saw it | treatment, or `none` without a holdout | yes, if treatment | no |
| Ranked slate, user never scrolled to it | treatment or `none` | yes, if treatment, with no taps | no |
| Control slate (your `context.control_item_ids`, the surface's `fallback.by_anchor` list for the anchor, or the surface's list) | control | yes | no: control is not a fallback |
| Engine fallback (past its budget, storage trouble, no active flow) | the assigned arm | yes, in that arm | yes |
| Client gave up and reported abandon | the assigned arm | yes, with no taps | yes (`client_abandoned`) |
| Client gave up and reported nothing | the assigned arm | yes, with no taps | no: this is an under-count, so report it |
| Client hid the block after hydration and reported abandon | the assigned arm | yes, with no taps | yes (`client_abandoned`) |
| Slate shorter than the surface's `min_slots`, answered with `shown: false` and no items | the assigned arm | yes, with no success, in both arms (intention to treat) | no |
| Request refused (`4xx`) | none: no impression | no | no |
| Surface in `shadow`, or `"shadow": true` | `none` | no | no |
| Surface in `testers` | `none` | no | yes, if flagged |
| No running holdout | `none` | no | yes, if flagged |
| Outside the experiment's window | as logged | no | yes, if flagged |
| `context.measured: false` | the assigned arm | no, in either arm | yes, if flagged |
| Synthetic example data | as generated | never | never |

One definition: the fallback rate is fallback slates (the engine's safety-net fallbacks plus slates the client abandoned) over non-shadow slates served. The overview's 7-day figure counts every non-shadow slate; a surface's page leaves `shown: false` slates out of both counts, because they carried no items. Next to it the surface metrics report `client_abandoned_rate` and `unmeasured_share` (slates requested with `context.measured: false`).

A `shown: false` slate counts in its arm (since engine 0.3; engine 0.2 left such slates out, and because control's list is always padded to full length and treatment's could fall short, that exclusion depended on the arm). `min_slots` is counted before the arm, from the items that pass the request's rules, so both arms hide alike. A treatment slate that comes out short is filled from the editorial list, the same list control uses; the answer and the impression say how many positions were filled in `fill_count`, and each filled slot in the logged slate (trace, export) carries `fill: true` with propensity 1.

## Why every served slate counts

Intention to treat means: once a unit is assigned to an arm, every slate served to it counts in that arm, whatever happened next. The alternative, counting only slates that were seen, sounds cleaner but is biased. If Patterna's ranking is slower than the editorial list, more treatment slates time out, and dropping them removes exactly the cases where treatment did worse. The ledger would then flatter the slower arm. Counting them as served with no taps charges the cost to the arm that caused it. The same holds for a block hidden because it had too few items: whether it is hidden by the engine (`shown: false`) or by your app (abandon), it stays in its arm.

Unmeasured slates are different: whether a user allowed analytics is decided before the arm can have any effect, and it is decided the same way in both arms. Leaving them out keeps the comparison fair and makes the rates mean something.

## What to do in your client

1. Use `RecommendWithFallback` (or the same pattern): your own list on a timeout, `5xx`, `429` or an open breaker.
2. Never retry `Recommend`. A retry logs a second impression for what the user sees as one list, and the first one counts as served with no taps.
3. With the SDKs: nothing more. They send the deadline and report abandonment. With your own client: mint `X-Request-Id`, send `X-Deadline-Ms` (or `deadline_ms`), and call abandon when you give up.
4. Report seen and taps with the slate's `request_id` and the served `position` (from 0), for both arms the same way. The engine takes kind and position from the logged slate, refuses items that were not in it (`422 unknown_item`) and accepts an optional `block` name when one slate is shown in two blocks (the ledger can then give a row per block with `by=block`).
5. If the user did not allow usage analytics and you will not report, send `context.measured: false`.
6. Set `min_slots` on the surface (`PATCH /surfaces/{key}`) to the smallest list worth showing, usually 1, and hide the block when the answer says `shown: false`. Never call abandon for such a slate. If you hide a block for another reason (hydration dropped items), report it with abandon.

## Read next

- [Proof and the ledger](https://www.patterna.nl/docs/proof-and-the-ledger)
- [Run a holdout](https://www.patterna.nl/docs/run-a-holdout)
- [Surfaces and flows](https://www.patterna.nl/docs/surfaces-and-flows)
