Skip to content

Commit

Permalink
feat: 🎸 API call retries and support for error reporting (#5)
Browse files Browse the repository at this point in the history
* feat: 🎸 API call retries and support for error reporting

* docs: ✏️ Added reporter to README
  • Loading branch information
JohanObrink authored Feb 8, 2021
1 parent f89f143 commit 9ed5df2
Show file tree
Hide file tree
Showing 16 changed files with 746 additions and 33 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ module.exports = {
'@typescript-eslint/semi': ['error', 'never'],
'jest/no-mocks-import': [0],
'max-len': [1, 110],
'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }],
'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx', '.tsx'] }],
},
}
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@ import init from '@skolplattformen/embedded-api'
import { CookieManager } from '@react-native-community/cookies'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { RootComponent } from './components/root'
import crashlytics from '@react-native-firebase/crashlytics'

const api = init(fetch, () => CookieManager.clearAll())
const reporter = {
log: (message) => crashlytics().log(message),
error: (error, label) => crashlytics().recordError(error, label),
}

export default () => (
<ApiProvider api={api} storage={AsyncStorage}>
<ApiProvider api={api} reporter={reporter} storage={AsyncStorage}>
<RootComponent />
</ApiProvider>
)
Expand Down
6 changes: 6 additions & 0 deletions src/__mocks__/reporter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const reporter = {
log: jest.fn().mockName('log'),
error: jest.fn().mockName('error'),
}

export default reporter
10 changes: 9 additions & 1 deletion src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ const hook = <T>(
const state = stateMap[key] || { status: 'pending', data: defaultValue }
return state
}
const { api, storage, isLoggedIn } = useApi()
const {
api, isLoggedIn, reporter, storage,
} = useApi()
const initialState = select(store.getState() as EntityStoreRootState)
const [state, setState] = useState(initialState)
const dispatch = useDispatch()
Expand All @@ -50,6 +52,7 @@ const hook = <T>(
key,
defaultValue,
apiCall: apiCaller(api),
retries: 0,
}

// Only use cache when not in fake mode
Expand All @@ -72,6 +75,11 @@ const hook = <T>(
|| newState.data !== state.data
|| newState.error !== state.error) {
setState(newState)

if (newState.error) {
const description = `Error getting ${entityName} from API`
reporter.error(newState.error, description)
}
}
}
useEffect(() => store.subscribe(listener), [])
Expand Down
50 changes: 40 additions & 10 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
/* eslint-disable default-case */
import { Middleware } from 'redux'
import { EntityAction, EntityStoreRootState } from './types'
import { EntityAction, EntityStoreRootState, ExtraActionProps } from './types'

