Skip to content

Commit 8b532ec

Browse files
authored
enhancement: error handling + exp flag (#31)
* enhancement: error handling + exp flag * fix formatting * url handling * fix url issue
1 parent 3b4e8db commit 8b532ec

File tree

4 files changed

+133
-28
lines changed

4 files changed

+133
-28
lines changed

src/client.js

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { memoryStorage } from './storage/index.js'
99
import { getJWT } from './utils/jwt.js'
1010
import { parseUrl, addHttpPrefix } from './utils/url.js'
1111
import { isBrowserContext } from './utils/runtime.js'
12+
import { isErrorUnavoidable } from './utils/errors.js'
1213

1314
const MAX_NODE_WEIGHT = 100
1415
/**
@@ -28,6 +29,7 @@ export class Saturn {
2829
* @param {number} [opts.downloadTimeout=0]
2930
* @param {string} [opts.orchURL]
3031
* @param {number} [opts.fallbackLimit]
32+
* @param {boolean} [opts.experimental]
3133
* @param {import('./storage/index.js').Storage} [opts.storage]
3234
*/
3335
constructor (opts = {}) {
@@ -54,7 +56,7 @@ export class Saturn {
5456
this._monitorPerformanceBuffer()
5557
}
5658
this.storage = this.opts.storage || memoryStorage()
57-
this.loadNodesPromise = this._loadNodes(this.opts)
59+
this.loadNodesPromise = this.opts.experimental ? this._loadNodes(this.opts) : null
5860
}
5961

6062
/**
@@ -81,7 +83,11 @@ export class Saturn {
8183
}
8284
}
8385

84-
const origins = options.origins
86+
let origins = options.origins
87+
if (!origins || origins.length === 0) {
88+
const replacementUrl = options.url ?? options.cdnURL
89+
origins = [replacementUrl]
90+
}
8591
const controllers = []
8692

8793
const createFetchPromise = async (origin) => {
@@ -125,11 +131,12 @@ export class Saturn {
125131

126132
abortRemainingFetches(controller, controllers)
127133
log = Object.assign(log, this._generateLog(res, log), { url })
128-
129134
if (!res.ok) {
130-
throw new Error(
131-
`Non OK response received: ${res.status} ${res.statusText}`
135+
const error = new Error(
136+
`Non OK response received: ${res.status} ${res.statusText}`
132137
)
138+
error.res = res
139+
throw error
133140
}
134141
} catch (err) {
135142
if (!res) {
@@ -184,11 +191,12 @@ export class Saturn {
184191
clearTimeout(connectTimeout)
185192

186193
log = Object.assign(log, this._generateLog(res, log))
187-
188194
if (!res.ok) {
189-
throw new Error(
195+
const error = new Error(
190196
`Non OK response received: ${res.status} ${res.statusText}`
191197
)
198+
error.res = res
199+
throw error
192200
}
193201
} catch (err) {
194202
if (!res) {
@@ -240,6 +248,10 @@ export class Saturn {
240248
// this is temporary until range requests are supported.
241249
let byteCountCheckpoint = 0
242250

251+
const throwError = () => {
252+
throw new Error(`All attempts to fetch content have failed. Last error: ${lastError.message}`)
253+
}
254+
243255
const fetchContent = async function * () {
244256
let byteCount = 0
245257
const byteChunks = await this.fetchContent(cidPath, opts)
@@ -268,6 +280,9 @@ export class Saturn {
268280
return
269281
} catch (err) {
270282
lastError = err
283+
if (err.res?.status === 410 || isErrorUnavoidable(err)) {
284+
throwError()
285+
}
271286
await this.loadNodesPromise
272287
}
273288
}
@@ -290,21 +305,24 @@ export class Saturn {
290305
return
291306
} catch (err) {
292307
lastError = err
308+
if (err.res?.status === 410 || isErrorUnavoidable(err)) {
309+
break
310+
}
293311
}
294312
fallbackCount += 1
295313
}
296314

297315
if (lastError) {
298-
throw new Error(`All attempts to fetch content have failed. Last error: ${lastError.message}`)
316+
throwError()
299317
}
300318
}
301319

302320
/**
303321
*
304322
* @param {string} cidPath
305323
* @param {object} [opts={}]
306-
* @param {('car'|'raw')} [opts.format]- -
307-
* @param {boolean} [opts.raceNodes]- -
324+
* @param {('car'|'raw')} [opts.format]
325+
* @param {boolean} [opts.raceNodes]
308326
* @param {number} [opts.connectTimeout=5000]
309327
* @param {number} [opts.downloadTimeout=0]
310328
* @returns {Promise<AsyncIterable<Uint8Array>>}
@@ -391,8 +409,8 @@ export class Saturn {
391409
* @param {object} log
392410
*/
393411
reportLogs (log) {
394-
if (!this.reportingLogs) return
395412
this.logs.push(log)
413+
if (!this.reportingLogs) return
396414
this.reportLogsTimeout && clearTimeout(this.reportLogsTimeout)
397415
this.reportLogsTimeout = setTimeout(this._reportLogs.bind(this), 3_000)
398416
}
@@ -560,6 +578,6 @@ export class Saturn {
560578
nodes = await orchNodesListPromise
561579
nodes = this._sortNodes(nodes)
562580
this.nodes = nodes
563-
this.storage?.set(Saturn.nodesListKey, nodes)
581+
this.storage.set(Saturn.nodesListKey, nodes)
564582
}
565583
}

