Skip to content

Commit f7ac27d

Browse files
cubapthehabes
andauthored
Add RERUM fetch wrapper with timeout (#41)
* Add RERUM fetch wrapper with timeout fixes #31 new file rerum.js which provides fetchRerum (bounded fetch using RERUM_FETCH_TIMEOUT_MS with AbortSignal.any and a 30s default) and error helpers (createRerumNetworkError / createRerumTimeoutError). Replace duplicated fetch/error-handling in routes (create, delete, overwrite, query, update) to use fetchRerum and the network-error helper for consistent 502/504 behavior and invalid-response checks. Add __tests__/rerum.testcases.md documenting timeout and upstream-failure scenarios. Update sample.env to note the default RERUM timeout configuration. * changes during review --------- Co-authored-by: Bryan Haberberger <bryan.j.haberberger@slu.edu>
1 parent 10f5722 commit f7ac27d

8 files changed

Lines changed: 161 additions & 50 deletions

File tree

__tests__/rerum.testcases.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# RERUM Fetch Timeout and Upstream Failure Test Cases
2+
3+
This file documents test cases for TinyPen's outgoing RERUM fetch behavior. These are not executable tests, but serve as a checklist for future test harness development.
4+
5+
## Expected Behaviors
6+
7+
### 1. RERUM responds quickly (normal success path)
8+
9+
- Setup: RERUM returns a valid 2xx JSON response before timeout
10+
- Should return expected route success code (200/201/204)
11+
- Response: pass-through body or headers expected by route
12+
13+
### 2. RERUM accepts connection but never responds
14+
15+
- Setup: upstream socket stays open with no response body/status
16+
- Should abort request after configured timeout
17+
- Should return 504
18+
- Response: timeout message indicating RERUM did not respond in time
19+
20+
### 3. RERUM response arrives just before timeout threshold
21+
22+
- Setup: upstream responds slightly before timeout deadline
23+
- Should not abort request
24+
- Should return route success code and expected body
25+
26+
### 4. RERUM network failure before response (DNS/connect/reset)
27+
28+
- Setup: fetch fails with connection-level/network error
29+
- Should return 502
30+
- Response: generic upstream error message
31+
32+
### 5. RERUM returns non-2xx status with text body
33+
34+
- Setup: upstream returns 4xx/5xx and text payload
35+
- Should return 502 from TinyPen routes
36+
- Response: includes upstream status and text body when available
37+
38+
### 6. RERUM returns non-2xx status with unreadable/invalid body
39+
40+
- Setup: upstream returns error status, body read fails
41+
- Should return 502
42+
- Response: generic upstream error message
43+
44+
### 7. RERUM returns 2xx with invalid payload shape for create/update/overwrite
45+
46+
- Setup: upstream returns 200 but missing `id` and `@id`
47+
- Should return 502
48+
- Response: generic upstream error message
49+
50+
### 8. /overwrite conflict pass-through remains intact
51+
52+
- Setup: upstream returns 409 with JSON conflict body
53+
- Should return 409
54+
- Response: conflict JSON body from upstream
55+
56+
### 9. Timeout value is overridden by environment variable
57+
58+
- Setup: set `RERUM_FETCH_TIMEOUT_MS` to a small positive value
59+
- Should abort according to that configured value
60+
- Should return 504 on stalled upstream
61+
62+
### 10. Invalid timeout env value falls back to default
63+
64+
- Setup: set `RERUM_FETCH_TIMEOUT_MS` to empty, non-numeric, or <= 0
65+
- Should use default timeout value
66+
- Should still abort stalled requests and return 504
67+
68+
---
69+
70+
Add new cases as needed. Checklist for RERUM timeout and upstream resiliency behavior.

rerum.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const DEFAULT_RERUM_TIMEOUT_MS = 30000
2+
3+
const getRerumTimeoutMs = () => {
4+
const timeoutMs = Number.parseInt(process.env.RERUM_FETCH_TIMEOUT_MS ?? `${DEFAULT_RERUM_TIMEOUT_MS}`, 10)
5+
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_RERUM_TIMEOUT_MS
6+
}
7+
8+
/**
9+
* Build the generic upstream error used when RERUM cannot be reached or returns invalid data.
10+
*
11+
* @param {string} url - The RERUM URL being requested
12+
* @returns {Error}
13+
*/
14+
const createRerumNetworkError = (url) => {
15+
const err = new Error(`500: ${url} - A RERUM error occurred`)
16+
err.status = 502
17+
return err
18+
}
19+
20+
/**
21+
* Build an upstream timeout error for requests that exceed the configured wait time.
22+
*
23+
* @param {string} url - The RERUM URL being requested
24+
* @param {number} timeoutMs - The timeout in milliseconds
25+
* @returns {Error}
26+
*/
27+
const createRerumTimeoutError = (url, timeoutMs) => {
28+
const err = new Error(`504: ${url} - RERUM did not respond within ${timeoutMs}ms`)
29+
err.status = 504
30+
return err
31+
}
32+
33+
/**
34+
* Execute a fetch to RERUM with a bounded wait time so workers do not block indefinitely.
35+
*
36+
* @param {string} url - The RERUM URL being requested
37+
* @param {RequestInit} [options={}] - Fetch options
38+
* @returns {Promise<Response>}
39+
*/
40+
async function fetchRerum(url, options = {}) {
41+
const timeoutMs = getRerumTimeoutMs()
42+
const timeoutController = new AbortController()
43+
const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs)
44+
45+
try {
46+
return await fetch(url, { ...options, signal: timeoutController.signal })
47+
}
48+
catch (err) {
49+
if (timeoutController.signal.aborted) {
50+
throw createRerumTimeoutError(url, timeoutMs)
51+
}
52+
throw createRerumNetworkError(url)
53+
}
54+
finally {
55+
clearTimeout(timeoutId)
56+
}
57+
}
58+
59+
export { createRerumNetworkError, fetchRerum }

