rework error handling 💥📝

This commit is contained in:
Jannis R 2022-05-03 23:21:44 +02:00
parent 0275b65c7a
commit 9b263bb379
No known key found for this signature in database
GPG key ID: 0FE83946296A88A5
9 changed files with 435 additions and 149 deletions

View file

@ -135,6 +135,44 @@ const client = createClient({
The default `profile.logRequest` [`console.error`](https://nodejs.org/docs/latest-v10.x/api/console.html#console_console_error_data_args)s the request body, if you have set `$DEBUG` to `hafas-client`. Likewise, `profile.logResponse` `console.error`s the response body.
## Error handling
Unexpected errors e.g. due to bugs in `hafas-client` itself aside, its methods may reject with the following errors:
- `HafasError` A generic error to signal that something HAFAS-related went wrong, either in the client, or in the HAFAS endpoint.
- `HafasAccessDeniedError` The HAFAS endpoint has rejected your request because you're not allowed to access it (or the specific API call). Subclass of `HafasError`.
- `HafasInvalidRequestError` The HAFAS endpoint reports that an invalid request has been sent. Subclass of `HafasError`.
- `HafasNotFoundError` The HAFAS endpoint does not know about such stop/trip/etc. Subclass of `HafasError`.
- `HafasServerError` An error occured within the HAFAS endpoint, so that it can't fulfill the request; For example, this happens when HAFAS' internal backend is unavailable. Subclass of `HafasError`.
Each error has the following properties:
- `isHafasError` Always `true`. Allows you to differente HAFAS-related errors from e.g. network errors.
- `code` A string representing the error type for all other error classes, e.g. `INVALID_REQUEST` for `HafasInvalidRequestError`. `null` for plain `HafasError`s.
- `isCausedByServer` Boolean, telling you if the HAFAS endpoint says that it couldn't process your request because *it* is unavailable/broken.
- `hafasCode` A HAFAS-specific error code, if the HAFAS endpoint returned one; e.g. `H890` when no journeys could be found. `null` otherwise.
- `request` The [Fetch API `Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) of the request.
- `url` The URL of the request.
- `response` The [Fetch API `Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
To check **if an error from `hafas-client` is HAFAS-specific, use `error instanceof HafasError`**:
```js
const {HafasError} = require('hafas-client/lib/errors')
try {
await client.journeys(/* … */)
} catch (err) {
if (err instanceof HafasError) {
// HAFAS-specific error
} else {
// different error, e.g. network (ETIMEDOUT, ENETDOWN)
}
}
```
To determine **if you should automatically retry an error, use `!error.causedByServer`**.
## Using `hafas-client` from another language
If you want to use `hafas-client` to access HAFAS APIs but work with non-Node.js environments, you can use [`hafas-client-rpc`](https://github.com/derhuerst/hafas-client-rpc) to create a [JSON-RPC](https://www.jsonrpc.org) interface that you can send commands to.

View file

@ -8,6 +8,7 @@ const defaultProfile = require('./lib/default-profile')
const validateProfile = require('./lib/validate-profile')
const {INVALID_REQUEST} = require('./lib/errors')
const sliceLeg = require('./lib/slice-leg')
const {HafasError} = require('./lib/errors')
const isNonEmptyString = str => 'string' === typeof str && str.length > 0
@ -248,11 +249,7 @@ const createClient = (profile, userAgent, opt = {}) => {
const {res, common} = await profile.request({profile, opt}, userAgent, req)
if (!Array.isArray(res.outConL) || !res.outConL[0]) {
const err = new Error('invalid response')
// technically this is not a HAFAS error
// todo: find a different flag with decent DX
err.isHafasError = true
throw err
throw new HafasError('invalid response, expected outConL[0]', null, {})
}
const ctx = {profile, opt, common, res}
@ -427,14 +424,10 @@ const createClient = (profile, userAgent, opt = {}) => {
const {res, common} = await profile.request({profile, opt}, userAgent, req)
if (!res || !Array.isArray(res.locL) || !res.locL[0]) {
// todo: proper stack trace?
// todo: DRY with lib/request.js
const err = new Error('response has no stop')
// technically this is not a HAFAS error
// todo: find a different flag with decent DX
err.isHafasError = true
err.code = INVALID_REQUEST
throw err
throw new HafasError('invalid response, expected locL[0]', null, {
// This problem occurs on invalid input. 🙄
code: INVALID_REQUEST,
})
}
const ctx = {profile, opt, res, common}
@ -622,9 +615,9 @@ const createClient = (profile, userAgent, opt = {}) => {
const {res, common} = await profile.request({profile, opt}, userAgent, req)
if (!Array.isArray(res.posL)) {
const err = new Error('invalid response')
err.shouldRetry = true
throw err
throw new HafasError('invalid response, expected posL[0]', null, {
shouldRetry: true,
})
}
const byDuration = []

44
lib/check-if-res-is-ok.js Normal file
View file

@ -0,0 +1,44 @@
'use strict'
const {HafasError, byErrorCode} = require('./errors')
const checkIfResponseIsOk = (_) => {
const {
body,
errProps: baseErrProps,
} = _
const errProps = {
...baseErrProps,
}
if (body.id) errProps.hafasResponseId = body.id
// Because we want more accurate stack traces, we don't construct the error here,
// but only return the constructor & error message.
const getError = (_) => {
// mutating here is ugly but pragmatic
if (_.errTxt) errProps.hafasMessage = _.errTxt
if (_.errTxtOut) errProps.hafasDescription = _.errTxtOut
if (_.err in byErrorCode) return byErrorCode[_.err]
return {
Error: HafasError,
message: body.errTxt || 'unknown error',
props: {},
}
}
if (body.err && body.err !== 'OK') {
const {Error: HafasError, message, props} = getError(body)
throw new HafasError(message, body.err, {...errProps, ...props})
}
if (!body.svcResL || !body.svcResL[0]) {
throw new HafasError('invalid/unsupported response structure', null, errProps)
}
if (body.svcResL[0].err !== 'OK') {
const {Error: HafasError, message, props} = getError(body.svcResL[0])
throw new HafasError(message, body.svcResL[0].err, {...errProps, ...props})
}
}
module.exports = checkIfResponseIsOk

View file

@ -5,205 +5,268 @@ const INVALID_REQUEST = 'INVALID_REQUEST'
const NOT_FOUND = 'NOT_FOUND'
const SERVER_ERROR = 'SERVER_ERROR'
class HafasError extends Error {
constructor (cleanMessage, hafasCode, props) {
const msg = hafasCode
? hafasCode + ': ' + cleanMessage
: cleanMessage
super(msg)
// generic props
this.isHafasError = true
// error-specific props
this.code = null
// By default, we take the blame, unless we know for sure.
this.isCausedByServer = false
this.hafasCode = hafasCode
Object.assign(this, props)
return this
}
}
class HafasAccessDeniedError extends HafasError {
constructor (cleanMessage, hafasCode, props) {
super(cleanMessage, hafasCode, props)
this.code = ACCESS_DENIED
return this
}
}
class HafasInvalidRequestError extends HafasError {
constructor (cleanMessage, hafasCode, props) {
super(cleanMessage, hafasCode, props)
this.code = INVALID_REQUEST
return this
}
}
class HafasNotFoundError extends HafasError {
constructor (cleanMessage, hafasCode, props) {
super(cleanMessage, hafasCode, props)
this.code = NOT_FOUND
return this
}
}
class HafasServerError extends HafasError {
constructor (cleanMessage, hafasCode, props) {
super(cleanMessage, hafasCode, props)
this.code = SERVER_ERROR
this.isCausedByServer = true
return this
}
}
// https://gist.github.com/derhuerst/79d49c0f04c1c192a5d15756e5af575f/edit
// todo:
// `code: 'METHOD_NA', message: 'HCI Service: service method disabled'`
// "err": "PARAMETER", "errTxt": "HCI Service: parameter invalid"
// "err": "PARAMETER", "errTxt": "HCI Service: parameter invalid","errTxtOut":"Während der Suche ist ein interner Fehler aufgetreten"
const byErrorCode = Object.assign(Object.create(null), {
H_UNKNOWN: {
isServer: false,
code: SERVER_ERROR,
Error: HafasError,
message: 'unknown internal error',
statusCode: 500,
props: {
shouldRetry: true,
},
},
AUTH: {
isClient: true,
code: ACCESS_DENIED,
Error: HafasAccessDeniedError,
message: 'invalid or missing authentication data',
statusCode: 401
props: {
},
},
R0001: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'unknown method',
statusCode: 400
props: {
},
},
R0002: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'invalid or missing request parameters',
statusCode: 400
props: {
},
},
R0007: {
isServer: true,
code: SERVER_ERROR,
Error: HafasServerError,
message: 'internal communication error',
statusCode: 500
props: {
shouldRetry: true,
},
},
R5000: {
isClient: true,
code: ACCESS_DENIED,
Error: HafasAccessDeniedError,
message: 'access denied',
statusCode: 401
props: {
},
},
S1: {
isServer: true,
code: SERVER_ERROR,
Error: HafasServerError,
message: 'journeys search: a connection to the backend server couldn\'t be established',
statusCode: 503
props: {
shouldRetry: true,
},
},
LOCATION: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasNotFoundError,
message: 'location/stop not found',
statusCode: 400
props: {
},
},
H390: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: departure/arrival station replaced',
statusCode: 400
props: {
},
},
H410: {
// todo: or is it a client error?
// todo: statusCode?
isServer: true,
code: SERVER_ERROR,
message: 'journeys search: incomplete response due to timetable change'
Error: HafasServerError,
message: 'journeys search: incomplete response due to timetable change',
props: {
},
},
H455: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: prolonged stop',
statusCode: 400
props: {
},
},
H460: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: stop(s) passed multiple times',
statusCode: 400
props: {
},
},
H500: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: too many trains, connection is not complete',
statusCode: 400
props: {
},
},
H890: {
isClient: true,
code: NOT_FOUND,
Error: HafasNotFoundError,
message: 'journeys search unsuccessful',
statusCode: 404
props: {
shouldRetry: true,
},
},
H891: {
isClient: true,
code: NOT_FOUND,
Error: HafasNotFoundError,
message: 'journeys search: no route found, try with an intermediate stations',
statusCode: 404
props: {
},
},
H892: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: query too complex, try less intermediate stations',
statusCode: 400
props: {
},
},
H895: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: departure & arrival are too near',
statusCode: 400
props: {
},
},
H899: {
// todo: or is it a client error?
// todo: statusCode?
isServer: true,
code: SERVER_ERROR,
message: 'journeys search unsuccessful or incomplete due to timetable change'
Error: HafasServerError,
message: 'journeys search unsuccessful or incomplete due to timetable change',
props: {
},
},
H900: {
// todo: or is it a client error?
// todo: statusCode?
isServer: true,
code: SERVER_ERROR,
message: 'journeys search unsuccessful or incomplete due to timetable change'
Error: HafasServerError,
message: 'journeys search unsuccessful or incomplete due to timetable change',
props: {
},
},
H9220: {
isClient: true,
code: NOT_FOUND,
Error: HafasNotFoundError,
message: 'journeys search: no stations found close to the address',
statusCode: 400
props: {
},
},
H9230: {
isServer: true,
code: SERVER_ERROR,
Error: HafasServerError,
message: 'journeys search: an internal error occured',
statusCode: 500
props: {
shouldRetry: true,
},
},
H9240: {
isClient: true,
code: NOT_FOUND,
Error: HafasNotFoundError,
message: 'journeys search unsuccessful',
statusCode: 404
props: {
shouldRetry: true,
},
},
H9250: {
isServer: true,
code: SERVER_ERROR,
Error: HafasServerError,
message: 'journeys search: leg query interrupted',
statusCode: 500
props: {
shouldRetry: true,
},
},
H9260: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: unknown departure station',
statusCode: 400
props: {
},
},
H9280: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: unknown intermediate station',
statusCode: 400
props: {
},
},
H9300: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: unknown arrival station',
statusCode: 400
props: {
},
},
H9320: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: the input is incorrect or incomplete',
statusCode: 400
props: {
},
},
H9360: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: invalid date/time',
statusCode: 400
props: {
},
},
H9380: {
isClient: true,
code: INVALID_REQUEST,
Error: HafasInvalidRequestError,
message: 'journeys search: departure/arrival/intermediate station defined more than once',
statusCode: 400
props: {
},
},
SQ001: {
isServer: true,
code: SERVER_ERROR,
Error: HafasServerError,
message: 'no departures/arrivals data available',
statusCode: 503
props: {
},
},
SQ005: {
isClient: true,
code: NOT_FOUND,
Error: HafasNotFoundError,
message: 'no trips found',
statusCode: 404
props: {
},
},
TI001: {
isServer: true,
code: SERVER_ERROR,
Error: HafasServerError,
message: 'no trip info available',
statusCode: 503
props: {
shouldRetry: true,
},
}
})
@ -212,5 +275,10 @@ module.exports = {
INVALID_REQUEST,
NOT_FOUND,
SERVER_ERROR,
HafasError,
HafasAccessDeniedError,
HafasInvalidRequestError,
HafasNotFoundError,
HafasServerError,
byErrorCode,
}