src/utils/errors.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,22 @@ export class TimeoutError extends Error {
1111
this.name = 'TimeoutError'
1212
}
1313
}
14+
15+
export function isErrorUnavoidable (error) {
16+
if (!error || typeof error.message !== 'string') return false
17+
18+
const errorPatterns = [
19+
/file does not exist/,
20+
/Cannot read properties of undefined \(reading '([^']+)'\)/,
21+
/([a-zA-Z_.]+) is undefined/,
22+
/undefined is not an object \(evaluating '([^']+)'\)/
23+
]
24+
25+
for (const pattern of errorPatterns) {
26+
if (pattern.test(error.message)) {
27+
return true
28+
}
29+
}
30+
31+
return false
32+
}

test/fallback.spec.js

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import assert from 'node:assert/strict'
33
import { describe, mock, test } from 'node:test'
44

55
import { Saturn } from '#src/index.js'
6-
import { concatChunks, generateNodes, getMockServer, mockJWT, mockNodesHandlers, mockOrchHandler, mockSaturnOriginHandler, MSW_SERVER_OPTS } from './test-utils.js'
6+
import { concatChunks, generateNodes, getMockServer, HTTP_STATUS_GONE, mockJWT, mockNodesHandlers, mockOrchHandler, mockSaturnOriginHandler, MSW_SERVER_OPTS } from './test-utils.js'
77

88
const TEST_DEFAULT_ORCH = 'https://orchestrator.strn.pl/nodes'
99
const TEST_NODES_LIST_KEY = 'saturn-nodes'
1010
const TEST_AUTH = 'https://fz3dyeyxmebszwhuiky7vggmsu0rlkoy.lambda-url.us-west-2.on.aws/'
1111
const TEST_ORIGIN_DOMAIN = 'saturn.ms'
1212
const CLIENT_KEY = 'key'
13+
14+
const experimental = true
15+
1316
describe('Client Fallback', () => {
1417
test('Nodes are loaded from the orchestrator if no storage is passed', async (t) => {
1518
const handlers = [
@@ -21,7 +24,7 @@ describe('Client Fallback', () => {
2124
const expectedNodes = generateNodes(2, TEST_ORIGIN_DOMAIN)
2225

2326
// No Storage is injected
24-
const saturn = new Saturn({ clientKey: CLIENT_KEY })
27+
const saturn = new Saturn({ clientKey: CLIENT_KEY, experimental })
2528
const mockOpts = { orchURL: TEST_DEFAULT_ORCH }
2629

2730
await saturn._loadNodes(mockOpts)
@@ -50,7 +53,7 @@ describe('Client Fallback', () => {
5053
t.mock.method(mockStorage, 'get')
5154
t.mock.method(mockStorage, 'set')
5255

53-
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY })
56+
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, experimental })
5457

5558
// Mocking options
5659
const mockOpts = { orchURL: TEST_DEFAULT_ORCH }
@@ -87,7 +90,7 @@ describe('Client Fallback', () => {
8790
t.mock.method(mockStorage, 'get')
8891
t.mock.method(mockStorage, 'set')
8992

90-
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY })
93+
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, experimental })
9194

9295
// Mocking options
9396
const mockOpts = { orchURL: TEST_DEFAULT_ORCH }
@@ -126,7 +129,7 @@ describe('Client Fallback', () => {
126129
t.mock.method(mockStorage, 'get')
127130
t.mock.method(mockStorage, 'set')
128131

129-
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, clientId: 'test' })
132+
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, clientId: 'test', experimental })
130133

131134
const cid = saturn.fetchContentWithFallback('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4')
132135

@@ -159,7 +162,7 @@ describe('Client Fallback', () => {
159162
t.mock.method(mockStorage, 'get')
160163
t.mock.method(mockStorage, 'set')
161164

162-
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, clientId: 'test' })
165+
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, clientId: 'test', experimental })
163166
// const origins =
164167

