Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions __tests__/rerum.testcases.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions rerum.js
Original file line number Diff line number Diff line change
@@ -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<Response>}
*/
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 }
15 changes: 4 additions & 11 deletions routes/create.js
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -59,4 +52,4 @@ router.post('/', rest.verifyJsonContentType, checkAccessToken, async (req, res,
}
})

export default router
export default router
9 changes: 2 additions & 7 deletions routes/delete.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express from "express"
import checkAccessToken from "../tokens.js"
import { fetchRerum } from "../rerum.js"

const router = express.Router()

Expand All @@ -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
Expand All @@ -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) {
Expand Down
13 changes: 3 additions & 10 deletions routes/overwrite.js
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
9 changes: 2 additions & 7 deletions routes/query.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express from "express"
import rest from "../rest.js"
import { fetchRerum } from "../rerum.js"

const router = express.Router()

Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
13 changes: 3 additions & 10 deletions routes/update.js
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ OPEN_API_CORS = false

PORT = 3333


#DEFAULT_RERUM_TIMEOUT_MS = 30000