@@ -5,6 +5,7 @@ import CircularProgress from "@mui/material/CircularProgress";
5
5
import Link from "@mui/material/Link" ;
6
6
import type { ApiErrorResponse } from "api/errors" ;
7
7
import type { ExternalAuthDevice } from "api/typesGenerated" ;
8
+ import { isAxiosError } from "axios" ;
8
9
import { Alert , AlertDetail } from "components/Alert/Alert" ;
9
10
import { CopyButton } from "components/CopyButton/CopyButton" ;
10
11
import type { FC } from "react" ;
@@ -14,6 +15,59 @@ interface GitDeviceAuthProps {
14
15
deviceExchangeError ?: ApiErrorResponse ;
15
16
}
16
17
18
+ const DeviceExchangeError = {
19
+ AuthorizationPending : "authorization_pending" ,
20
+ SlowDown : "slow_down" ,
21
+ ExpiredToken : "expired_token" ,
22
+ AccessDenied : "access_denied" ,
23
+ } as const ;
24
+
25
+ export const isExchangeErrorRetryable = ( _ : number , error : unknown ) => {
26
+ if ( ! isAxiosError ( error ) ) {
27
+ return false ;
28
+ }
29
+ const detail = error . response ?. data ?. detail ;
30
+ return (
31
+ detail === DeviceExchangeError . AuthorizationPending ||
32
+ detail === DeviceExchangeError . SlowDown
33
+ ) ;
34
+ } ;
35
+
36
+ /**
37
+ * The OAuth2 specification (https://datatracker.ietf.org/doc/html/rfc8628)
38
+ * describes how the client should handle retries. This function returns a
39
+ * closure that implements the retry logic described in the specification.
40
+ * The closure should be memoized because it stores state.
41
+ */
42
+ export const newRetryDelay = ( initialInterval : number | undefined ) => {
43
+ // "If no value is provided, clients MUST use 5 as the default."
44
+ // https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
45
+ let interval = initialInterval ?? 5 ;
46
+ let lastFailureCountHandled = 0 ;
47
+ return ( failureCount : number , error : unknown ) => {
48
+ const isSlowDown =
49
+ isAxiosError ( error ) &&
50
+ error . response ?. data . detail === DeviceExchangeError . SlowDown ;
51
+ // We check the failure count to ensure we increase the interval
52
+ // at most once per failure.
53
+ if ( isSlowDown && lastFailureCountHandled < failureCount ) {
54
+ lastFailureCountHandled = failureCount ;
55
+ // https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
56
+ // "the interval MUST be increased by 5 seconds for this and all subsequent requests"
57
+ interval += 5 ;
58
+ }
59
+ let extraDelay = 0 ;
60
+ if ( isSlowDown ) {
61
+ // I found GitHub is very strict about their rate limits, and they'll block
62
+ // even if the request is 500ms earlier than they expect. This may happen due to
63
+ // e.g. network latency, so it's best to cool down for longer if GitHub just
64
+ // rejected our request.
65
+ extraDelay = 5 ;
66
+ }
67
+ return ( interval + extraDelay ) * 1000 ;
68
+ } ;
69
+ } ;
70
+
17
71
export const GitDeviceAuth : FC < GitDeviceAuthProps > = ( {
18
72
externalAuthDevice,
19
73
deviceExchangeError,
@@ -27,16 +81,26 @@ export const GitDeviceAuth: FC<GitDeviceAuthProps> = ({
27
81
if ( deviceExchangeError ) {
28
82
// See https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
29
83
switch ( deviceExchangeError . detail ) {
30
- case "authorization_pending" :
84
+ case DeviceExchangeError . AuthorizationPending :
85
+ break ;
86
+ case DeviceExchangeError . SlowDown :
87
+ status = (
88
+ < div >
89
+ { status }
90
+ < Alert severity = "warning" >
91
+ Rate limit reached. Waiting a few seconds before retrying...
92
+ </ Alert >
93
+ </ div >
94
+ ) ;
31
95
break ;
32
- case "expired_token" :
96
+ case DeviceExchangeError . ExpiredToken :
33
97
status = (
34
98
< Alert severity = "error" >
35
99
The one-time code has expired. Refresh to get a new one!
36
100
</ Alert >
37
101
) ;
38
102
break ;
39
- case "access_denied" :
103
+ case DeviceExchangeError . AccessDenied :
40
104
status = (
41
105
< Alert severity = "error" > Access to the Git provider was denied.</ Alert >
42
106
) ;
0 commit comments