diff --git a/__tests__/rerum.testcases.md b/__tests__/rerum.testcases.md new file mode 100644 index 0000000..47fc290 --- /dev/null +++ b/__tests__/rerum.testcases.md @@ -0,0 +1,70 @@ +# RERUM Fetch Timeout and Upstream Failure Test Cases + +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. + +## Expected Behaviors + +### 1. RERUM responds quickly (normal success path) + +- Setup: RERUM returns a valid 2xx JSON response before timeout +- Should return expected route success code (200/201/204) +- Response: pass-through body or headers expected by route + +### 2. RERUM accepts connection but never responds + +- Setup: upstream socket stays open with no response body/status +- Should abort request after configured timeout +- Should return 504 +- Response: timeout message indicating RERUM did not respond in time + +### 3. RERUM response arrives just before timeout threshold + +- Setup: upstream responds slightly before timeout deadline +- Should not abort request +- Should return route success code and expected body + +### 4. RERUM network failure before response (DNS/connect/reset) + +- Setup: fetch fails with connection-level/network error +- Should return 502 +- Response: generic upstream error message + +### 5. RERUM returns non-2xx status with text body + +- Setup: upstream returns 4xx/5xx and text payload +- Should return 502 from TinyPen routes +- Response: includes upstream status and text body when available + +### 6. RERUM returns non-2xx status with unreadable/invalid body + +- Setup: upstream returns error status, body read fails +- Should return 502 +- Response: generic upstream error message + +### 7. RERUM returns 2xx with invalid payload shape for create/update/overwrite + +- Setup: upstream returns 200 but missing `id` and `@id` +- Should return 502 +- Response: generic upstream error message + +### 8. /overwrite conflict pass-through remains intact + +- Setup: upstream returns 409 with JSON conflict body +- Should return 409 +- Response: conflict JSON body from upstream + +### 9. Timeout value is overridden by environment variable + +- Setup: set `RERUM_FETCH_TIMEOUT_MS` to a small positive value +- Should abort according to that configured value +- Should return 504 on stalled upstream + +### 10. Invalid timeout env value falls back to default + +- Setup: set `RERUM_FETCH_TIMEOUT_MS` to empty, non-numeric, or <= 0 +- Should use default timeout value +- Should still abort stalled requests and return 504 + +--- + +Add new cases as needed. Checklist for RERUM timeout and upstream resiliency behavior. diff --git a/rerum.js b/rerum.js new file mode 100644 index 0000000..0eb3a1e --- /dev/null +++ b/rerum.js @@ -0,0 +1,62 @@ +const DEFAULT_RERUM_TIMEOUT_MS = 30000 + +const getRerumTimeoutMs = () => { + const timeoutMs = Number.parseInt(process.env.RERUM_FETCH_TIMEOUT_MS ?? `${DEFAULT_RERUM_TIMEOUT_MS}`, 10) + return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_RERUM_TIMEOUT_MS +} + +/** + * Build the generic upstream error used when RERUM cannot be reached or returns invalid data. + * + * @param {string} url - The RERUM URL being requested + * @returns {Error} + */ +const createRerumNetworkError = (url) => { + const err = new Error(`500: ${url} - A RERUM error occurred`) + err.status = 502 + return err +} + +/** + * Build an upstream timeout error for requests that exceed the configured wait time. + * + * @param {string} url - The RERUM URL being requested + * @param {number} timeoutMs - The timeout in milliseconds + * @returns {Error} + */ +const createRerumTimeoutError = (url, timeoutMs) => { + const err = new Error(`504: ${url} - RERUM did not respond within ${timeoutMs}ms`) + err.status = 504 + return err +} + +/** + * Execute a fetch to RERUM with a bounded wait time so workers do not block indefinitely. + * + * @param {string} url - The RERUM URL being requested + * @param {RequestInit} [options={}] - Fetch options + * @returns {Promise} + */ +async function fetchRerum(url, options = {}) { + const timeoutMs = getRerumTimeoutMs() + const timeoutController = new AbortController() + const timeoutId = setTimeout(() => timeoutController.abort(), timeoutMs) + const signal = options.signal + ? AbortSignal.any([options.signal, timeoutController.signal]) + : timeoutController.signal + + try { + return await fetch(url, { ...options, signal }) + } + catch (err) { + if (err?.name === "AbortError" && timeoutController.signal.aborted) { + throw createRerumTimeoutError(url, timeoutMs) + } + throw createRerumNetworkError(url) + } + finally { + clearTimeout(timeoutId) + } +} + +export { createRerumNetworkError, fetchRerum } diff --git a/routes/create.js b/routes/create.js index 388b3ec..05b4008 100644 --- a/routes/create.js +++ b/routes/create.js @@ -1,6 +1,7 @@ import express from "express" import checkAccessToken from "../tokens.js" import rest from "../rest.js" +import { createRerumNetworkError, fetchRerum } from "../rerum.js" const router = express.Router() @@ -24,7 +25,7 @@ router.post('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, } } const createURL = `${process.env.RERUM_API_ADDR}create` - const rerumResponse = await fetch(createURL, createOptions) + const rerumResponse = await fetchRerum(createURL, createOptions) .then(async (resp) => { if (resp.ok) return resp.json() // The response from RERUM indicates a failure, likely with a specific code and textual body @@ -38,17 +39,9 @@ router.post('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, err.status = 502 throw err }) - .catch(err => { - if (err.status === 502) throw err - const genericRerumNetworkError = new Error(`500: ${createURL} - A RERUM error occurred`) - genericRerumNetworkError.status = 502 - throw genericRerumNetworkError - }) if (!(rerumResponse.id || rerumResponse["@id"])) { // A 200 with garbled data, call it a fail - const genericRerumNetworkError = new Error(`500: ${createURL} - A RERUM error occurred`) - genericRerumNetworkError.status = 502 - throw genericRerumNetworkError + throw createRerumNetworkError(createURL) } res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id) res.status(201).json(rerumResponse) @@ -59,4 +52,4 @@ router.post('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, } }) -export default router \ No newline at end of file +export default router diff --git a/routes/delete.js b/routes/delete.js index be95e12..54b4cb0 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -1,5 +1,6 @@ import express from "express" import checkAccessToken from "../tokens.js" +import { fetchRerum } from "../rerum.js" const router = express.Router() @@ -20,7 +21,7 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}` } } - await fetch(deleteURL, deleteOptions) + await fetchRerum(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return // 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) => { err.status = 502 throw err }) - .catch(err => { - if (err.status === 502) throw err - const genericRerumNetworkError = new Error(`500: ${deleteURL} - A RERUM error occurred`) - genericRerumNetworkError.status = 502 - throw genericRerumNetworkError - }) res.status(204).end() } catch (err) { diff --git a/routes/overwrite.js b/routes/overwrite.js index b09a42a..a9bde7b 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -1,6 +1,7 @@ import express from "express" import checkAccessToken from "../tokens.js" import rest from "../rest.js" +import { createRerumNetworkError, fetchRerum } from "../rerum.js" const router = express.Router() @@ -41,7 +42,7 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n } const overwriteURL = `${process.env.RERUM_API_ADDR}overwrite` - const rerumResponse = await fetch(overwriteURL, overwriteOptions) + const rerumResponse = await fetchRerum(overwriteURL, overwriteOptions) .then(async (resp) => { if (resp.ok) return resp.json() // Handle 409 conflict error for version mismatch (optimistic locking) @@ -63,17 +64,9 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n err.status = 502 throw err }) - .catch(err => { - if (err.status === 502 || err.status === 409) throw err - const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`) - genericRerumNetworkError.status = 502 - throw genericRerumNetworkError - }) if (!(rerumResponse.id || rerumResponse["@id"])) { // A 200 with garbled data, call it a fail - const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`) - genericRerumNetworkError.status = 502 - throw genericRerumNetworkError + throw createRerumNetworkError(overwriteURL) } res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id) res.status(200).json(rerumResponse) diff --git a/routes/query.js b/routes/query.js index fcc45f4..baf05fe 100644 --- a/routes/query.js +++ b/routes/query.js @@ -1,5 +1,6 @@ import express from "express" import rest from "../rest.js" +import { fetchRerum } from "../rerum.js" const router = express.Router() @@ -36,7 +37,7 @@ router.post('/', rest.verifyJsonContentType, async (req, res, next) => { } } const queryURL = `${process.env.RERUM_API_ADDR}query?limit=${lim}&skip=${skip}` - const rerumResponse = await fetch(queryURL, queryOptions) + const rerumResponse = await fetchRerum(queryURL, queryOptions) .then(async (resp) => { if (resp.ok) return resp.json() // The response from RERUM indicates a failure, likely with a specific code and textual body @@ -50,12 +51,6 @@ router.post('/', rest.verifyJsonContentType, async (req, res, next) => { err.status = 502 throw err }) - .catch(err => { - if (err.status === 502) throw err - const genericRerumNetworkError = new Error(`500: ${queryURL} - A RERUM error occurred`) - genericRerumNetworkError.status = 502 - throw genericRerumNetworkError - }) res.status(200).json(rerumResponse) } catch (err) { diff --git a/routes/update.js b/routes/update.js index a025875..7b57388 100644 --- a/routes/update.js +++ b/routes/update.js @@ -1,6 +1,7 @@ import express from "express" import checkAccessToken from "../tokens.js" import rest from "../rest.js" +import { createRerumNetworkError, fetchRerum } from "../rerum.js" const router = express.Router() @@ -27,7 +28,7 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n } } const updateURL = `${process.env.RERUM_API_ADDR}update` - const rerumResponse = await fetch(updateURL, updateOptions) + const rerumResponse = await fetchRerum(updateURL, updateOptions) .then(async (resp) => { if (resp.ok) return resp.json() // The response from RERUM indicates a failure, likely with a specific code and textual body @@ -41,17 +42,9 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n err.status = 502 throw err }) - .catch(err => { - if (err.status === 502) throw err - const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) - genericRerumNetworkError.status = 502 - throw genericRerumNetworkError - }) if (!(rerumResponse.id || rerumResponse["@id"])) { // A 200 with garbled data, call it a fail - const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) - genericRerumNetworkError.status = 502 - throw genericRerumNetworkError + throw createRerumNetworkError(updateURL) } res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id) res.status(200).json(rerumResponse) diff --git a/sample.env b/sample.env index 694af50..c1830b4 100644 --- a/sample.env +++ b/sample.env @@ -16,4 +16,4 @@ OPEN_API_CORS = false PORT = 3333 - +#DEFAULT_RERUM_TIMEOUT_MS = 30000