165168
const cid = saturn.fetchContentWithFallback('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4', { raceNodes: true })
@@ -193,7 +196,7 @@ describe('Client Fallback', () => {
193196
t.mock.method(mockStorage, 'get')
194197
t.mock.method(mockStorage, 'set')
195198

196-
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, clientId: 'test' })
199+
const saturn = new Saturn({ storage: mockStorage, clientKey: CLIENT_KEY, clientId: 'test', experimental })
197200

198201
const cid = saturn.fetchContentWithFallback('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4', { raceNodes: true })
199202

@@ -215,7 +218,7 @@ describe('Client Fallback', () => {
215218

216219
const server = getMockServer(handlers)
217220
server.listen(MSW_SERVER_OPTS)
218-
const saturn = new Saturn({ clientKey: CLIENT_KEY, clientId: 'test' })
221+
const saturn = new Saturn({ clientKey: CLIENT_KEY, clientId: 'test', experimental })
219222

220223
const fetchContentMock = mock.fn(async function * (cidPath, opts) {
221224
yield Buffer.from('chunk1')
@@ -243,7 +246,7 @@ describe('Client Fallback', () => {
243246

244247
const server = getMockServer(handlers)
245248
server.listen(MSW_SERVER_OPTS)
246-
const saturn = new Saturn({ clientKey: CLIENT_KEY, clientId: 'test' })
249+
const saturn = new Saturn({ clientKey: CLIENT_KEY, clientId: 'test', experimental })
247250

248251
const fetchContentMock = mock.fn(async function * (cidPath, opts) { throw new Error('Fetch error') }) // eslint-disable-line
249252
saturn.fetchContent = fetchContentMock
@@ -264,6 +267,70 @@ describe('Client Fallback', () => {
264267
server.close()
265268
})
266269

270+
test('Should abort fallback on 410s', async () => {
271+
const numNodes = 3
272+
const handlers = [
273+
mockOrchHandler(numNodes, TEST_DEFAULT_ORCH, 'saturn.ms'),
274+
mockJWT(TEST_AUTH),
275+
...mockNodesHandlers(numNodes, TEST_ORIGIN_DOMAIN, 3, HTTP_STATUS_GONE)
276+
]
277+
278+
const server = getMockServer(handlers)
279+
server.listen(MSW_SERVER_OPTS)
280+
const saturn = new Saturn({ clientKey: CLIENT_KEY, clientId: 'test', experimental })
281+
await saturn.loadNodesPromise
282+
283+
let error
284+
try {
285+
for await (const _ of saturn.fetchContentWithFallback('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4')) { // eslint-disable-line
286+
// This loop body shouldn't be reached.
287+
}
288+
} catch (e) {
289+
error = e
290+
}
291+
const logs = saturn.logs
292+
293+
assert(error)
294+
assert.strictEqual(logs.length, 1)
295+
mock.reset()
296+
server.close()
297+
})
298+
299+
test('Should abort fallback on specific errors', async () => {
300+
const numNodes = 3
301+
const handlers = [
302+
mockOrchHandler(numNodes, TEST_DEFAULT_ORCH, 'saturn.ms'),
303+
mockJWT(TEST_AUTH),
304+
...mockNodesHandlers(numNodes, TEST_ORIGIN_DOMAIN, 3, HTTP_STATUS_GONE)
305+
]
306+
307+
const server = getMockServer(handlers)
308+
server.listen(MSW_SERVER_OPTS)
309+
const saturn = new Saturn({ clientKey: CLIENT_KEY, clientId: 'test', experimental })
310+
await saturn.loadNodesPromise
311+
312+
let callCount = 0
313+
const fetchContentMock = mock.fn(async function * (cidPath, opts) {
314+
callCount++
315+
yield ''
316+
throw new Error('file does not exist')
317+
})
318+
319+
saturn.fetchContent = fetchContentMock
320+
321+
let error
322+
try {
323+
for await (const _ of saturn.fetchContentWithFallback('bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4')) { // eslint-disable-line
324+
}
325+
} catch (e) {
326+
error = e
327+
}
328+
329+
assert(error)
330+
assert.strictEqual(callCount, 1)
331+
mock.reset()
332+
server.close()
333+
})
267334
test('Handles fallback with chunk overlap correctly', async () => {
268335
const numNodes = 3
269336
const handlers = [
@@ -274,7 +341,7 @@ describe('Client Fallback', () => {
274341

275342
const server = getMockServer(handlers)
276343
server.listen(MSW_SERVER_OPTS)
277-
const saturn = new Saturn({ clientKey: CLIENT_KEY, clientId: 'test' })
344+
const saturn = new Saturn({ clientKey: CLIENT_KEY, clientId: 'test', experimental })
278345

279346
let callCount = 0
280347
const fetchContentMock = mock.fn(async function * (cidPath, opts) {
@@ -313,7 +380,7 @@ describe('Client Fallback', () => {
313380

314381
const server = getMockServer(handlers)
315382
server.listen(MSW_SERVER_OPTS)
316-
const saturn = new Saturn({ clientKey: CLIENT_KEY, clientId: 'test' })
383+
const saturn = new Saturn({ clientKey: CLIENT_KEY, clientId: 'test', experimental })
317384

318385
let callCount = 0
319386
let fetchContentMock = mock.fn(async function * (cidPath, opts) {

test/test-utils.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import { fileURLToPath } from 'node:url'
99
import fs from 'fs'
1010
import { addHttpPrefix } from '../src/utils/url.js'
1111

12-
const HTTP_STATUS_OK = 200
13-
const HTTP_STATUS_TIMEOUT = 504
12+
export const HTTP_STATUS_OK = 200
13+
export const HTTP_STATUS_TIMEOUT = 504
14+
export const HTTP_STATUS_GONE = 410
1415

1516
const __dirname = dirname(fileURLToPath(import.meta.url))
1617
process.env.TESTING = 'true'
@@ -120,20 +121,20 @@ export function mockJWT (authURL) {
120121
* @param {number} count - amount of nodes to mock
121122
* @param {string} originDomain - saturn origin domain.
122123
* @param {number} failures
124+
* @param {number} failureCode
123125
* @returns {RestHandler<any>[]}
124126
*/
125-
export function mockNodesHandlers (count, originDomain, failures = 0) {
127+
export function mockNodesHandlers (count, originDomain, failures = 0, failureCode = HTTP_STATUS_TIMEOUT) {
126128
if (failures > count) {
127129
throw Error('failures number cannot exceed node count')
128130
}
129131
const nodes = generateNodes(count, originDomain)
130-
131132
const handlers = nodes.map((node, idx) => {
132133
const url = `${node.url}/ipfs/:cid`
133134
return rest.get(url, (req, res, ctx) => {
134135
if (idx < failures) {
135136
return res(
136-
ctx.status(HTTP_STATUS_TIMEOUT)
137+
ctx.status(failureCode)
137138
)
138139
}
139140
const filepath = getFixturePath('hello.car')

0 commit comments

Comments
 (0)