type IMiddleware = Middleware<{}, EntityStoreRootState>
export const apiMiddleware: IMiddleware = (storeApi) => (next) => (action: EntityAction<any>) => {
try {
switch (action.type) {
case 'GET_FROM_API': {
// Retrieve from cache
if (action.extra?.getFromCache) {
const cacheAction: EntityAction<any> = {
...action,
type: 'GET_FROM_CACHE',
}
storeApi.dispatch(cacheAction)
}

// Call api
const apiCall = action.extra?.apiCall
if (apiCall) {
const extra = action.extra as ExtraActionProps<any>
apiCall()
.then((res: any) => {
const resultAction: EntityAction<any> = {
Expand All @@ -19,23 +29,43 @@ export const apiMiddleware: IMiddleware = (storeApi) => (next) => (action: Entit
}
storeApi.dispatch(resultAction)

if (action.extra?.saveToCache && res) {
if (extra.saveToCache && res) {
const cacheAction: EntityAction<any> = {
...resultAction,
type: 'STORE_IN_CACHE',
}
storeApi.dispatch(cacheAction)
}
})
}
.catch((error) => {
const retries = extra.retries + 1

// Retrieve from cache
if (action.extra?.getFromCache) {
const cacheAction: EntityAction<any> = {
...action,
type: 'GET_FROM_CACHE',
}
storeApi.dispatch(cacheAction)
const errorAction: EntityAction<any> = {
...action,
extra: {
...extra,
retries,
},
type: 'API_ERROR',
error,
}
storeApi.dispatch(errorAction)

// Retry 3 times
if (retries < 3) {
const retryAction: EntityAction<any> = {
...action,
type: 'GET_FROM_API',
extra: {
...extra,
retries,
},
}
setTimeout(() => {
storeApi.dispatch(retryAction)
}, retries * 500)
}
})
}
}
}
Expand Down
17 changes: 14 additions & 3 deletions src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ import React, {
import { Provider } from 'react-redux'
import { ApiContext } from './context'
import store from './store'
import { AsyncStorage, IApiContext } from './types'
import { AsyncStorage, IApiContext, Reporter } from './types'

type TApiProvider = FC<PropsWithChildren<{ api: Api, storage: AsyncStorage }>>
export const ApiProvider: TApiProvider = ({ children, api, storage }) => {
type TApiProvider = FC<PropsWithChildren<{
api: Api,
storage: AsyncStorage,
reporter?: Reporter
}>>
const noopReporter: Reporter = {
log: () => { },
error: () => { },
}
export const ApiProvider: TApiProvider = ({
children, api, storage, reporter = noopReporter,
}) => {
const [isLoggedIn, setIsLoggedIn] = useState(api.isLoggedIn)
const [isFake, setIsFake] = useState(api.isFake)

Expand All @@ -19,6 +29,7 @@ export const ApiProvider: TApiProvider = ({ children, api, storage }) => {
storage,
isLoggedIn,
isFake,
reporter,
}

useEffect(() => {
Expand Down
8 changes: 8 additions & 0 deletions src/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ const createReducer = <T>(entity: EntityName): EntityReducer<T> => {
}
break
}
case 'API_ERROR': {
newNode = {
...node,
status: action.extra.retries < 3 ? node.status : 'error',
error: action.error,
}
break
}
case 'RESULT_FROM_CACHE': {
newNode = {
...node,
Expand Down
31 changes: 22 additions & 9 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ import {
} from '@skolplattformen/embedded-api'
import { Action, Reducer } from 'redux'

export interface Reporter {
log: (message: string) => void
error: (error: Error, label?: string) => void
}

export interface IApiContext {
api: Api
storage: AsyncStorage
isLoggedIn: boolean
isFake: boolean
reporter: Reporter
}

export type EntityStatus = 'pending' | 'loading' | 'loaded' | 'error'
Expand All @@ -30,21 +36,28 @@ export interface ApiCall<T> {
}
export interface ExtraActionProps<T> {
apiCall: ApiCall<T>
retries: number
key: string
defaultValue: T
getFromCache?: () => Promise<string | null>
saveToCache?: (value: string) => Promise<void>
}
export type EntityActionType = 'GET_FROM_API' | 'RESULT_FROM_API' | 'GET_FROM_CACHE' | 'RESULT_FROM_CACHE' | 'STORE_IN_CACHE' | 'CLEAR'
export type EntityActionType = 'GET_FROM_API'
| 'RESULT_FROM_API'
| 'API_ERROR'
| 'GET_FROM_CACHE'
| 'RESULT_FROM_CACHE'
| 'STORE_IN_CACHE'
| 'CLEAR'
export type EntityName = 'USER'
| 'CHILDREN'
| 'CALENDAR'
| 'CLASSMATES'
| 'MENU'
| 'NEWS'
| 'NOTIFICATIONS'
| 'SCHEDULE'
| 'ALL'
| 'CHILDREN'
| 'CALENDAR'
| 'CLASSMATES'
| 'MENU'
| 'NEWS'
| 'NOTIFICATIONS'
| 'SCHEDULE'
| 'ALL'
export interface EntityAction<T> extends Action<EntityActionType> {
entity: EntityName
data?: T
Expand Down
81 changes: 80 additions & 1 deletion src/useCalendar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useCalendar } from './hooks'
import store from './store'
import init from './__mocks__/@skolplattformen/embedded-api'
import createStorage from './__mocks__/AsyncStorage'
import reporter from './__mocks__/reporter'

const pause = (ms = 0) => new Promise((r) => setTimeout(r, ms))

Expand All @@ -14,7 +15,13 @@ describe('useCalendar(child)', () => {
let response
let child
const wrapper = ({ children }) => (
<ApiProvider api={api} storage={storage}>{children}</ApiProvider>
<ApiProvider
api={api}
storage={storage}
reporter={reporter}
>
{children}
</ApiProvider>
)
beforeEach(() => {
response = [{ id: 1 }]
Expand Down Expand Up @@ -144,4 +151,76 @@ describe('useCalendar(child)', () => {
expect(storage.cache.calendar_10).toEqual('[{"id":2}]')
})
})
it('retries if api fails', async () => {
await act(async () => {
api.isLoggedIn = true
const error = new Error('fail')
api.getCalendar.mockRejectedValueOnce(error)

const { result, waitForNextUpdate } = renderHook(() => useCalendar(child), { wrapper })

await waitForNextUpdate()
await waitForNextUpdate()
await waitForNextUpdate()

expect(result.current.error).toEqual(error)
expect(result.current.status).toEqual('loading')
expect(result.current.data).toEqual([{ id: 2 }])

jest.advanceTimersToNextTimer()

await waitForNextUpdate()
await waitForNextUpdate()
await waitForNextUpdate()

expect(result.current.status).toEqual('loaded')
expect(result.current.data).toEqual([{ id: 1 }])
})
})
it('gives up after 3 retries', async () => {
await act(async () => {
api.isLoggedIn = true
const error = new Error('fail')
api.getCalendar.mockRejectedValueOnce(error)
api.getCalendar.mockRejectedValueOnce(error)
api.getCalendar.mockRejectedValueOnce(error)

const { result, waitForNextUpdate } = renderHook(() => useCalendar(child), { wrapper })

await waitForNextUpdate()
await waitForNextUpdate()
await waitForNextUpdate()

expect(result.current.error).toEqual(error)
expect(result.current.status).toEqual('loading')
expect(result.current.data).toEqual([{ id: 2 }])

jest.advanceTimersToNextTimer()

await waitForNextUpdate()
await waitForNextUpdate()
await waitForNextUpdate()

expect(result.current.error).toEqual(error)
expect(result.current.status).toEqual('error')
expect(result.current.data).toEqual([{ id: 2 }])
})
})
it('reports if api fails', async () => {
await act(async () => {
api.isLoggedIn = true
const error = new Error('fail')
api.getCalendar.mockRejectedValueOnce(error)

const { result, waitForNextUpdate } = renderHook(() => useCalendar(child), { wrapper })

await waitForNextUpdate()
await waitForNextUpdate()
await waitForNextUpdate()

expect(result.current.error).toEqual(error)

expect(reporter.error).toHaveBeenCalledWith(error, 'Error getting CALENDAR from API')
})
})
})
Loading

0 comments on commit 9ed5df2

Please sign in to comment.