routes/create.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import express from "express"
22
import checkAccessToken from "../tokens.js"
33
import rest from "../rest.js"
4+
import { createRerumNetworkError, fetchRerum } from "../rerum.js"
45

56
const router = express.Router()
67

@@ -24,9 +25,12 @@ router.post('/', rest.verifyJsonContentType, checkAccessToken, async (req, res,
2425
}
2526
}
2627
const createURL = `${process.env.RERUM_API_ADDR}create`
27-
const rerumResponse = await fetch(createURL, createOptions)
28+
const rerumResponse = await fetchRerum(createURL, createOptions)
2829
.then(async (resp) => {
29-
if (resp.ok) return resp.json()
30+
if (resp.ok) {
31+
try { return await resp.json() }
32+
catch (e) { throw createRerumNetworkError(createURL) }
33+
}
3034
// The response from RERUM indicates a failure, likely with a specific code and textual body
3135
let rerumErrorMessage
3236
try {
@@ -38,17 +42,9 @@ router.post('/', rest.verifyJsonContentType, checkAccessToken, async (req, res,
3842
err.status = 502
3943
throw err
4044
})
41-
.catch(err => {
42-
if (err.status === 502) throw err
43-
const genericRerumNetworkError = new Error(`500: ${createURL} - A RERUM error occurred`)
44-
genericRerumNetworkError.status = 502
45-
throw genericRerumNetworkError
46-
})
4745
if (!(rerumResponse.id || rerumResponse["@id"])) {
4846
// A 200 with garbled data, call it a fail
49-
const genericRerumNetworkError = new Error(`500: ${createURL} - A RERUM error occurred`)
50-
genericRerumNetworkError.status = 502
51-
throw genericRerumNetworkError
47+
throw createRerumNetworkError(createURL)
5248
}
5349
res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id)
5450
res.status(201).json(rerumResponse)
@@ -59,4 +55,4 @@ router.post('/', rest.verifyJsonContentType, checkAccessToken, async (req, res,
5955
}
6056
})
6157

62-
export default router
58+
export default router

routes/delete.js

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import express from "express"
22
import checkAccessToken from "../tokens.js"
3+
import { fetchRerum } from "../rerum.js"
34

45
const router = express.Router()
56

@@ -20,7 +21,7 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => {
2021
'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`
2122
}
2223
}
23-
await fetch(deleteURL, deleteOptions)
24+
await fetchRerum(deleteURL, deleteOptions)
2425
.then(async (resp) => {
2526
if (resp.ok) return
2627
// The response from RERUM indicates a failure, likely with a specific code and textual body
@@ -34,12 +35,6 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => {
3435
err.status = 502
3536
throw err
3637
})
37-
.catch(err => {
38-
if (err.status === 502) throw err
39-
const genericRerumNetworkError = new Error(`500: ${deleteURL} - A RERUM error occurred`)
40-
genericRerumNetworkError.status = 502
41-
throw genericRerumNetworkError
42-
})
4338
res.status(204).end()
4439
}
4540
catch (err) {

routes/overwrite.js

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import express from "express"
22
import checkAccessToken from "../tokens.js"
33
import rest from "../rest.js"
4+
import { createRerumNetworkError, fetchRerum } from "../rerum.js"
45

56
const router = express.Router()
67

@@ -41,9 +42,12 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n
4142
}
4243

