Skip to content

Commit 7e3c88a

Browse files
authored
fix: cached response body can be undefined (#185)
If an empty ndjson response has been cached, the response body can be undefined so handle this case.
1 parent ff7a513 commit 7e3c88a

File tree

3 files changed

+106
-20
lines changed

3 files changed

+106
-20
lines changed

packages/client/.aegir.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ const options = {
3939
echo.polka.post('/add-providers/:cid', (req, res) => {
4040
callCount++
4141
try {
42-
4342
// if request body content-type was json it's already been parsed
4443
const data = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
4544

@@ -81,17 +80,17 @@ const options = {
8180
const acceptHeader = req.headers.accept
8281
const data = providerData || { Providers: [] }
8382

84-
if (providerData?.Providers?.length === 0) {
85-
res.statusCode = 404
86-
res.end()
87-
return
88-
}
89-
9083
if (acceptHeader?.includes('application/x-ndjson')) {
9184
res.setHeader('Content-Type', 'application/x-ndjson')
9285
const providers = Array.isArray(data.Providers) ? data.Providers : []
9386
res.end(providers.map(p => JSON.stringify(p)).join('\n'))
9487
} else {
88+
if (providerData?.Providers?.length === 0) {
89+
res.statusCode = 404
90+
res.end()
91+
return
92+
}
93+
9594
res.setHeader('Content-Type', 'application/json')
9695
res.end(JSON.stringify(data))
9796
}

packages/client/src/client.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,12 @@ export class DelegatedRoutingV1HttpApiClient implements DelegatedRoutingV1HttpAp
121121
const url = new URL(`${this.url}routing/v1/providers/${cid}`)
122122

123123
this.#addFilterParams(url, options.filterAddrs, options.filterProtocols)
124-
const getOptions = { headers: { Accept: 'application/x-ndjson' }, signal }
124+
const getOptions = {
125+
headers: {
126+
accept: 'application/x-ndjson, application/json'
127+
},
128+
signal
129+
}
125130
const res = await this.#makeRequest(url.toString(), getOptions)
126131

127132
if (!res.ok) {
@@ -142,15 +147,22 @@ export class DelegatedRoutingV1HttpApiClient implements DelegatedRoutingV1HttpAp
142147
throw new BadResponseError(`Unexpected status code: ${res.status}`)
143148
}
144149

145-
if (res.body == null) {
146-
throw new BadResponseError('Routing response had no body')
147-
}
148-
149150
const contentType = res.headers.get('Content-Type')
151+
150152
if (contentType == null) {
151153
throw new BadResponseError('No Content-Type header received')
152154
}
153155

156+
if (res.body == null) {
157+
if (contentType !== 'application/x-ndjson') {
158+
throw new BadResponseError('Routing response had no body')
159+
}
160+
161+
// cached ndjson responses have no body property if the gateway returned
162+
// no results
163+
return
164+
}
165+
154166
if (contentType.startsWith('application/json')) {
155167
const body = await res.json()
156168
// Handle null/undefined Providers from servers (both old and new may return empty arrays)
@@ -416,16 +428,22 @@ export class DelegatedRoutingV1HttpApiClient implements DelegatedRoutingV1HttpAp
416428
// Only try to use cache for GET requests
417429
if (requestMethod === 'GET') {
418430
const cachedResponse = await this.cache?.match(url)
431+
419432
if (cachedResponse != null) {
420433
// Check if the cached response has expired
421434
const expires = parseInt(cachedResponse.headers.get('x-cache-expires') ?? '0', 10)
422435
if (expires > Date.now()) {
423436
this.log('returning cached response for %s', key)
437+
this.logResponse(cachedResponse)
438+
424439
return cachedResponse
425440
} else {
441+
this.log('evicting cached response for %s', key)
426442
// Remove expired response from cache
427443
await this.cache?.delete(url)
428444
}
445+
} else if (this.cache != null) {
446+
this.log('cache miss for %s', key)
429447
}
430448
}
431449

@@ -437,8 +455,14 @@ export class DelegatedRoutingV1HttpApiClient implements DelegatedRoutingV1HttpAp
437455
return response.clone()
438456
}
439457

458+
this.log('outgoing request:')
459+
this.logRequest(url, options)
460+
440461
// Create new request and track it
441462
const requestPromise = fetch(url, options).then(async response => {
463+
this.log('incoming response:')
464+
this.logResponse(response)
465+
442466
// Only cache successful GET requests
443467
if (this.cache != null && response.ok && requestMethod === 'GET') {
444468
const expires = Date.now() + this.cacheTTL
@@ -468,4 +492,21 @@ export class DelegatedRoutingV1HttpApiClient implements DelegatedRoutingV1HttpAp
468492
toString (): string {
469493
return `DefaultDelegatedRoutingV1HttpApiClient(${this.url})`
470494
}
495+
496+
private logRequest (url: string, init: RequestInit): void {
497+
const headers = new Headers(init.headers)
498+
this.log('%s %s HTTP/1.1', init.method ?? 'GET', url)
499+
500+
for (const [key, value] of headers.entries()) {
501+
this.log('%s: %s', key, value)
502+
}
503+
}
504+
505+
private logResponse (response: Response): void {
506+
this.log('HTTP/1.1 %d %s', response.status, response.statusText)
507+
508+
for (const [key, value] of response.headers.entries()) {
509+
this.log('%s: %s', key, value)
510+
}
511+
}
471512
}

packages/client/test/index.spec.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import { generateKeyPair } from '@libp2p/crypto/keys'
44
import { start, stop } from '@libp2p/interface'
5+
import { defaultLogger } from '@libp2p/logger'
56
import { peerIdFromPrivateKey, peerIdFromString, peerIdFromCID } from '@libp2p/peer-id'
67
import { multiaddr } from '@multiformats/multiaddr'
78
import { expect } from 'aegir/chai'
89
import { createIPNSRecord, marshalIPNSRecord } from 'ipns'
910
import all from 'it-all'
1011
import { CID } from 'multiformats/cid'
11-
import { createDelegatedRoutingV1HttpApiClient } from '../src/index.js'
12+
import { isBrowser } from 'wherearewe'
13+
import { delegatedRoutingV1HttpApiClient } from '../src/index.js'
1214
import { itBrowser } from './fixtures/it.js'
1315
import type { DelegatedRoutingV1HttpApiClient } from '../src/index.js'
1416

@@ -37,14 +39,27 @@ const serverUrl = process.env.ECHO_SERVER
3739

3840
describe('delegated-routing-v1-http-api-client', () => {
3941
let client: DelegatedRoutingV1HttpApiClient
42+
let clientWithCache: DelegatedRoutingV1HttpApiClient
4043

4144
beforeEach(async () => {
42-
client = createDelegatedRoutingV1HttpApiClient(new URL(serverUrl), { cacheTTL: 0 })
43-
await start(client)
45+
client = delegatedRoutingV1HttpApiClient({
46+
url: new URL(serverUrl),
47+
cacheTTL: 0
48+
})({
49+
logger: defaultLogger()
50+
})
51+
clientWithCache = delegatedRoutingV1HttpApiClient({
52+
url: new URL(serverUrl),
53+
cacheTTL: 30_000
54+
})({
55+
logger: defaultLogger()
56+
})
57+
58+
await start(client, clientWithCache)
4459
})
4560

4661
afterEach(async () => {
47-
await stop(client)
62+
await stop(client, clientWithCache)
4863
})
4964

5065
it('should find providers', async () => {
@@ -108,15 +123,40 @@ describe('delegated-routing-v1-http-api-client', () => {
108123
await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, {
109124
method: 'POST',
110125
headers: {
111-
Accept: 'application/x-ndjson'
126+
'Content-Type': 'application/json'
112127
},
113-
body: '' // Empty NDJSON stream
128+
body: JSON.stringify({ Providers: [] })
114129
})
115130

116131
const provs = await all(client.getProviders(cid))
117132
expect(provs).to.be.empty()
118133
})
119134

135+
it('should return empty array when no providers found in cached response (200 with empty NDJSON)', async function () {
136+
if (!isBrowser) {
137+
// 'globalThis.caches are only availale in browser environments'
138+
this.skip()
139+
}
140+
141+
const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn')
142+
143+
// Clear any providers - send empty NDJSON
144+
await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, {
145+
method: 'POST',
146+
headers: {
147+
'Content-Type': 'application/json'
148+
},
149+
body: JSON.stringify({ Providers: [] })
150+
})
151+
152+
const provs = await all(clientWithCache.getProviders(cid))
153+
expect(provs).to.be.empty()
154+
155+
// invoke again immediately to get cached response
156+
const cachedProvs = await all(clientWithCache.getProviders(cid))
157+
expect(cachedProvs).to.be.empty()
158+
})
159+
120160
it('should return empty array when server returns 404 for providers (old server behavior)', async () => {
121161
// Test backward compatibility with old servers that return 404
122162
const cid = CID.parse(TEST_CIDS.PROVIDERS_404)
@@ -223,9 +263,12 @@ describe('delegated-routing-v1-http-api-client', () => {
223263
})
224264

225265
it('should add filter parameters the query of the request url based on global filter', async () => {
226-
const client = createDelegatedRoutingV1HttpApiClient(new URL(serverUrl), {
266+
const client = delegatedRoutingV1HttpApiClient({
267+
url: new URL(serverUrl),
227268
filterProtocols: ['transport-bitswap', 'unknown'],
228269
filterAddrs: ['tcp', '!p2p-circuit']
270+
})({
271+
logger: defaultLogger()
229272
})
230273
const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn')
231274

@@ -533,8 +576,11 @@ describe('delegated-routing-v1-http-api-client', () => {
533576

534577
itBrowser('should respect cache TTL', async () => {
535578
const shortTTL = 100 // 100ms TTL for testing
536-
const clientWithShortTTL = createDelegatedRoutingV1HttpApiClient(new URL(serverUrl), {
579+
const clientWithShortTTL = delegatedRoutingV1HttpApiClient({
580+
url: new URL(serverUrl),
537581
cacheTTL: shortTTL
582+
})({
583+
logger: defaultLogger()
538584
})
539585
await start(clientWithShortTTL)
540586

0 commit comments

Comments
 (0)