Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions docs/http-status-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# HTTP Status Code Conventions

This document defines the consistent rules for HTTP status codes across all Lens API routes.

## The Rule

**404 Not Found** is returned when:
- A **resource does not exist** (the requested entity is not configured, not registered, or fundamentally absent)
- Examples: pair not watched, API key not found, webhook subscription ID not found

**200 OK with empty result** is returned when:
- A **query matched nothing** (the resource exists and the query was valid, but no data satisfied the filters/time range)
- Examples: no price data in the requested time window, no trades in the last 24h, no pools for the given asset

**400 Bad Request** is returned when:
- Request parameters are malformed, out of range, or violate validation rules
- Examples: invalid date format, unsupported window value, negative amount

## Application per Route

### Routes returning 404 for non-existent resources

| Route | 404 Condition |
|---|---|
| `GET /price/:assetA/:assetB` | Pair not watched on the requested network |
| `GET /price/:assetA/:assetB/route` | Pair not watched on the requested network |
| `GET /price/:assetA/:assetB/depth` | Pair not watched on the requested network |
| `GET /benchmark/:asset` | Pair not watched on the requested network |
| `DELETE /admin/keys/:id` | API key ID not found |
| `DELETE /webhooks/:id` | Already 204 (idempotent deletion) |

### Routes returning 200 with empty result + reason

| Route | Empty Result Condition | Response Shape |
|---|---|---|
| `GET /basket` | One or more assets have no price data | `{ error: "No price data found for: ASSET1, ASSET2" }` **→ Changed to 200** |
| `GET /compare/:asset` | No Lens or Reflector price available | `{ lens: null, reflector: null, status: "no_data", ... }` **→ Changed to 200** |
| `GET /candles/:assetA/:assetB` | No trades in the requested time range | `{ candles: [], pairKey, interval, from, to }` |
| `GET /price/:assetA/:assetB/history` | No aggregated buckets for the window | `{ buckets: [], pairKey, window }` |
| `GET /spreads/:asset` | No venue quoted the asset in the window | `{ venues: [], tightest: null, ... }` |
| `GET /volumes/:asset` | No trades for the asset in the window | `{ totalVolume: 0, byVenue: {}, venues: [], tradeCount: 0, ... }` |
| `GET /screener` | No pairs matched the filters | `{ data: [], nextCursor: null, hasMore: false, total: null }` |
| `GET /pools` | No pools for the network | `{ pools: [] }` |
| `GET /pairs` | No watched pairs (configuration issue) | `{ pairs: [] }` |

### Rationale

The distinction preserves semantic information that a single status code cannot carry:

- **404** tells the client "you asked for something that cannot exist — check your configuration or endpoint"
- **200 + empty** tells the client "your request was valid but there is no data right now — try again later or widen your filters"

A client that retries a 404 without changing its request is wasting resources. A client that retries a 200-empty after waiting or adjusting filters is behaving correctly.

### Migration notes

Routes that **changed** from 404 to 200-empty:
- `/basket` — was 404 when an asset had no price; now 200 with `{ error: ... }` listing the missing assets
- `/compare/:asset` — was 404 when both sources were null; now 200 with `status: "no_data"`

