Skip to content

Commit 338885f

Browse files
authored
fix: prevent duplicate trustless-gateway reqs (#503)
Fixes ipfs/service-worker-gateway#104 This PR fixes issues brought up in service-worker-gateway where sub-resources end up causing multiple requests to a trustless gateway for the root CID.
1 parent 97fb1a7 commit 338885f

File tree

4 files changed

+105
-19
lines changed

4 files changed

+105
-19
lines changed

packages/block-brokers/.aegir.js

+11
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@ const options = {
1717
})
1818
res.end(Uint8Array.from([0, 1, 2, 0]))
1919
})
20+
server.all('/ipfs/bafkqabtimvwgy3yk', async (req, res) => {
21+
// delay the response
22+
await new Promise((resolve) => setTimeout(resolve, 500))
23+
24+
res.writeHead(200, {
25+
'content-type': 'application/octet-stream',
26+
'content-length': 5
27+
})
28+
// "hello"
29+
res.end(Uint8Array.from([104, 101, 108, 108, 111]))
30+
})
2031

2132
await server.listen()
2233
const { port } = server.server.address()

packages/block-brokers/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"@libp2p/logger": "^4.0.7",
7171
"@libp2p/peer-id-factory": "^4.0.7",
7272
"@multiformats/uri-to-multiaddr": "^8.0.0",
73+
"@types/polka": "^0.5.7",
7374
"@types/sinon": "^17.0.3",
7475
"aegir": "^42.2.5",
7576
"cors": "^2.8.5",

packages/block-brokers/src/trustless-gateway/trustless-gateway.ts

+63-18
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
import { base64 } from 'multiformats/bases/base64'
12
import type { ComponentLogger, Logger } from '@libp2p/interface'
23
import type { CID } from 'multiformats/cid'
34

5+
export interface TrustlessGatewayStats {
6+
attempts: number
7+
errors: number
8+
invalidBlocks: number
9+
successes: number
10+
pendingResponses?: number
11+
}
12+
413
/**
514
* A `TrustlessGateway` keeps track of the number of attempts, errors, and
615
* successes for a given gateway url so that we can prioritize gateways that
@@ -37,13 +46,34 @@ export class TrustlessGateway {
3746
*/
3847
#successes = 0
3948

49+
/**
50+
* A map of pending responses for this gateway. This is used to ensure that
51+
* only one request per CID is made to a given gateway at a time, and that we
52+
* don't make multiple in-flight requests for the same CID to the same gateway.
53+
*/
54+
#pendingResponses = new Map<string, Promise<Uint8Array>>()
55+
4056
private readonly log: Logger
4157

4258
constructor (url: URL | string, logger: ComponentLogger) {
4359
this.url = url instanceof URL ? url : new URL(url)
4460
this.log = logger.forComponent(`helia:trustless-gateway-block-broker:${this.url.hostname}`)
4561
}
4662

