> For the complete documentation index, see [llms.txt](https://docs.trover.tech/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.trover.tech/engineering/verify-2026-09-07-rate-limits-and-soldout.md).

# Verify 2026-09-07 — 429s, "sold out" badge, buy pill

**Migration:** none. No schema change.

## What was wrong

`docker logs trover-api-1` for 2026-09-06 07:00 → 2026-09-07 07:15 holds 4,995 responses with status 429. **4,994 of them are our own `@fastify/rate-limit`** ("Rate limit exceeded, retry in 2 seconds"), not OpenSea. One is a real OpenSea `rate_limited` (07:05:26Z, `/v1/nft-market/collections?mode=new`) — that is the "rate limited by opensea · retry in 1s" the leaderboard showed.

Two causes behind the 4,994:

1. **One bucket for everyone.** The API is reached through the Cloudflare tunnel, and Fastify was built without `trustProxy`, so every request's `request.ip` was the docker bridge `172.19.0.1`. All visitors shared the single 120/min bucket.
2. **A candle storm.** `DexNftCollectionTerminal` re-ran `loadChart()` on every `market_event` / `floor_update` / `candle_update` frame, and with the "all" range that is a walk of up to 50 candle pages. Peak measured: 610 candle requests in one minute on `thefirmbrokers` (12:55Z), 3,312 of them answered 429 in that hour.

The single OpenSea 429: the key is metered 120/min (`x-ratelimit-limit`) but enforced in bursts; `/collections` issues up to 24 `collectionStats` calls in one `Promise.all`, and a 429 without a reset header blocks the shared budget for one second. A request arriving in that second with no stale cache threw.

## Changes

* `apps/api/src/server.ts` — `trustProxy: true`; rate limit 600/min keyed on `cf-connecting-ip` (falls back to `request.ip`); 429 body now carries `provider: "trover"` and `retryAfterMs` like `providerReply` does.
* `packages/integrations/src/opensea.ts` — 250 ms pacing per process (4 rps burst cap); a block ≤ 3 s is waited out inline instead of thrown; a burst 429 is retried once after its block; `new OpenSeaClient(env, { reserveRemaining })` makes a client stand aside when the shared minute budget is down to the reserve. The four worker clients pass 20; the API passes nothing.
* `apps/web/src/lib/use-api-request.ts` — `fetchWithRateLimitRetry` waits `retryAfterMs` (≤ 8 s) and retries up to twice before surfacing; used by `useApiRequest`, the NFT terminal, `DexHome` (both lists) and `DexTokenTerminal`. A per-visitor 429 reads "too many requests from this connection", not "rate limited by trover".
* `DexNftCollectionTerminal.tsx` — chart refreshes coalesce to one per 2 s, and a live refresh with the "all" range re-reads only the newest candle page and merges it into the pages already walked.
* **Sold out for ten minutes.** `packages/domain` gained `presentNftLifecycle()`; the API applies it in `marketJson` and `lifecycleOf`, so every payload presents `minted_out` as `secondary` (no chip) once the last mint — the sellout moment — is older than 10 minutes. The stored `lifecycle` column is untouched, so the "ended" tab still lists those collections.
* **Buy pill** on the listings tab is a 28 px button vertically centred in the row, not stretched to the row height.

## Deploy

Frontend: Vercel on push to `main`. Backend (api, worker, nft-realtime all changed):

```
ssh trevor-server 'cd ~/trover && nohup bash -c "set -x; git pull --ff-only && docker compose -f docker-compose.yml -f docker-compose.production.yml -f docker-compose.nft-realtime.yml build api worker nft-realtime && docker compose -f docker-compose.yml -f docker-compose.production.yml -f docker-compose.nft-realtime.yml up -d --force-recreate --no-deps api worker nft-realtime; echo DEPLOY_EXIT=\$?" > ~/trover/deploy-20260907-a.log 2>&1 < /dev/null & echo launched'
```

No `run --rm migrate` step: nothing to migrate.

## Verify

```
# per-visitor buckets: real client addresses, not 172.19.0.1
docker logs --tail 2000 trover-api-1 | grep "incoming request" | grep -o '"remoteAddress":"[^"]*"' | sort | uniq -c | sort -rn | head
# 429 rate over the last hour of lines (was ~5k/day)
docker logs --tail 50000 trover-api-1 | grep -c '"statusCode":429'
# sold-out presentation: a minted-out drop whose last mint is > 10 min old must report lifecycle "secondary"
curl -s 'http://localhost:3001/v1/nft-market/trending?chain=robinhood&window=24h&limit=40&lifecycle=ended' | python3 -c 'import sys,json; [print(r["slug"], r["lifecycle"], r["soldOut"]) for r in json.load(sys.stdin)["rows"][:10]]'
```

On the site: open a busy collection with the "all" range and watch the network tab — candle requests stay at one per two seconds under a stream of sales, and the hover buy pill on the listings tab is button-height.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.trover.tech/engineering/verify-2026-09-07-rate-limits-and-soldout.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