Routes that were **already correct** and unchanged:
- `/candles`, `/price/*/history`, `/spreads`, `/volumes`, `/screener`, `/pools`, `/pairs` — all return 200 with empty arrays
- `/price`, `/price/*/route`, `/price/*/depth`, `/benchmark` — all return 404 for unwatched pairs
12 changes: 4 additions & 8 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,7 @@
"get": {
"operationId": "compareAsset",
"summary": "Compare Lens prices with every configured external oracle",
"description": "Returns 200 even when both Lens and all configured oracles have no data for the asset. The `status` field distinguishes \"no_data\" from \"aligned\", \"minor_deviation\", \"major_deviation\" and \"partial\" (one source only). The response includes both the legacy `reflector` field for backward compatibility and an `oracles` array listing every configured source.",
"security": [],
"parameters": [
{
Expand All @@ -1391,7 +1392,7 @@
],
"responses": {
"200": {
"description": "Price comparison",
"description": "Price comparison (includes status:\"no_data\" when both sources are null)",
"headers": {
"Cache-Control": {
"$ref": "#/components/headers/CacheControl"
Expand All @@ -1405,9 +1406,6 @@
}
}
},
"404": {
"description": "No price data found for the asset"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
}
Expand Down Expand Up @@ -1605,6 +1603,7 @@
"get": {
"operationId": "getBasket",
"summary": "Compute the weighted price of a basket of assets",
"description": "Returns 200 even when one or more assets have no price data. The response body will contain an `error` field listing the missing assets and `reason: \"missing_price_data\"` rather than computing a partial basket price. A 404 would imply the assets themselves are invalid.",
"security": [],
"parameters": [
{
Expand Down Expand Up @@ -1646,7 +1645,7 @@
],
"responses": {
"200": {
"description": "Basket price",
"description": "Basket price or error with reason when price data is missing",
"headers": {
"Cache-Control": {
"$ref": "#/components/headers/CacheControl"
Expand All @@ -1663,9 +1662,6 @@
"400": {
"description": "Missing assets or mismatched/invalid weights"
},
"404": {
"description": "No price data for one or more assets"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
}
Expand Down
19 changes: 13 additions & 6 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,12 @@ paths:
get:
operationId: compareAsset
summary: Compare Lens prices with every configured external oracle
description: >-
Returns 200 even when both Lens and all configured oracles have no data
for the asset. The `status` field distinguishes "no_data" from "aligned",
"minor_deviation", "major_deviation" and "partial" (one source only).
The response includes both the legacy `reflector` field for backward
compatibility and an `oracles` array listing every configured source.
security: []
parameters:
- name: asset
Expand All @@ -907,16 +913,14 @@ paths:
- $ref: '#/components/parameters/NetworkQuery'
responses:
'200':
description: Price comparison
description: Price comparison (includes status:"no_data" when both sources are null)
headers:
Cache-Control:
$ref: '#/components/headers/CacheControl'
content:
application/json:
schema:
$ref: '#/components/schemas/CompareResponse'
'404':
description: No price data found for the asset
'429':
$ref: '#/components/responses/TooManyRequests'

Expand Down Expand Up @@ -1041,6 +1045,11 @@ paths:
get:
operationId: getBasket
summary: Compute the weighted price of a basket of assets
description: >-
Returns 200 even when one or more assets have no price data. The
response body will contain an `error` field listing the missing assets
and `reason: "missing_price_data"` rather than computing a partial
basket price. A 404 would imply the assets themselves are invalid.
security: []
parameters:
- name: asset
Expand All @@ -1067,7 +1076,7 @@ paths:
example: [0.6, 0.4]
responses:
'200':
description: Basket price
description: Basket price or error with reason when price data is missing
headers:
Cache-Control:
$ref: '#/components/headers/CacheControl'
Expand All @@ -1077,8 +1086,6 @@ paths:
$ref: '#/components/schemas/BasketResponse'
'400':
description: Missing assets or mismatched/invalid weights
'404':
description: No price data for one or more assets
'429':
$ref: '#/components/responses/TooManyRequests'

Expand Down
9 changes: 7 additions & 2 deletions src/__tests__/basket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ describe('GET /basket', () => {
await app.close()
})

it('returns 404 when an asset has no price data', async () => {
it('returns 200 with error and reason when an asset has no price data', async () => {
mockQuery.mockImplementation(async (_sql: string, params: string[]) => {
const asset = params[0]
if (asset === 'USDC') return { rows: [{ vwap: '1.0' }] }
Expand All @@ -143,7 +143,12 @@ describe('GET /basket', () => {
method: 'GET',
url: '/basket?asset=USDC&weight=1&asset=UNKNOWN&weight=1',
})
expect(res.statusCode).toBe(404)
// Changed from 404 to 200 per issue #262: the asset code is valid, the
// query succeeded, there is just no data. A 404 would imply the endpoint
// or the asset itself doesn't exist.
expect(res.statusCode).toBe(200)
expect(res.json().error).toMatch(/UNKNOWN/)
expect(res.json().reason).toBe('missing_price_data')
expect(res.json().missingAssets).toContain('UNKNOWN')
})
})
4 changes: 3 additions & 1 deletion src/api/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,12 @@ export async function registerRESTRoutes(app: FastifyInstance) {
async (req, reply) => {
const { assetA, assetB } = req.params
const amount = parseFloat(req.query.amount ?? '1000')
// Validate amount before the pair check: a malformed request parameter
// is a 400 regardless of whether the pair is watched.
if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' })
const network = req.network ?? activeNetwork
const pair = findPair(assetA, assetB, network)
if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` })
if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' })

return getBestRoute(pair.assetA, pair.assetB, pair.pairKey, amount, network)
}
Expand Down
9 changes: 8 additions & 1 deletion src/routes/basket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,14 @@ export async function registerBasketRoutes(app: FastifyInstance) {

const missingAssets = components.filter((c) => c.price === null).map((c) => c.asset)
if (missingAssets.length > 0) {
return reply.status(404).send({ error: `No price data found for: ${missingAssets.join(', ')}` })
// 200 with reason: query was valid but no price data exists (yet).
// A 404 would imply the assets themselves are invalid/unwatched.
return {
error: `No price data found for: ${missingAssets.join(', ')}`,
reason: 'missing_price_data',
missingAssets,
requestedAt: new Date().toISOString(),
}
}

const basketPrice = components.reduce(
Expand Down
15 changes: 14 additions & 1 deletion src/routes/oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,21 @@ export async function registerOracleRoutes(
const network: NetworkName = requestedNetwork ?? activeNetwork

const comparison = await compareOracles(assetCode, network, adapters)
// CHANGE FROM UPSTREAM (#262): Return 200 with status:"no_data" instead of 404
// when both Lens and all oracles have no data. The asset code itself is valid,
// the query succeeded — there is just no data yet. A 404 would imply the asset
// is invalid or the endpoint does not exist.
if (!comparison) {
return reply.status(404).send({ error: `No price data found for asset ${assetCode}` })
return {
asset: assetCode,
network,
lens: null,
reflector: null,
deviationPct: null,
status: 'no_data',
fetchedAt: new Date().toISOString(),
oracles: [],
}
}
return comparison
}
Expand Down
Loading