View file

@ -10,7 +10,8 @@ const pick = require('lodash/pick')
const {stringify} = require('qs')
const {Request, fetch} = require('cross-fetch')
const {parse: parseContentType} = require('content-type')
const {byErrorCode} = require('./errors')
const {HafasError} = require('./errors')
const checkIfResponseIsOk = require('./check-if-res-is-ok')
const proxyAddress = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || null
const localAddresses = process.env.LOCAL_ADDRESS || null
@ -127,15 +128,14 @@ const request = async (ctx, userAgent, reqData) => {
const res = await fetch(url, req)
// Async stack traces are not supported everywhere yet, so we create our own.
const errProps = {
isHafasError: true, // todo: rename to `isHafasClientError`
statusCode: res.status,
request: req.body, // todo [breaking]: change to fetchReq
url: url,
request: fetchReq,
response: res,
url,
}
if (!res.ok) {
// todo [breaking]: make this a FetchError or a HafasClientError?
const err = new Error(res.statusText)
Object.assign(err, errProps)
throw err
@ -145,9 +145,7 @@ const request = async (ctx, userAgent, reqData) => {
if (cType) {
const {type} = parseContentType(cType)
if (type !== 'application/json') {
const err = new Error('invalid response content-type: ' + cType)
err.response = res
throw err
throw new HafasError('invalid/unsupported response content-type: ' + cType, null, errProps)
}
}
@ -155,30 +153,10 @@ const request = async (ctx, userAgent, reqData) => {
profile.logResponse(ctx, res, body, reqId)
const b = JSON.parse(body)
if (b.err) errProps.hafasErrorCode = b.err
if (b.errTxt) errProps.hafasErrorMessage = b.errTxt
if (b.id) errProps.responseId = b.id
if (b.err && b.err !== 'OK') {
const err = new Error(b.errTxt || b.err)
Object.assign(err, errProps)
if (b.err in byErrorCode) {
Object.assign(err, byErrorCode[b.err])
}
throw err
}
if (!b.svcResL || !b.svcResL[0]) {
const err = new Error('invalid/unsupported response structure')
Object.assign(err, errProps)
throw err
}
if (b.svcResL[0].err !== 'OK') {
const err = new Error(b.svcResL[0].errTxt || b.svcResL[0].err)
Object.assign(err, errProps)
if (b.svcResL[0].err in byErrorCode) {
Object.assign(err, byErrorCode[b.svcResL[0].err])
}
throw err
}
checkIfResponseIsOk({
body: b,
errProps,
})
const svcRes = b.svcResL[0].res
return {

View file

@ -70,7 +70,7 @@
},
"scripts": {
"lint": "eslint .",
"test-unit": "tap test/*.js test/format/*.js test/parse/*.js",
"test-unit": "tap test/lib/*.js test/*.js test/format/*.js test/parse/*.js",
"test-integration": "VCR_MODE=playback tap test/e2e/*.js",
"test-integration:record": "VCR_MODE=record tap -t60 -j16 test/e2e/*.js",
"test-e2e": "VCR_OFF=true tap -t60 -j16 test/e2e/*.js",

28
test/fixtures/error-h9360.json vendored Normal file
View file

@ -0,0 +1,28 @@
{
"ver": "1.44",
"ext": "BVG.1",
"lang": "deu",
"id": "3xmggksuiukkx6wx",
"err": "OK",
"graph": {
"id": "standard",
"index": 0
},
"subGraph": {
"id": "global",
"index": 0
},
"view": {
"id": "standard",
"index": 0,
"type": "WGS84"
},
"svcResL": [
{
"meth": "StationBoard",
"err": "H9360",
"errTxt": "HAFAS Kernel: Date outside of the timetable period.",
"errTxtOut": "Fehler bei der Datumseingabe oder Datum außerhalb der Fahrplanperiode (01.05.2022 - 10.12.2022)"
}
]
}

28
test/fixtures/error-location.json vendored Normal file
View file

@ -0,0 +1,28 @@
{
"ver": "1.44",
"ext": "BVG.1",
"lang": "deu",
"id": "mmmxakseiuk9g6wg",
"err": "OK",
"graph": {
"id": "standard",
"index": 0
},
"subGraph": {
"id": "global",
"index": 0
},
"view": {
"id": "standard",
"index": 0,
"type": "WGS84"
},
"svcResL": [
{
"meth": "StationBoard",
"err": "LOCATION",
"errTxt": "HCI Service: location missing or invalid",
"errTxtOut": "Während der Suche ist ein interner Fehler aufgetreten"
}
]
}

View file

@ -0,0 +1,109 @@
'use strict'
const tap = require('tap')
const checkIfResIsOk = require('../../lib/check-if-res-is-ok')
const {
INVALID_REQUEST,
NOT_FOUND,
HafasError,
HafasInvalidRequestError,
HafasNotFoundError,
} = require('../../lib/errors')
const resParameter = require('../fixtures/error-parameter.json')
const resNoMatch = require('../fixtures/error-no-match.json')
const resH9360 = require('../fixtures/error-h9360.json')
const resLocation = require('../fixtures/error-location.json')
const secret = Symbol('secret')
tap.test('checkIfResponseIsOk properly throws HAFAS "H9360" errors', (t) => {
try {
checkIfResIsOk({
body: resH9360,
errProps: {secret},
})
} catch (err) {
t.ok(err)
t.ok(err instanceof HafasError)
t.equal(err.isHafasError, true)
t.equal(err.message.slice(0, 7), 'H9360: ')
t.ok(err.message.length > 7)
t.ok(err instanceof HafasInvalidRequestError)
t.equal(err.isCausedByServer, false)
t.equal(err.code, INVALID_REQUEST)
t.equal(err.hafasCode, 'H9360')
t.equal(err.hafasResponseId, resH9360.id)
t.equal(err.hafasMessage, 'HAFAS Kernel: Date outside of the timetable period.')
t.equal(err.hafasDescription, 'Fehler bei der Datumseingabe oder Datum außerhalb der Fahrplanperiode (01.05.2022 - 10.12.2022)')
t.equal(err.secret, secret)
t.end()
}
})
tap.test('checkIfResponseIsOk properly throws HAFAS "LOCATION" errors', (t) => {
try {
checkIfResIsOk({
body: resLocation,
errProps: {secret},
})
} catch (err) {
t.ok(err)
t.ok(err instanceof HafasError)
t.equal(err.isHafasError, true)
t.equal(err.message.slice(0, 10), 'LOCATION: ')
t.ok(err.message.length > 10)
t.ok(err instanceof HafasNotFoundError)
t.equal(err.isCausedByServer, false)
t.equal(err.code, NOT_FOUND)
t.equal(err.hafasCode, 'LOCATION')
t.equal(err.hafasResponseId, resLocation.id)
t.equal(err.hafasMessage, 'HCI Service: location missing or invalid')
t.equal(err.hafasDescription, 'Während der Suche ist ein interner Fehler aufgetreten')
t.equal(err.secret, secret)
t.end()
}
})
tap.test('checkIfResponseIsOk properly parses an unknown HAFAS errors', (t) => {
const body = {
ver: '1.42',
id: '1234567890',
err: 'FOO',
errTxt: 'random errTxt',
errTxtOut: 'even more random errTxtOut',
svcResL: [],
}
try {
checkIfResIsOk({
body,
errProps: {secret},
})
} catch (err) {
t.ok(err)
t.ok(err instanceof HafasError)
t.equal(err.isHafasError, true)
t.equal(err.message, `${body.err}: ${body.errTxt}`)
t.equal(err.isCausedByServer, false)
t.equal(err.code, null)
t.equal(err.hafasCode, body.err)
t.equal(err.hafasResponseId, body.id)
t.equal(err.hafasMessage, body.errTxt)
t.equal(err.hafasDescription, body.errTxtOut)
t.equal(err.secret, secret)
t.end()
}
})