63+
/**
64+
* This function returns a unique string for the multihash.bytes of the CID.
65+
*
66+
* Some useful resources for why this is needed can be found using the links below:
67+
*
68+
* - https://github.com/ipfs/helia/pull/503#discussion_r1572451331
69+
* - https://github.com/ipfs/kubo/issues/6815
70+
* - https://www.notion.so/pl-strflt/Handling-ambiguity-around-CIDs-9d5e14f6516f438980b01ef188efe15d#d9d45cd1ed8b4d349b96285de4aed5ab
71+
*/
72+
#uniqueBlockId (cid: CID): string {
73+
const multihashBytes = cid.multihash.bytes
74+
return base64.encode(multihashBytes)
75+
}
76+
4777
/**
4878
* Fetch a raw block from `this.url` following the specification defined at
4979
* https://specs.ipfs.tech/http-gateways/trustless-gateway/
@@ -60,26 +90,29 @@ export class TrustlessGateway {
6090
throw new Error(`Signal to fetch raw block for CID ${cid} from gateway ${this.url} was aborted prior to fetch`)
6191
}
6292

93+
const blockId = this.#uniqueBlockId(cid)
6394
try {
64-
this.#attempts++
65-
const res = await fetch(gwUrl.toString(), {
66-
signal,
67-
headers: {
68-
// also set header, just in case ?format= is filtered out by some
69-
// reverse proxy
70-
Accept: 'application/vnd.ipld.raw'
71-
},
72-
cache: 'force-cache'
73-
})
74-
75-
this.log('GET %s %d', gwUrl, res.status)
76-
77-
if (!res.ok) {
78-
this.#errors++
79-
throw new Error(`unable to fetch raw block for CID ${cid} from gateway ${this.url}`)
95+
let pendingResponse: Promise<Uint8Array> | undefined = this.#pendingResponses.get(blockId)
96+
if (pendingResponse == null) {
97+
this.#attempts++
98+
pendingResponse = fetch(gwUrl.toString(), {
99+
signal,
100+
headers: {
101+
Accept: 'application/vnd.ipld.raw'
102+
},
103+
cache: 'force-cache'
104+
}).then(async (res) => {
105+
this.log('GET %s %d', gwUrl, res.status)
106+
if (!res.ok) {
107+
this.#errors++
108+
throw new Error(`unable to fetch raw block for CID ${cid} from gateway ${this.url}`)
109+
}
110+
this.#successes++
111+
return new Uint8Array(await res.arrayBuffer())
112+
})
113+
this.#pendingResponses.set(blockId, pendingResponse)
80114
}
81-
this.#successes++
82-
return new Uint8Array(await res.arrayBuffer())
115+
return await pendingResponse
83116
} catch (cause) {
84117
// @ts-expect-error - TS thinks signal?.aborted can only be false now
85118
// because it was checked for true above.
@@ -88,6 +121,8 @@ export class TrustlessGateway {
88121
}
89122
this.#errors++
90123
throw new Error(`unable to fetch raw block for CID ${cid}`)
124+
} finally {
125+
this.#pendingResponses.delete(blockId)
91126
}
92127
}
93128

@@ -130,4 +165,14 @@ export class TrustlessGateway {
130165
incrementInvalidBlocks (): void {
131166
this.#invalidBlocks++
132167
}
168+
169+
getStats (): TrustlessGatewayStats {
170+
return {
171+
attempts: this.#attempts,
172+
errors: this.#errors,
173+
invalidBlocks: this.#invalidBlocks,
174+
successes: this.#successes,
175+
pendingResponses: this.#pendingResponses.size
176+
}
177+
}
133178
}

packages/block-brokers/test/trustless-gateway.spec.ts

+30-1
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import { createEd25519PeerId } from '@libp2p/peer-id-factory'
55
import { multiaddr } from '@multiformats/multiaddr'
66
import { uriToMultiaddr } from '@multiformats/uri-to-multiaddr'
77
import { expect } from 'aegir/chai'
8+
import { CID } from 'multiformats/cid'
89
import * as raw from 'multiformats/codecs/raw'
910
import Sinon from 'sinon'
1011
import { type StubbedInstance, stubConstructor, stubInterface } from 'sinon-ts'
1112
import { TrustlessGatewayBlockBroker } from '../src/trustless-gateway/broker.js'
1213
import { TrustlessGateway } from '../src/trustless-gateway/trustless-gateway.js'
1314
import { createBlock } from './fixtures/create-block.js'
1415
import type { Routing } from '@helia/interface'
15-
import type { CID } from 'multiformats/cid'
1616

1717
describe('trustless-gateway-block-broker', () => {
1818
let blocks: Array<{ cid: CID, block: Uint8Array }>
@@ -190,4 +190,33 @@ describe('trustless-gateway-block-broker', () => {
190190

191191
await expect(sessionBlockstore?.retrieve?.(blocks[0].cid)).to.eventually.deep.equal(blocks[0].block)
192192
})
193+
194+
it('does not trigger new network requests if the same cid request is in-flight', async function () {
195+
// from .aegir.js polka server
196+
const cid = CID.parse('bafkqabtimvwgy3yk')
197+
if (process.env.TRUSTLESS_GATEWAY == null) {
198+
return this.skip()
199+
}
200+
const trustlessGateway = new TrustlessGateway(process.env.TRUSTLESS_GATEWAY, defaultLogger())
201+
202+
// Call getRawBlock multiple times with the same CID
203+
const promises = Array.from({ length: 10 }, async () => trustlessGateway.getRawBlock(cid))
204+
205+
// Wait for both promises to resolve
206+
const [block1, ...blocks] = await Promise.all(promises)
207+
208+
// Assert that all calls to getRawBlock returned the same block
209+
for (const block of blocks) {
210+
expect(block).to.deep.equal(block1)
211+
}
212+
213+
expect(trustlessGateway.getStats()).to.deep.equal({
214+
// attempt is only incremented when a new request is made
215+
attempts: 1,
216+
errors: 0,
217+
invalidBlocks: 0,
218+
successes: 1,
219+
pendingResponses: 0 // the queue is empty
220+
})
221+
})
193222
})

0 commit comments

Comments
 (0)