Skip to content
Merged
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
Add optional maintenance retry handler
Signed-off-by: Christoph Wurst <[email protected]>
  • Loading branch information
ChristophWurst committed Sep 29, 2022
commit 94559f638fc21fe1fbe8dc322aa64ad28e925f4e
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,25 @@ const baseURL = generateUrl('/apps/your_app_id/api')
axios.defaults.baseURL = baseURL
```

## Retry handling

This package can optionally retry requests if they fail due to Nextcloud's *maintenance mode*. To activate this feature, pass
`retryIfMaintenanceMode: true` into the request options. This mechanism will only catch relatively short server maintenance
downtime in the range of seconds to a minute. Any longer downtime still has to be handled by the application, show feedback
to the user, reload the page etc.

```js
import axios from '@nextcloud/axios'

const pizzas = await axios.get('/apps/pizza/api/pizzas', {
retryIfMaintenanceMode: true,
})
const myPizza = await axios.post('/apps/pizza/api/pizzas', { toppings: ['pineapple'] }, {
retryIfMaintenanceMode: true,
})
```


References

- [@nextcloud/router](https://github.com/nextcloud/nextcloud-router)
Expand Down
4 changes: 4 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Axios, { AxiosInstance, CancelTokenStatic } from 'axios'
import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'

import { onError } from './interceptors/maintenance-mode'

interface CancelableAxiosInstance extends AxiosInstance {
CancelToken: CancelTokenStatic
isCancel(value: any): boolean
Expand All @@ -16,6 +18,8 @@ const cancelableClient: CancelableAxiosInstance = Object.assign(client, {
isCancel: Axios.isCancel,
})

cancelableClient.interceptors.response.use(r => r, onError(cancelableClient))

onRequestTokenUpdate(token => client.defaults.headers.requesttoken = token)

export default cancelableClient
32 changes: 32 additions & 0 deletions lib/interceptors/maintenance-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const RETRY_DELAY_KEY = Symbol('retryDelay')

export const onError = axios => async (error) => {
const { config, response, request: { responseURL } } = error
const { status, headers } = response

/**
* Retry requests if they failed due to maintenance mode
*
* The delay is exponential. It starts at 2s and then doubles
* until a final retry after 32s. This results in roughly 1m of
* retries until we give up and throw the axios error towards
* the caller.
*/
if (status === 503
&& headers['x-nextcloud-maintenance-mode'] === '1'
&& config.retryIfMaintenanceMode
&& (!config[RETRY_DELAY_KEY] || config[RETRY_DELAY_KEY] <= 32)) {
const retryDelay = (config[RETRY_DELAY_KEY] ?? 1) * 2
console.warn(`Request to ${responseURL} failed because of maintenance mode. Retrying in ${retryDelay}s`)
await new Promise((resolve, _) => {
setTimeout(resolve, retryDelay*1000)
})

return axios({
...config,
[RETRY_DELAY_KEY]: retryDelay,
})
}

return Promise.reject(error)
}
97 changes: 97 additions & 0 deletions test/interceptors/maintenance-mode.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { onError } from '../../lib/interceptors/maintenance-mode'

describe('maintenance mode interceptor', () => {

let axiosMock
let interceptor

beforeEach(() => {
axiosMock = jest.fn()
interceptor = onError(axiosMock)
})

it('does not retry HTTP404', async () => {
try {
await interceptor({
config: {},
response: {
status: 404,
headers: {},
},
request: {
responseURL: '/some/url',
},
})
} catch (e) {
expect(e.response.status).toBe(404)
expect(axiosMock).not.toHaveBeenCalled()
return
}
fail('Should not be reached')
})

it('does not retry if not asked to', async () => {
try {
await interceptor({
config: {},
response: {
status: 503,
headers: {
'x-nextcloud-maintenance-mode': '1',
},
},
request: {
responseURL: '/some/url',
},
})
} catch (e) {
expect(e.response.status).toBe(503)
expect(axiosMock).not.toHaveBeenCalled()
return
}
fail('Should not be reached')
})

it('does not retry if header missing', async () => {
try {
await interceptor({
config: {
retryIfMaintenanceMode: true,
},
response: {
status: 503,
headers: {},
},
request: {
responseURL: '/some/url',
},
})
} catch (e) {
expect(e.response.status).toBe(503)
expect(axiosMock).not.toHaveBeenCalled()
return
}
fail('Should not be reached')
})

it('does retry', async () => {
axiosMock.mockReturnValue(42)
const response = await interceptor({
config: {
retryIfMaintenanceMode: true,
},
response: {
status: 503,
headers: {
'x-nextcloud-maintenance-mode': '1',
},
},
request: {
responseURL: '/some/url',
},
})

expect(axiosMock).toHaveBeenCalled()
expect(response).toBe(42)
})
})