Skip to content

Instantly share code, notes, and snippets.

@jamesknelson
Last active September 10, 2020 03:52
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jamesknelson/ab93890eb26f2841a2f8846d4013b151 to your computer and use it in GitHub Desktop.
Save jamesknelson/ab93890eb26f2841a2f8846d4013b151 to your computer and use it in GitHub Desktop.
import * as Govern from 'govern'
import axios, { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios'
export interface HTTPOperation<Success = any, Rejection = any> {
hasEnded: boolean
isBusy: boolean
wasCancelled: boolean
wasSuccessful: boolean
wasRejected: boolean
error?: any
/**
* Data about the operation. For example, the requested data, or
* details on why the operation failed or was cancelled,
*
* Note that requests do not need to make anything available on this
* attribute, and may opt to store their received data in a global store
* instead.
*
* Cannot be typed as you never know what will be returned by an error.
*/
response?: HTTPOperationResponse<any>
/**
* Start an operation that was held due to lack of authentication or network
* connectivity.
*/
start?: () => void
}
export enum HTTPOperationStatus {
// The request couldn't be completed due to lack of authentication,
// and should be retried once the user has authenticated.
//
// The app should show an authentication modal when any requests have this
// state, so UIs can just treat it as `busy`.
AwaitingAuthentication = 'AwaitingAuthentication',
// The request couyldn't be completed due to lack of connection,
// and should be retried once the user has reconnected.
//
// This isn't treated as a failed request, so optimistic UIs can
// still display the expected result, but may also display a warning
// that some changes may not be saved.
AwaitingConnection = 'AwaitingConnection',
// The request is in progress
Busy = 'Busy',
Cancelled = 'Cancelled',
Error = 'Error',
Rejection = 'Rejected',
Success = 'Success',
}
export namespace HTTPOperation {
export const Status = HTTPOperationStatus
export type Status = HTTPOperationStatus
export type Response<Data = any> = AxiosResponse<Data>
}
export type HTTPOperationResponse<Data = any> = AxiosResponse<Data>
export interface HTTPOperationControllerProps<Success = any, Rejection = any>
extends AxiosRequestConfig {
maxRetries?: number
/**
* If false, a failure to authenticate will be treated as a failure instead
* of an indication that the user needs to authenticate.
*/
canAwaitAuthentication?: boolean
/**
* If false, a lack of connection will prevent the request from being
* retried.
*/
canAwaitConnection?: boolean
/**
* If true, prevents the request from being made.
*/
isAwaitingAuthentication?: boolean
/**
* If false, prevents the request from being made. The exponential backoff
* timing is reset when it's value changes.
*/
isOnline?: boolean
/**
* This is a convenience prop to help in situations where authentication
* -related headers need to change between request tries.
*/
authenticationHeaders?: { [name: string]: string }
/**
* Allows you to configure which responses are considered unauthenticated,
* not connected, failed, and successful.
*/
getResponseStatus?(response: HTTPOperationResponse<any>): HTTPOperationStatus
/**
* Called upon successful completion of the request. Use this to store
* received data, update authentication headers, etc.
*/
onSuccess?(response: HTTPOperationResponse<Success>): void
/**
* The request failed for an unknown reason, e.g. a 5xx status code.
*/
onError?(response?: HTTPOperationResponse<any>): void
/**
* The request was rejected by the server, e.g. a 4xx status code.
*/
onRejection?(response?: HTTPOperationResponse<Rejection>): void
/**
* Called after success, failure, rejection or cancelled. Facilitates cleanup that must
* be run regardless of the result.
*/
onEnd?(response?: HTTPOperationResponse<any>): void
/**
* The request was cancelled before it could be completed. This is called
* if a request can't be made due to network or authentication issues, and
* is cancelled before it can be retried.
**/
onCancel?(): void
/**
* A request is about to be sent.
*/
onSend?(): void
/**
* You cannot supply axios' "validateStatus" option, as "request" uses it
* internally.
*/
validateStatus?: never
}
interface HTTPOperationControllerState {
status: HTTPOperationStatus
hasEnded: boolean
response?: HTTPOperationResponse<any>
}
export class HTTPOperationController<
Success = any,
Rejection = any
> extends Govern.Component<
HTTPOperationControllerProps<Success, Rejection>,
HTTPOperationControllerState,
HTTPOperation<Success, Rejection>
> {
static Element<Success = any, Rejection = any>(
props: HTTPOperationControllerProps<Success, Rejection>,
) {
return Govern.createElement(
HTTPOperationController as Govern.ElementType<
HTTPOperationController<Success, Rejection>
>,
props,
)
}
static defaultProps = {
canAwaitAuthentication: true,
canAwaitConnection: true,
getResponseStatus: defaultGetResponseStatus,
}
axiosConfig: AxiosRequestConfig
holdCount: number
isDisposed: boolean
constructor(props) {
super(props)
this.holdCount = 0
let {
maxDisconnectedRetries,
maxUnauthenticatedRetries,
initialStatus,
getResponseStatus,
getResult,
getError,
requestDidSucceed,
requestDidFail,
requestDidComplete,
requestWasCancelled,
requestWasHeld,
requestWillSend,
...axiosConfig
} = props
this.axiosConfig = axiosConfig
// We want to allow configuration of response status via
// `getResponseStatus`, so always have axios return success (except when
// there is no connection)
this.axiosConfig.validateStatus = () => true
// Set state to "Busy" *after* calling `this.start`, so that `this.start`
// doesn't find "Busy" as the status and then throw an error.
this.state = {
hasEnded: false,
status: initialStatus,
}
}
render() {
return {
isBusy: this.state.status === HTTPOperationStatus.Busy,
hasEnded: this.state.hasEnded,
wasCancelled: this.state.status === HTTPOperationStatus.Cancelled,
wasSuccessful: this.state.status === HTTPOperationStatus.Success,
wasRejected: this.state.status === HTTPOperationStatus.Rejection,
error: this.state.status === HTTPOperationStatus.Error,
/**
* The result of the action. Only available when status is "success".
*/
response: this.state.response,
/**
* Start or restart an action.
*/
start: this.start,
}
}
componentDidMount() {
if (!this.state.status) {
this.start()
}
}
componentDidUpdate(prevProps: HTTPOperationControllerProps<any>) {
if (
prevProps.isOnline === false &&
this.props.isOnline === true &&
this.state.status === HTTPOperationStatus.AwaitingConnection
) {
this.start()
}
}
componentWillUnmount() {
this.isDisposed = true
}
start = () => {
let props = this.props
let status = this.state.status
if (
status &&
status !== HTTPOperationStatus.AwaitingAuthentication &&
status !== HTTPOperationStatus.AwaitingConnection
) {
console.error(
`You can only start an "AwaitingAuthentication" and "AwaitingConnection" requests, but the request to "${
props.url
}" has status "${status}".`,
)
return
}
// The `requestWillSend` callback and `setState` may both publish new
// values, so run them in a single dispatcher action.
this.dispatch(this.doRequest)
}
doRequest = () => {
let attemptConfig = Object.assign({}, this.axiosConfig)
if (this.props.authenticationHeaders) {
attemptConfig.headers = Object.assign(
{},
this.axiosConfig.headers,
this.props.authenticationHeaders,
)
}
// Run the "requestWillSend" lifecycle method before updating state
// and notifying subscribers that we're sending.
if (this.props.onSend) {
this.props.onSend()
}
this.setState({
status: HTTPOperationStatus.Busy,
})
axios
.request(attemptConfig)
.then(this.handleAxiosResponse, this.handleAxiosError)
}
cancel = () => {
let props = this.props
let status = this.state.status
if (
status !== HTTPOperationStatus.AwaitingAuthentication &&
status !== HTTPOperationStatus.AwaitingConnection &&
status !== HTTPOperationStatus.Busy
) {
console.error(
`You can only cancel "AwaitingAuthentication", "AwaitingConnection" or "Busy" requests, but the request to "${
props.url
}" has status "${status}".`,
)
return
}
if (status === HTTPOperationStatus.Busy) {
console.warn(
`Cancelling in-progress HTTP requests is only partially supported; the request will continue, but the result will not be processed.`,
)
}
// The `requestWasCancelled` callback and `setState` may both publish new
// values, so run them in a single dispatcher action.
this.dispatch(() => {
this.setState({
hasEnded: true,
status: HTTPOperationStatus.Cancelled,
})
if (this.props.onCancel) {
this.props.onCancel()
}
if (this.props.onEnd) {
this.props.onEnd()
}
})
}
hold = (status: HTTPOperationStatus, response?: AxiosResponse<any>) => {
let props = this.props
++this.holdCount
let maxRetries = props.maxRetries === undefined ? 0 : props.maxRetries
if (
(this.holdCount <= maxRetries &&
(props.canAwaitAuthentication &&
status === HTTPOperationStatus.AwaitingAuthentication)) ||
(props.canAwaitConnection &&
status === HTTPOperationStatus.AwaitingConnection)
) {
this.dispatch(() => {
// We haven't yet reached our retry limit.
this.setState({
status: status,
})
})
} else {
this.error(response)
}
}
error = (response?: AxiosResponse<any>) => {
this.dispatch(() => {
this.setState({
hasEnded: true,
status: HTTPOperationStatus.Error,
response,
})
if (this.props.onError) {
this.props.onError(response)
}
if (this.props.onEnd) {
this.props.onEnd(response)
}
})
}
handleAxiosResponse = (response: AxiosResponse<any>) => {
let props = this.props
if (this.isDisposed) {
// The request may be disposed before we receive a response if it
// is cancelled.
return
}
if (this.state.status !== HTTPOperationStatus.Cancelled) {
let status = this.props.getResponseStatus!(response)
switch (status) {
case HTTPOperationStatus.Cancelled:
return this.cancel()
case HTTPOperationStatus.Success:
this.dispatch(() => {
this.setState({
response,
hasEnded: true,
status,
})
if (this.props.onSuccess) {
this.props.onSuccess(response)
}
if (this.props.onEnd) {
this.props.onEnd(response)
}
})
break
case HTTPOperationStatus.Rejection:
this.dispatch(() => {
this.setState({
response,
hasEnded: true,
status,
})
if (this.props.onRejection) {
this.props.onRejection(response)
}
if (this.props.onEnd) {
this.props.onEnd(response)
}
})
break
case HTTPOperationStatus.Error:
this.error(response)
break
case HTTPOperationStatus.AwaitingAuthentication:
case HTTPOperationStatus.AwaitingConnection:
this.hold(status, response)
break
default:
throw new Error(
`getResponseStatus returned unknow status "${status}".`,
)
}
}
}
protected handleAxiosError = error => {
if (this.isDisposed) {
// The request may be disposed before we receive a response if it
// is cancelled.
return
}
if (this.state.status !== HTTPOperationStatus.Cancelled) {
// I'm assuming that any Axios errors indicate lack of network
// connectivity. If this is incorrect, PRs with fixes are welcome!
this.hold(HTTPOperationStatus.AwaitingConnection)
}
}
}
function defaultGetResponseStatus(
response: HTTPOperationResponse<any>,
): HTTPOperationStatus {
if (!response.status) {
return HTTPOperationStatus.AwaitingConnection
} else if (response.status === 401) {
return HTTPOperationStatus.AwaitingAuthentication
} else if (response.status >= 200 && response.status < 300) {
return HTTPOperationStatus.Success
} else if (response.status >= 400 && response.status < 500) {
return HTTPOperationStatus.Rejection
} else {
return HTTPOperationStatus.Error
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment