The File Retriever serves content from the Autonomys DSN by composing IPLD DAG-PB chunks into streamable files. It validates access, enforces moderation, queries the DAG Indexer for metadata and chunk ordering, and fetches raw objects via the Subspace Gateway using Object Mapping Indexer hints.
- Request arrives at
GET /files/:cidwith API authentication - Validate CID format and optional byte range; check moderation ban list
- Check cache: If file is cached and not bypassed (via
originControl=no-cache), return from cache - Fetch node metadata from DAG Indexer (size, filename, mime type, encoding)
- DAG Indexer miss: reconstruct metadata and chunk ordering from the DSN (see DAG Indexer fallback)
- Resolve object mappings (piece index + offset) via Object Mapping Indexer
- Batch-fetch nodes from Subspace Gateway
- Stream file (DFS traversal over chunks) or partial range; cache full responses
The DAG Indexer is the fast path for node metadata and chunk ordering, but it is not authoritative: it trails the chain, and a node it fails to index leaves a permanent gap. A miss therefore does not mean the file is absent — the object may be perfectly retrievable from the DSN.
On a miss the service rebuilds what the indexer would have provided:
- Metadata is decoded from the head node fetched via its object mapping. The CID commits to the node bytes, so type, size, name, links and upload options are authoritative; chain provenance (block, extrinsic, timestamp) is left empty because only the indexer has it.
- Chunk ordering comes from walking the DAG depth-first from the head node, which yields leaves in file order. The indexed path derives the same order in SQL, from the full path of link positions down the DAG. Rebuilt chunk lists are cached in memory so a client fetching chunk by chunk doesn't re-walk the DAG per request.
A DAG larger than DAG_INDEXER_FALLBACK_MAX_NODES is refused with
dag_too_large_for_fallback — such a file can only be served once it is indexed,
so the refusal carries no Retry-After and is cached (the verdict is deterministic
for a content-addressed CID) to stop retries re-walking thousands of nodes. The
cache honours DAG_INDEXER_FALLBACK_CHUNK_LIST_CACHE_TTL, so the verdict is
re-derived once per TTL rather than being remembered forever.
Reconstruction is bounded by DAG_INDEXER_FALLBACK_DEADLINE_MS, and that budget
covers each individual gateway fetch as well as the walk as a whole: a fetch is
given no more than the time left, because FETCH_TIMEOUT (180s, retried three
times) would otherwise let one slow call overrun the budget many times over and
outlive the caller regardless. The budget applies to rebuilding metadata too,
not only chunk lists — a single node is enough to hang for the full
FETCH_TIMEOUT × retries, and GET /files/:cid/metadata is the endpoint an
indexer gap shows up on first.
A missing head is not the only failure mode. Nodes are indexed one extrinsic at a
time, so a file's head can be indexed while nodes below it are not — the indexer
stopped mid-file, or dropped a node permanently. The indexed chunk list is then a
truncated view of the file, which would be served as a 200 carrying the wrong
bytes (an empty body, in the case where nothing under the head was indexed).
The chunk-list query therefore reports links it could not resolve rather than dropping them, and any unresolved link is treated exactly like a missing head: the chunk list is rebuilt from the DSN. So a partially indexed file is served correctly or fails honestly, never truncated.
Set DAG_INDEXER_FALLBACK_ENABLED=false to restore the previous behaviour of
failing on an indexer miss.
GET /health/dag-indexer reports the indexer's frontier: lagBlocks,
lastProcessedHeight, targetHeight and the indexer's own indexerHealthy
flag. It returns 503 once the lag exceeds DAG_INDEXER_LAG_ALERT_BLOCKS, or
whenever the indexer flags itself unhealthy, so a monitor can alert on the status
code alone.
It also reports two timestamps, which answer different questions and are both
taken from SubQuery's _metadata:
| Field | Meaning |
|---|---|
lastProcessedBlockTimestamp |
Chain timestamp of the frontier block — how stale the indexed data is. |
lastProcessedTimestamp |
Wall-clock time the indexer last processed anything — whether it is moving. |
The difference between them is what identifies a wedge. A frontier far behind
head with lastProcessedTimestamp advancing is an indexer working through a
backlog, which resolves itself; a frontier that does not move and a
lastProcessedTimestamp that does not advance is the failure seen in production,
where the frontier sat on one block for a 53-minute observation window. Alerting
on lag alone cannot tell those apart.
GET /health stays a pure liveness check and ignores indexer lag on purpose:
this service can still serve cached files and reconstruct unindexed ones, so
failing liveness would remove the only component still able to serve them.
Watch this endpoint whenever the fallback is enabled. Reconstruction makes a lagging indexer look like latency rather than errors, which is exactly how a multi-day gap can go unnoticed.
When VICTORIA_ACTIVE=true the service also emits a dag_indexer_fallback
counter tagged with outcome, which says how much traffic the gap is costing:
outcome |
Meaning |
|---|---|
metadata_rebuilt |
Node metadata reconstructed from the DSN |
chunk_list_rebuilt |
A DAG was walked to rebuild a chunk list (the expensive case) |
chunk_list_cached |
Unindexed file served from an already-rebuilt chunk list |
chunk_list_incomplete |
The head was indexed but nodes below it were not, so it was rebuilt |
failed |
Reconstruction did not produce a result |
Three fields accompany the outcome, each measuring a different thing:
| Field | Meaning |
|---|---|
nodes_walked |
Nodes visited, head included — the cost, and the quantity MAX_NODES bounds, so the two are directly comparable. 0 for a cache hit. |
chunk_count |
Leaf chunks in the resulting list — the size signal for the chunk-list cache, which is bounded in chunks, not nodes. |
unindexed_links |
Links the DAG references that the indexer had no row for. |
Keeping them separate matters for reading the numbers at all: nodes_walked once
carried the leaf count for a rebuild, the cached chunk count for a cache hit,
and the count of missing links for a partial index. Since the SDK calls into the
chunk list once per chunk request, that made a 5000-chunk download report 25M
walked nodes for a rebuild that visited 5000, while multi-level rebuilds
simultaneously under-reported by omitting inlinks.
A rising ratio of chunk_list_rebuilt to chunk_list_cached means rebuilds are
not being amortised — check the cache bounds and TTL before raising MAX_NODES.
chunk_list_incomplete is worth its own alert: it means the indexer is dropping
nodes, which is a different failure from trailing the chain.
GET /health— Returns 200 OK (liveness; ignores DAG Indexer lag by design)GET /health/dag-indexer— DAG Indexer frontier;503when degraded
-
GET /files/:cid— Stream the file- Query params:
raw=true— DisableContent-EncodingheaderoriginControl=no-cache— Bypass cache
- Headers:
Range: bytes=N-M— Request byte range (returns 206)x-origin-control: no-cache— Bypass cache
- Query params:
-
GET /files/:cid/metadata— Returns IPLD metadata for the node -
GET /files/:cid/status— Returns{ isCached: boolean } -
GET /files/:cid/partial?chunk=N— Returns raw bytes for chunk N (204 if missing)
All moderation endpoints require API authentication.
POST /moderation/:cid/ban— Ban a CIDPOST /moderation/:cid/unban— Unban a CIDGET /moderation/:cid/status— Returns{ isBanned: boolean }GET /moderation/banned?page=N&limit=M— Paginated list of banned CIDs
GET /nodes/:cid— Raw DAG-PB node bytes (decoded to PBNode)GET /nodes/:cid/ipld— Decoded IPLD node with metadata
All /files/* and /moderation/* endpoints require authentication:
- Header:
Authorization: Bearer <API_SECRET> - Query param:
?api_key=<API_SECRET>
Two-tier caching system using LRU (Least Recently Used) eviction:
- In-memory LRU cache: Fast metadata lookups, evicts oldest entries when
CACHE_MAX_SIZEis reached - SQLite persistence: Metadata stored in
$CACHE_DIR/files/files.sqlitewith TTL-based expiration - File storage: Complete files stored on disk at
$CACHE_DIR/files/with partitioned directory structure
Partial range responses (206) are not cached.
Bypass cache: Use query param originControl=no-cache or header x-origin-control: no-cache.
- Supports standard HTTP Range headers
- Determines affected chunks and slices the stream precisely
- Returns
206 Partial ContentwithContent-Rangeheader - For chunk-level access, use
GET /files/:cid/partial?chunk=N
| Header | Description |
|---|---|
Content-Type |
Inferred from filename (when not encrypted) |
Content-Disposition |
filename="<name>" when filename is available |
Content-Encoding |
deflate for compressed files (unless raw=true) |
Accept-Ranges |
bytes (advertises range support) |
x-file-origin |
cache or gateway (indicates data source) |
| Variable | Description |
|---|---|
API_SECRET |
API authentication token |
SUBSPACE_GATEWAY_URLS |
Comma-separated gateway URLs |
OBJECT_MAPPING_INDEXER_URL |
Object Mapping Indexer endpoint |
DATABASE_URL |
PostgreSQL connection (DAG Indexer DB + banned files) |
| Variable | Default | Description |
|---|---|---|
FILE_RETRIEVER_PORT |
8090 |
HTTP server port |
LOG_LEVEL |
info |
Logging verbosity |
CORS_ORIGIN |
* |
CORS allowed origins |
| Variable | Default | Description |
|---|---|---|
CACHE_DIR |
./.cache |
File cache directory |
CACHE_MAX_SIZE |
10737418240 |
Max cache size in bytes (10 GB) |
CACHE_TTL |
86400000 |
Cache TTL in milliseconds (24 hours) |
| Variable | Default | Description |
|---|---|---|
MAX_OBJECTS_PER_FETCH |
100 |
Max objects per gateway request |
MAX_SIMULTANEOUS_FETCHES |
10 |
Concurrent fetch limit |
FETCH_TIMEOUT |
180000 |
Fetch timeout in milliseconds |
| Variable | Description |
|---|---|
VICTORIA_ACTIVE |
Enable metrics (true/false) |
VICTORIA_ENDPOINT |
VictoriaMetrics endpoint |
VICTORIA_USERNAME |
Auth username |
VICTORIA_PASSWORD |
Auth password |
METRIC_ENVIRONMENT_TAG |
Environment tag for metrics |
| Service | Purpose |
|---|---|
| DAG Indexer (PostgreSQL) | Node metadata, chunk ordering, file structure |
| Object Mapping Indexer | Piece index + offset for batch fetching |
| Subspace Gateway | subspace_fetchObject RPC to retrieve encoded nodes |
| Status | Condition |
|---|---|
400 |
Invalid CID or non-streamable node type (only File nodes are streamable) |
401 |
Missing or invalid authentication |
451 |
File is banned (Unavailable For Legal Reasons) |
404 |
Neither the DAG Indexer nor the Object Mapping Indexer knows the CID |
503 |
Known to the DSN but not servable yet, or not reconstructable — retryable |
Bodies of 404/503 responses carry a machine-readable reason so callers can
pick a retry strategy instead of parsing messages:
| Reason | Status | Meaning | Retry? |
|---|---|---|---|
object_not_found |
404 |
The Object Mapping Indexer has no mapping for the CID. May still appear once its segment is archived. | Later, with long backoff |
object_not_retrievable_yet |
503 |
Mapping exists, bytes aren't available yet (e.g. segment still plotting). | Yes, per Retry-After |
object_mapping_lookup_failed |
503 |
The mapping lookup itself failed (indexer timeout/unreachable/faulting). Says nothing about the object. | Yes, per Retry-After |
dag_too_large_for_fallback |
503 |
The DAG exceeds DAG_INDEXER_FALLBACK_MAX_NODES. Only indexing resolves this, so no Retry-After is given. |
No — the verdict is cached |
dag_indexer_fallback_timed_out |
503 |
Reconstruction exceeded DAG_INDEXER_FALLBACK_DEADLINE_MS. The DSN was too slow, not necessarily unhealthy. |
Yes, per Retry-After |
The reason is delivered as {"error": "<message>", "reason": "<code>"}, from the
error middleware registered in index.ts. Express identifies an error handler by
its arity, so that handler must declare four parameters (err, req, res, next) —
with three it is silently demoted to ordinary middleware, errors bypass it, and
callers get an HTML body with no reason at all.