4344
const overwriteURL = `${process.env.RERUM_API_ADDR}overwrite`
44-
const rerumResponse = await fetch(overwriteURL, overwriteOptions)
45+
const rerumResponse = await fetchRerum(overwriteURL, overwriteOptions)
4546
.then(async (resp) => {
46-
if (resp.ok) return resp.json()
47+
if (resp.ok) {
48+
try { return await resp.json() }
49+
catch (e) { throw createRerumNetworkError(overwriteURL) }
50+
}
4751
// Handle 409 conflict error for version mismatch (optimistic locking)
4852
if (resp.status === 409) {
4953
const conflictBody = await resp.json()
@@ -63,17 +67,9 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n
6367
err.status = 502
6468
throw err
6569
})
66-
.catch(err => {
67-
if (err.status === 502 || err.status === 409) throw err
68-
const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`)
69-
genericRerumNetworkError.status = 502
70-
throw genericRerumNetworkError
71-
})
7270
if (!(rerumResponse.id || rerumResponse["@id"])) {
7371
// A 200 with garbled data, call it a fail
74-
const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`)
75-
genericRerumNetworkError.status = 502
76-
throw genericRerumNetworkError
72+
throw createRerumNetworkError(overwriteURL)
7773
}
7874
res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id)
7975
res.status(200).json(rerumResponse)

routes/query.js

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import express from "express"
22
import rest from "../rest.js"
3+
import { createRerumNetworkError, fetchRerum } from "../rerum.js"
34

45
const router = express.Router()
56

@@ -36,9 +37,12 @@ router.post('/', rest.verifyJsonContentType, async (req, res, next) => {
3637
}
3738
}
3839
const queryURL = `${process.env.RERUM_API_ADDR}query?limit=${lim}&skip=${skip}`
39-
const rerumResponse = await fetch(queryURL, queryOptions)
40+
const rerumResponse = await fetchRerum(queryURL, queryOptions)
4041
.then(async (resp) => {
41-
if (resp.ok) return resp.json()
42+
if (resp.ok) {
43+
try { return await resp.json() }
44+
catch (e) { throw createRerumNetworkError(queryURL) }
45+
}
4246
// The response from RERUM indicates a failure, likely with a specific code and textual body
4347
let rerumErrorMessage
4448
try {
@@ -50,12 +54,6 @@ router.post('/', rest.verifyJsonContentType, async (req, res, next) => {
5054
err.status = 502
5155
throw err
5256
})
53-
.catch(err => {
54-
if (err.status === 502) throw err
55-
const genericRerumNetworkError = new Error(`500: ${queryURL} - A RERUM error occurred`)
56-
genericRerumNetworkError.status = 502
57-
throw genericRerumNetworkError
58-
})
5957
res.status(200).json(rerumResponse)
6058
}
6159
catch (err) {

routes/update.js

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import express from "express"
22
import checkAccessToken from "../tokens.js"
33
import rest from "../rest.js"
4+
import { createRerumNetworkError, fetchRerum } from "../rerum.js"
45

56
const router = express.Router()
67

@@ -27,9 +28,12 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n
2728
}
2829
}
2930
const updateURL = `${process.env.RERUM_API_ADDR}update`
30-
const rerumResponse = await fetch(updateURL, updateOptions)
31+
const rerumResponse = await fetchRerum(updateURL, updateOptions)
3132
.then(async (resp) => {
32-
if (resp.ok) return resp.json()
33+
if (resp.ok) {
34+
try { return await resp.json() }
35+
catch (e) { throw createRerumNetworkError(updateURL) }
36+
}
3337
// The response from RERUM indicates a failure, likely with a specific code and textual body
3438
let rerumErrorMessage
3539
try {
@@ -41,17 +45,9 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n
4145
err.status = 502
4246
throw err
4347
})
44-
.catch(err => {
45-
if (err.status === 502) throw err
46-
const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`)
47-
genericRerumNetworkError.status = 502
48-
throw genericRerumNetworkError
49-
})
5048
if (!(rerumResponse.id || rerumResponse["@id"])) {
5149
// A 200 with garbled data, call it a fail
52-
const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`)
53-
genericRerumNetworkError.status = 502
54-
throw genericRerumNetworkError
50+
throw createRerumNetworkError(updateURL)
5551
}
5652
res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id)
5753
res.status(200).json(rerumResponse)

sample.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ OPEN_API_CORS = false
1616

1717
PORT = 3333
1818

19-
19+
# Outgoing RERUM fetch timeout in milliseconds. Defaults to 30000 if unset, empty, non-numeric, or <= 0.
20+
#RERUM_FETCH_TIMEOUT_MS = 30000

0 commit comments

Comments
 (0)