refactor: APIError handling

This commit is contained in:
axolotle 2024-08-05 16:38:55 +02:00
parent 254e1aca56
commit 9f8ee2f250
7 changed files with 85 additions and 110 deletions

View file

@ -3,15 +3,16 @@
* @module api/errors
*/
import type { APIRequest } from '@/composables/useRequests'
import i18n from '@/i18n'
import type { APIErrorData, RequestMethod, APIRequest } from './api'
import type { APIErrorData, RequestMethod } from './api'
class APIError extends Error {
name = 'APIError'
code: number
status: string
method: RequestMethod
request: APIRequest
requestId: string
path: string
constructor(
@ -28,7 +29,7 @@ class APIError extends Error {
this.code = status
this.status = statusText
this.method = request.method
this.request = request
this.requestId = request.id
this.path = urlObj.pathname + urlObj.search
}
@ -152,13 +153,13 @@ const errors = {
}
export {
errors as default,
APIError,
APIErrorLog,
APIBadRequestError,
APIConnexionError,
APIError,
APIErrorLog,
APIInternalError,
APINotFoundError,
APINotRespondingError,
APIUnauthorizedError,
errors as default,
}

View file

@ -3,7 +3,7 @@
* @module api/handlers
*/
import errors, { APIError } from '@/api/errors'
import errors from '@/api/errors'
import {
STATUS_VARIANT,
type APIRequest,
@ -98,39 +98,6 @@ export function getError(
errorCode = 'log'
}
// This error can be catched by a view otherwise it will be catched by the `onUnhandledAPIError` handler.
// This error can be catched by a view otherwise it will be catched by the global error handler.
return new errors[errorCode](request, response, errorData)
}
/**
* If an APIError is not catched by a view it will be dispatched to the store so the
* error can be displayed in the error modal.
*/
export function onUnhandledAPIError(error: APIError) {
error.log()
store.dispatch('HANDLE_ERROR', error)
}
/**
* Global catching of unhandled promise's rejections.
* Those errors (thrown or rejected from inside a promise) can't be catched by
* `window.onerror`.
*/
export function registerGlobalErrorHandlers() {
window.addEventListener('unhandledrejection', (e) => {
const error = e.reason
if (error instanceof APIError) {
onUnhandledAPIError(error)
// Seems like there's a bug in Firefox and the error logging in not prevented.
e.preventDefault()
}
})
// Keeping this in case it is needed.
// Global catching of errors occuring inside vue components.
// Vue.config.errorHandler = (err, vm, info) => {}
// Global catching of regular js errors.
// window.onerror = (message, source, lineno, colno, error) => {}
}

View file

@ -1,2 +1,2 @@
export { default, objectToParams } from './api'
export { getError, registerGlobalErrorHandlers } from './handlers'
export { getError } from './handlers'

View file

@ -1,11 +1,13 @@
import { createGlobalState } from '@vueuse/core'
import { v4 as uuid } from 'uuid'
import { computed, reactive, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import type { APIQuery, RequestMethod } from '@/api/api'
import { type APIError } from '@/api/errors'
import { APIErrorLog, type APIError } from '@/api/errors'
import { isObjectLiteral } from '@/helpers/commons'
import i18n from '@/i18n'
import store from '@/store'
import type { StateVariant } from '@/types/commons'
export type RequestStatus = 'pending' | 'success' | 'warning' | 'error'
@ -47,6 +49,8 @@ export const STATUS_VARIANT = {
} as const
export const useRequests = createGlobalState(() => {
const router = useRouter()
const requests = shallowRef<APIRequest[]>([])
const currentRequest = computed(() => {
return requests.value.find((r) => r.showModal)
@ -146,6 +150,41 @@ export const useRequests = createGlobalState(() => {
}, 350)
}
function handleAPIError(err: APIError) {
err.log()
if (err.code === 401) {
// Unauthorized
store.dispatch('DISCONNECT')
} else if (err instanceof APIErrorLog) {
// Errors that have produced logs
router.push({ name: 'tool-log', params: { name: err.logRef } })
} else {
const request = requests.value.find((r) => r.id === err.requestId)!
request.err = err
}
}
function showModal(requestId: APIRequest['id']) {
const request = requests.value.find((r) => r.id === requestId)!
request.showModal = true
}
function dismissModal(requestId: APIRequest['id']) {
const request = requests.value.find((r) => r.id === requestId)!
if (request.err && request.initial) {
// In case of an initial request (data that is needed by a view to render itself),
// try to go back so the user doesn't get stuck at a never ending skeleton view.
if (history.length > 2) {
history.back()
} else {
// if the url was opened in a new tab, return to home
router.push({ name: 'home' })
}
}
request.showModal = false
}
return {
requests,
historyList,
@ -153,5 +192,8 @@ export const useRequests = createGlobalState(() => {
locked,
startRequest,
endRequest,
handleAPIError,
dismissModal,
showModal,
}
})

View file

@ -1,15 +1,15 @@
import { createApp, type Component } from 'vue'
import App from './App.vue'
import { createBootstrap } from 'bootstrap-vue-next'
import { VueShowdownPlugin } from 'vue-showdown'
import { watchOnce } from '@vueuse/core'
import { createBootstrap } from 'bootstrap-vue-next'
import { createApp, type Component } from 'vue'
import { VueShowdownPlugin } from 'vue-showdown'
import App from './App.vue'
import { APIError } from './api/errors'
import { useRequests } from './composables/useRequests'
import { useSettings } from './composables/useSettings'
import store from './store'
import router from './router'
import i18n from './i18n'
import { registerGlobalErrorHandlers } from './api'
import router from './router'
import store from './store'
import '@/scss/main.scss'
@ -17,6 +17,24 @@ type Module = { default: Component }
const app = createApp(App)
// Error catching
function onError(err: unknown) {
if (err instanceof APIError) {
useRequests().handleAPIError(err)
} else {
// FIXME Error modal for internal code error?
throw err
}
}
app.config.errorHandler = (err) => onError(err)
window.addEventListener('unhandledrejection', (e) => {
// Global catching of unhandled promise's rejections.
// Those errors (thrown or rejected from inside a promise) can't be catched by
// `window.onerror` or vue.
e.preventDefault()
onError(e.reason)
})
app.use(store)
app.use(router)
app.use(i18n)
@ -46,7 +64,5 @@ Object.values(globalComponentsModules).forEach(
},
)
registerGlobalErrorHandlers()
// Load default locales translations files then mount the app
watchOnce(useSettings().localesLoaded, () => app.mount('#app'))

View file

@ -1,8 +1,9 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useRequests } from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import routes from './routes'
import store from '@/store'
import routes from './routes'
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
@ -32,8 +33,10 @@ router.beforeEach((to, from, next) => {
updateTransitionName({ to, from })
}
if (store.getters.error) {
store.dispatch('DISMISS_ERROR', true)
const { currentRequest, dismissModal } = useRequests()
if (currentRequest.value?.err) {
// In case an error is still present after code route change
dismissModal(currentRequest.value.id)
}
if (to.name === 'post-install' && store.getters.installed) {

View file

@ -10,7 +10,6 @@ export default {
connected: localStorage.getItem('connected') === 'true', // Boolean
yunohost: null, // Object { version, repo }
reconnecting: null, // null|Object { attemps, delay, initialDelay }
error: null, // null || request
routerKey: undefined, // String if current route has params
breadcrumb: [], // Array of routes
transitionName: null, // String of CSS class if transitions are enabled
@ -34,14 +33,6 @@ export default {
state.reconnecting = args
},
SET_ERROR(state, request) {
if (request) {
state.error = request
} else {
state.error = null
}
},
SET_ROUTER_KEY(state, key) {
state.routerKey = key
},
@ -134,48 +125,6 @@ export default {
})
},
HANDLE_ERROR({ commit, dispatch }, error) {
if (error.code === 401) {
// Unauthorized
dispatch('DISCONNECT')
} else if (error.logRef) {
// Errors that have produced logs
router.push({ name: 'tool-log', params: { name: error.logRef } })
} else {
// The request is temporarely stored in the error for reference, but we reverse
// the ownership to stay generic.
const request = error.request
delete error.request
request.error = error
// Display the error in a modal on the current view.
commit('SET_ERROR', request)
}
},
REVIEW_ERROR({ commit }, request) {
request.review = true
commit('SET_ERROR', request)
},
DISMISS_ERROR({ commit, state }, { initial, review = false }) {
if (initial && !review) {
// In case of an initial request (data that is needed by a view to render itself),
// try to go back so the user doesn't get stuck at a never ending skeleton view.
if (history.length > 2) {
history.back()
} else {
// if the url was opened in a new tab, return to home
router.push({ name: 'home' })
}
}
commit('SET_ERROR', null)
},
DISMISS_WARNING({ commit, state }, request) {
commit('SET_WAITING', false)
delete request.showWarningMessage
},
UPDATE_ROUTER_KEY({ commit }, { to, from }) {
if (isEmptyValue(to.params)) {
commit('SET_ROUTER_KEY', undefined)
@ -257,11 +206,8 @@ export default {
installed: (state) => state.installed,
connected: (state) => state.connected,
yunohost: (state) => state.yunohost,
error: (state) => state.error,
reconnecting: (state) => state.reconnecting,
history: (state) => state.history,
lastAction: (state) => state.history[state.history.length - 1],
currentRequest: (state) => state.currentRequest,
routerKey: (state) => state.routerKey,
breadcrumb: (state) => state.breadcrumb,
transitionName: (state) => state.transitionName,