Skip to content

Commit 4aaff91

Browse files
cubapthehabes
andauthored
Improve route error handling consistency from TinyPEN changes (#111)
* Improve route error handling consistency * Add RERUM fetch helper; improve route errors Introduce rerum.js with fetchRerum (timeout/abort handling) and helpers for network/timeout errors. Update create, update, overwrite, delete and query routes to use fetchRerum, add Origin header and unified error handling, validate request bodies/params more strictly, and surface RERUM textual error bodies as 502 responses. Also handle id/_id normalization, reject empty queries, and specially return 409 conflict bodies from overwrite. * Improve error handling and validation Add explicit error body handling and streamline error propagation. error-messenger now detects ReadableStream bodies and uses an explicitBody field (fixing a typo in the default message). rest.httpError sets error.errorBody when a body is provided so original bodies are preserved. Routes enforce request body presence before checking @id and now throw a 400 error instead of directly sending a response (overwrite.js, update.js). query.js clarifies the limit/skip validation message to require non-negative integers. Tests updated to reflect changed status codes/messages and the query tests now mount the messenger middleware. * Use httpError helper and payload for errors Standardize error handling by introducing/using httpError and a `payload` property for error bodies. rest.js now sets error.payload instead of errorBody/body. error-messenger.js was simplified to send rerum_error_res.payload and dropped the ReadableStream/body detection logic. Routes (overwrite, query, update) now import and throw httpError for validation and upstream RERUM errors instead of creating Error objects and manually setting status codes. This centralizes HTTP error construction and response payload handling. * Rename httpError parameter to payload Change httpError signature from (message, status, body) to (message, status, payload) and set error.payload = payload. Note: the conditional still checks 'body !== undefined', which appears to be an inconsistent leftover and may prevent payload from being attached; update the check to 'payload !== undefined' as a follow-up. * fixes all tests --------- Co-authored-by: Bryan Haberberger <bryan.j.haberberger@slu.edu>
1 parent f856d6e commit 4aaff91

File tree

14 files changed

+391
-168
lines changed

14 files changed

+391
-168
lines changed

UPSTREAM_CANDIDATES.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ This document tracks candidate updates identified from TinyPen that may be upstr
1515
| C-001 | Content-Type validation middleware for JSON endpoints | upstream-now | PR #39 | `routes/query.js`, `routes/create.js`, `routes/update.js`, `routes/delete.js`, `routes/overwrite.js` | Detect malformed or unsupported media types and return 415. |
1616
| C-002 | Query body validation for empty object/array | upstream-now | PR #33 | `routes/query.js`, `routes/__tests__/query.test.js` | Reject empty query payloads with 400. |
1717
| C-003 | Query pagination validation for `limit` and `skip` | upstream-now | PR #33 | `routes/query.js`, `routes/__tests__/query.test.js` | Require non-negative integers when provided. |
18-
| C-004 | Route-level error semantic consistency | investigate-first | PR #39 and related | `routes/*.js`, `error-messenger.js` | Normalize error objects/status handling before broad refactor. |
18+
| C-004 | Route-level error semantic consistency | upstream-now | PR #39 and related | `routes/*.js`, `error-messenger.js` | Partial implementation landed: explicit 502 network failures, structured JSON passthrough, and fixed upstream propagation defects. Remaining route-specific parity can follow later. |
1919
| C-005 | Dependency cleanup after Node upgrade | investigate-first | TinyPen dependency diffs | `package.json` | Verify runtime paths before removing deps (`morgan`, `dotenv-expand`, `jsonld`). |
2020
| C-006 | Optimistic locking parity improvements | investigate-first | PR #20 | `routes/overwrite.js`, `routes/__tests__/overwrite.test.js` | Keep current behavior, evaluate if more parity is needed. |
2121
| C-007 | Temporary test alignment imports from TinyPen | upstream-now | PR #35 context | `routes/__tests__/*` | Include useful generated coverage temporarily while test strategy evolves. |
@@ -42,3 +42,13 @@ This document tracks candidate updates identified from TinyPen that may be upstr
4242
3. Full test suite passes before merge.
4343
4. Dependency changes include runtime verification notes.
4444
5. Uncertain candidates remain blocked until evidence is upgraded from investigate-first to upstream-now.
45+
46+
## Deferred Test Cases
47+
48+
These scenarios are worth adding as follow-up coverage, but do not block the current implementation slice.
49+
50+
1. `create`, `update`, `delete`, and `overwrite` should each preserve upstream text error bodies and status codes.
51+
2. `update`, `delete`, and `overwrite` should each map network failures to `502 Bad Gateway`.
52+
3. `overwrite` should preserve its special `409` JSON conflict response when `If-Overwritten-Version` negotiation fails.
53+
4. `delete/:id` should verify upstream non-2xx responses are propagated through the shared messenger instead of failing locally.
54+
5. Shared error handling should verify fallback behavior for generic thrown `Error` objects without upstream response metadata.

error-messenger.js

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Errors from RERUM are a response code with a text body (except those handled specifically upstream)
33
* We want to send the same error code and message through. It is assumed to be RESTful and useful.
4+
* This is a fallback for unhandled errors from RERUM, and should not be used for expected errors that are handled upstream (e.g. 409 conflict for version mismatch on overwrite). It will also handle network errors that occur when trying to reach RERUM, which will be mapped to a 502 Bad Gateway with a message indicating a RERUM network error.
45
* This will also handle generic (500) app level errors, as well as app level 404 errors.
56
*
67
* @param rerum_error_res A Fetch API Response object from a fetch() to RERUM that encountered an error. Explanatory text is in .text(). In some cases it is a unhandled generic (500) app level Error.
@@ -9,26 +10,40 @@
910
* @param next The Express next() operator, unused here but required to support the middleware chain.
1011
*/
1112
export async function messenger(rerum_error_res, req, res, next) {
12-
if (res.headersSent) {
13-
return
14-
}
15-
let error = {}
16-
let rerum_err_text
17-
try {
18-
// Unless already handled upstream the rerum_error_res is an error Response with details as a textual body.
19-
rerum_err_text = await rerum_error_res.text()
20-
}
21-
catch (err) {
22-
// It is some 500
23-
rerum_err_text = undefined
13+
if (res.headersSent) {
14+
return
15+
}
16+
17+
const error = {
18+
message: rerum_error_res.statusMessage ?? rerum_error_res.message ?? "A server error has occurred",
19+
status: rerum_error_res.statusCode ?? rerum_error_res.status ?? 500,
20+
}
21+
22+
if (rerum_error_res.payload !== undefined) {
23+
console.error(error)
24+
res.status(error.status).json(rerum_error_res.payload)
25+
return
26+
}
27+
28+
try {
29+
const contentType = rerum_error_res.headers?.get?.("Content-Type")?.toLowerCase?.() ?? ""
30+
if (contentType.includes("json")) {
31+
error.body = await rerum_error_res.json()
32+
console.error(error)
33+
res.status(error.status).json(error.body)
34+
return
2435
}
25-
if (rerum_err_text) error.message = rerum_err_text
26-
else {
27-
// Perhaps this is a more generic 500 from the app, perhaps involving RERUM, and there is no good rerum_error_res
28-
error.message = rerum_error_res.statusMessage ?? rerum_error_res.message ?? `A server error has occurred`
36+
37+
const rerumErrText = await rerum_error_res.text()
38+
if (rerumErrText) {
39+
error.message = rerumErrText
2940
}
30-
error.status = rerum_error_res.statusCode ?? rerum_error_res.status ?? 500
31-
console.error(error)
32-
res.set("Content-Type", "text/plain; charset=utf-8")
33-
res.status(error.status).send(error.message)
41+
}
42+
catch (err) {
43+
// Fall back to the status/message values already collected above.
44+
}
45+
46+
console.error(error)
47+
res.set("Content-Type", "text/plain; charset=utf-8")
48+
res.status(error.status).send(error.message)
3449
}

rerum.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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+
const signal = options.signal
45+
? AbortSignal.any([options.signal, timeoutController.signal])
46+
: timeoutController.signal
47+
48+
try {
49+
return await fetch(url, { ...options, signal })
50+
}
51+
catch (err) {
52+
if (err?.name === "AbortError" && timeoutController.signal.aborted) {
53+
throw createRerumTimeoutError(url, timeoutMs)
54+
}
55+
throw createRerumNetworkError(url)
56+
}
57+
finally {
58+
clearTimeout(timeoutId)
59+
}
60+
}
61+
62+
export { createRerumNetworkError, fetchRerum }

rest.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ const acceptedJsonContentTypes = new Set([
33
"application/ld+json"
44
])
55

6+
export function httpError(message, status = 500, payload) {
7+
const error = new Error(message)
8+
error.status = status
9+
if (payload !== undefined) {
10+
error.payload = payload
11+
}
12+
return error
13+
}
14+
615
function getContentType(req) {
716
const rawContentType = req.headers?.["content-type"]
817
if (!rawContentType) {

routes/__tests__/create.test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import request from "supertest"
33
import { jest } from "@jest/globals"
44

55
import createRoute from "../create.js"
6+
import { messenger } from "../../error-messenger.js"
67
//import app from "../../app.js"
78

89
const routeTester = new express()
910
routeTester.use(express.json())
1011
routeTester.use(express.urlencoded({ extended: false }))
1112
routeTester.use("/create", createRoute)
1213
routeTester.use("/app/create", createRoute)
14+
routeTester.use(messenger)
1315

1416
const rerum_uri = `${process.env.RERUM_ID_PATTERN}_not_`
1517

@@ -108,6 +110,41 @@ describe("Check that incorrect TinyNode create route usage results in expected R
108110
})
109111
})
110112

113+
describe("Check that TinyNode create route propagates upstream and network errors predictably. __rest __core", () => {
114+
it("Preserves upstream text errors and maps network failures to 502.", async () => {
115+
global.fetch = jest.fn(() =>
116+
Promise.resolve({
117+
ok: false,
118+
status: 503,
119+
headers: {
120+
get: () => "text/plain; charset=utf-8"
121+
},
122+
text: () => Promise.resolve("Upstream create failure")
123+
})
124+
)
125+
126+
let response = await request(routeTester)
127+
.post("/create")
128+
.set("Content-Type", "application/json")
129+
.send({ "test": "item" })
130+
.then(resp => resp)
131+
.catch(err => err)
132+
expect(response.statusCode).toBe(502)
133+
expect(response.text).toContain("Upstream create failure")
134+
135+
global.fetch = jest.fn(() => Promise.reject(new Error("socket hang up")))
136+
137+
response = await request(routeTester)
138+
.post("/create")
139+
.set("Content-Type", "application/json")
140+
.send({ "test": "item" })
141+
.then(resp => resp)
142+
.catch(err => err)
143+
expect(response.statusCode).toBe(502)
144+
expect(response.text).toContain("A RERUM error occurred")
145+
})
146+
})
147+
111148
/**
112149
* Full integration test. Checks the TinyNode app create endpoint functionality and RERUM connection.
113150
*
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import express from "express"
2+
import request from "supertest"
3+
import { messenger } from "../../error-messenger.js"
4+
5+
const app = express()
6+
7+
app.get("/json-error", (req, res, next) => {
8+
next({
9+
status: 422,
10+
headers: {
11+
get: () => "application/json"
12+
},
13+
json: async () => ({ message: "Invalid payload", field: "name" })
14+
})
15+
})
16+
17+
app.use(messenger)
18+
19+
describe("Check shared error messenger behavior. __rest __core", () => {
20+
it("Returns structured JSON error bodies when upstream responds with JSON.", async () => {
21+
const response = await request(app)
22+
.get("/json-error")
23+
.then(resp => resp)
24+
.catch(err => err)
25+
26+
expect(response.statusCode).toBe(422)
27+
expect(response.body.message).toBe("Invalid payload")
28+
expect(response.body.field).toBe("name")
29+
})
30+
})

routes/__tests__/mount.test.js

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,32 @@ afterEach(() => {
3232
* @returns {boolean} - True if all routes are found
3333
*/
3434
function routeExists(routes) {
35-
const stack = app.router.stack
3635
const foundRoutes = new Set()
37-
for (const layer of stack) {
38-
if (layer.matchers && layer.matchers[0]) {
39-
const matcher = layer.matchers[0]
40-
// Test each route against this layer's matcher
41-
for (const route of routes) {
42-
if (matcher(route)) foundRoutes.add(route)
36+
const scanStack = (stack = []) => {
37+
for (const layer of stack) {
38+
if (layer.matchers && layer.matchers[0]) {
39+
const matcher = layer.matchers[0]
40+
for (const route of routes) {
41+
if (matcher(route)) foundRoutes.add(route)
42+
}
4343
}
44-
}
45-
// Also check route.path directly if it exists
46-
if (layer.route && layer.route.path) {
47-
for (const route of routes) {
48-
if (layer.route.path === route) foundRoutes.add(route)
44+
if (layer.regexp) {
45+
for (const route of routes) {
46+
if (layer.regexp.test(route)) foundRoutes.add(route)
47+
}
48+
}
49+
if (layer.route && layer.route.path) {
50+
for (const route of routes) {
51+
if (layer.route.path === route) foundRoutes.add(route)
52+
}
53+
}
54+
if (layer.handle?.stack) {
55+
scanStack(layer.handle.stack)
4956
}
5057
}
5158
}
59+
60+
scanStack(app._router?.stack)
5261
// Check if all expected routes were found
5362
return routes.every(route => foundRoutes.has(route))
5463
}

routes/__tests__/query.test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import express from "express"
22
import request from "supertest"
33
import { jest } from "@jest/globals"
44
import queryRoute from "../query.js"
5+
import { messenger } from "../../error-messenger.js"
56
//import app from "../../app.js"
67

78
const routeTester = new express()
89
routeTester.use(express.json())
910
routeTester.use(express.urlencoded({ extended: false }))
1011
routeTester.use("/query", queryRoute)
1112
routeTester.use("/app/query", queryRoute)
13+
routeTester.use(messenger)
1214

1315
const rerum_uri = `${process.env.RERUM_ID_PATTERN}_not_`
1416

routes/create.js

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,55 @@
11
import express from "express"
22
import checkAccessToken from "../tokens.js"
33
import { verifyJsonContentType } from "../rest.js"
4+
import { createRerumNetworkError, fetchRerum } from "../rerum.js"
45
const router = express.Router()
56

67
/* POST a create to the thing. */
78
router.post('/', verifyJsonContentType, checkAccessToken, async (req, res, next) => {
89

910
try {
11+
// if an id is passed in, pop off the end to make it an _id
12+
if (req.body.id) {
13+
req.body._id = req.body.id.split('/').pop()
14+
}
15+
1016
// check body for JSON
11-
const body = JSON.stringify(req.body)
17+
const createBody = JSON.stringify(req.body)
1218
const createOptions = {
1319
method: 'POST',
14-
body,
20+
body: createBody,
1521
headers: {
1622
'user-agent': 'Tiny-Things/1.0',
23+
'Origin': process.env.ORIGIN,
1724
'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`,
1825
'Content-Type' : "application/json;charset=utf-8"
1926
}
2027
}
2128
const createURL = `${process.env.RERUM_API_ADDR}create`
22-
let errored = false
23-
const result = await fetch(createURL, createOptions).then(res=>{
24-
if (res.ok) return res.json()
25-
errored = true
26-
return res
27-
})
28-
.catch(err => {
29+
const rerumResponse = await fetchRerum(createURL, createOptions)
30+
.then(async (resp) => {
31+
if (resp.ok) return resp.json()
32+
// The response from RERUM indicates a failure, likely with a specific code and textual body
33+
let rerumErrorMessage
34+
try {
35+
rerumErrorMessage = `${resp.status ?? 500}: ${createURL} - ${await resp.text()}`
36+
} catch (e) {
37+
rerumErrorMessage = `500: ${createURL} - A RERUM error occurred`
38+
}
39+
const err = new Error(rerumErrorMessage)
40+
err.status = 502
2941
throw err
3042
})
31-
// Send RERUM error responses to error-messenger.js
32-
if (errored) return next(result)
33-
const location = result?.["@id"] ?? result?.id
34-
const responseBody = { ...req.body, ...(result ?? {}) }
35-
if (location) {
36-
res.setHeader("Location", location)
43+
if (!(rerumResponse.id || rerumResponse["@id"])) {
44+
// A 200 with garbled data, call it a fail
45+
throw createRerumNetworkError(createURL)
3746
}
38-
res.status(201).json(responseBody)
47+
res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id)
48+
res.status(201).json(rerumResponse)
3949
}
4050
catch (err) {
41-
next(err)
51+
console.error(err)
52+
res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred')
4253
}
4354
})
4455

0 commit comments

Comments
 (0)