Skip to content

Commit c3b1085

Browse files
authored
Add retry-on-401 for GitHub API calls (#13)
When a GitHub API call returns 401 (e.g. due to clock skew, early token revocation, or network delays), force-refresh the token and retry once. Changes: - Add forceRefreshClient to bypass both in-memory and file caches - Add withFreshClient wrapper that catches 401 and retries with fresh token - Refactor updateCommitStatus and checkExistingStatus to use withFreshClient - Add token validation to FakeGithubApi mock server (unique tokens, expiry tracking, clock skew simulation via expiration offset) - Add '# github token expiration offset N' test directive - Add golden test for 401 retry scenario
1 parent 068082c commit c3b1085

5 files changed

Lines changed: 166 additions & 49 deletions

File tree

src/CommitStatus.hs

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import qualified Data.Text as Text
2020
import qualified Data.ByteString.Lazy as BL
2121
import System.FileLock (withFileLock, SharedExclusive(..))
2222
import System.Directory (doesFileExist)
23-
import Utils (getCurrentCommit, logError, logDebug)
23+
import Utils (getCurrentCommit, logError, logDebug, logWarn)
2424
import Types (AppState(..), GithubClient(..), Settings(..))
2525

2626
-- Define the data types for the status update
@@ -118,6 +118,33 @@ loadOrRefreshClient appState = do
118118
writeIORef appState.githubClient (Just client)
119119
pure client
120120

121+
-- Force-refresh the token, ignoring both in-memory and file caches
122+
forceRefreshClient :: AppState -> IO GithubClient
123+
forceRefreshClient appState = do
124+
let cacheFile = credentialsCacheFile appState.settings
125+
let lockFile = cacheFile <> ".lock"
126+
127+
writeIORef appState.githubClient Nothing
128+
129+
client <- withFileLock lockFile Exclusive \_ ->
130+
refreshToken appState cacheFile
131+
132+
writeIORef appState.githubClient (Just client)
133+
pure client
134+
135+
-- Execute a GitHub API request, retrying once with a fresh token on 401
136+
withFreshClient :: AppState -> (GithubClient -> IO (HTTP.Response BL.ByteString)) -> IO (HTTP.Response BL.ByteString)
137+
withFreshClient appState doRequest = do
138+
client <- getClient appState
139+
response <- doRequest client
140+
if response.responseStatus.statusCode == 401
141+
then do
142+
logWarn appState "GitHub API returned 401, force-refreshing token and retrying..."
143+
freshClient <- forceRefreshClient appState
144+
doRequest freshClient
145+
else
146+
pure response
147+
121148
-- Create new token and write to cache (caller should hold EXCLUSIVE lock)
122149
refreshToken :: AppState -> FilePath -> IO GithubClient
123150
refreshToken appState cacheFile = do
@@ -209,23 +236,23 @@ createTokenFromGitHub appState = do
209236

210237
updateCommitStatus :: MonadIO m => AppState -> StatusRequest -> m ()
211238
updateCommitStatus appState statusRequest = liftIO do
212-
client <- getClient appState
213239
sha <- getCurrentCommit appState
214240

215-
-- Prepare the status update request
216-
let statusUrl = toString client.apiUrl <> "/repos/" ++ toString client.owner ++ "/" ++ toString client.repo ++ "/statuses/" ++ toString sha
217-
initStatusRequest <- HTTP.parseRequest statusUrl
218-
let statusReq = initStatusRequest
219-
{ HTTP.method = "POST"
220-
, HTTP.requestHeaders =
221-
[ ("Authorization", "Bearer " <> TE.encodeUtf8 client.accessToken)
222-
, ("Accept", "application/vnd.github.v3+json")
223-
, ("Content-Type", "application/json")
224-
, ("User-Agent", "restaumatic-bot")
225-
]
226-
, HTTP.requestBody = HTTP.RequestBodyLBS $ encode statusRequest
227-
}
228-
statusResponse <- HTTP.httpLbs statusReq client.manager
241+
statusResponse <- withFreshClient appState \client -> do
242+
let statusUrl = toString client.apiUrl <> "/repos/" ++ toString client.owner ++ "/" ++ toString client.repo ++ "/statuses/" ++ toString sha
243+
initStatusRequest <- HTTP.parseRequest statusUrl
244+
let statusReq = initStatusRequest
245+
{ HTTP.method = "POST"
246+
, HTTP.requestHeaders =
247+
[ ("Authorization", "Bearer " <> TE.encodeUtf8 client.accessToken)
248+
, ("Accept", "application/vnd.github.v3+json")
249+
, ("Content-Type", "application/json")
250+
, ("User-Agent", "restaumatic-bot")
251+
]
252+
, HTTP.requestBody = HTTP.RequestBodyLBS $ encode statusRequest
253+
}
254+
HTTP.httpLbs statusReq client.manager
255+
229256
if statusResponse.responseStatus.statusCode == 201
230257
then
231258
logDebug appState "Commit status updated successfully"
@@ -237,21 +264,21 @@ updateCommitStatus appState statusRequest = liftIO do
237264
-- Check if a status exists for the current commit and context
238265
checkExistingStatus :: MonadIO m => AppState -> T.Text -> m Bool
239266
checkExistingStatus appState contextName = liftIO do
240-
client <- getClient appState
241267
sha <- getCurrentCommit appState
242268

243-
-- Prepare the GET request for statuses
244-
let statusUrl = toString client.apiUrl <> "/repos/" ++ toString client.owner ++ "/" ++ toString client.repo ++ "/commits/" ++ toString sha ++ "/statuses"
245-
initStatusRequest <- HTTP.parseRequest statusUrl
246-
let statusReq = initStatusRequest
247-
{ HTTP.method = "GET"
248-
, HTTP.requestHeaders =
249-
[ ("Authorization", "Bearer " <> TE.encodeUtf8 client.accessToken)
250-
, ("Accept", "application/vnd.github.v3+json")
251-
, ("User-Agent", "restaumatic-bot")
252-
]
253-
}
254-
statusResponse <- HTTP.httpLbs statusReq client.manager
269+
statusResponse <- withFreshClient appState \client -> do
270+
let statusUrl = toString client.apiUrl <> "/repos/" ++ toString client.owner ++ "/" ++ toString client.repo ++ "/commits/" ++ toString sha ++ "/statuses"
271+
initStatusRequest <- HTTP.parseRequest statusUrl
272+
let statusReq = initStatusRequest
273+
{ HTTP.method = "GET"
274+
, HTTP.requestHeaders =
275+
[ ("Authorization", "Bearer " <> TE.encodeUtf8 client.accessToken)
276+
, ("Accept", "application/vnd.github.v3+json")
277+
, ("User-Agent", "restaumatic-bot")
278+
]
279+
}
280+
HTTP.httpLbs statusReq client.manager
281+
255282
if statusResponse.responseStatus.statusCode == 200
256283
then do
257284
let mStatuses = eitherDecode @[StatusResponse] (HTTP.responseBody statusResponse)

test/FakeGithubApi.hs

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
{-# LANGUAGE OverloadedStrings #-}
22
{-# LANGUAGE RecursiveDo #-}
33

4-
module FakeGithubApi (Server, start, stop, clearOutput, getOutput, setTokenLifetime) where
4+
module FakeGithubApi (Server, start, stop, clearOutput, getOutput, setTokenLifetime, setTokenExpirationOffset) where
55

66
import Universum
77

88
import Network.Wai
99
import qualified Network.Wai.Handler.Warp as Warp
10-
import Network.HTTP.Types (status200, status201, status400, status404, methodPost, methodGet)
10+
import Network.HTTP.Types (status200, status201, status400, status401, status404, methodPost, methodGet)
1111
import Data.Aeson (encode, object, (.=), Value)
1212
import qualified Data.Aeson as Aeson
1313
import qualified Data.Map.Strict as Map
14-
import Data.Time.Clock (getCurrentTime, addUTCTime)
14+
import Data.Time.Clock (getCurrentTime, addUTCTime, UTCTime)
1515
import Data.Time.Format.ISO8601 (iso8601Show)
16+
import qualified Data.ByteString as BS
1617

1718
import Control.Concurrent (forkIO, ThreadId, killThread)
1819

@@ -35,43 +36,87 @@ handleAccessTokenRequest server instId req respond =
3536
then do
3637
-- Read token lifetime from server state
3738
lifetimeSeconds <- readIORef server.tokenLifetimeSeconds
39+
offset <- readIORef server.tokenExpirationOffset
3840
now <- getCurrentTime
39-
let expiresAt = addUTCTime (fromIntegral lifetimeSeconds) now
41+
let actualExpiry = addUTCTime (fromIntegral lifetimeSeconds) now
42+
let reportedExpiry = addUTCTime (fromIntegral offset) actualExpiry
43+
44+
-- Issue unique token
45+
n <- atomicModifyIORef server.tokenCounter (\c -> (c + 1, c + 1))
46+
let tokenText = "mock-access-token-" <> show n
47+
48+
-- Track the token with its actual expiry
49+
modifyIORef server.validTokens (Map.insert tokenText actualExpiry)
50+
4051
addOutput server $ "Requested access token for installation " <> instId
4152
respond $ responseLBS status200 [("Content-Type", "application/json")]
4253
(encode $ object
43-
[ "token" .= ("mock-access-token" :: Text)
44-
, "expires_at" .= iso8601Show expiresAt
54+
[ "token" .= tokenText
55+
, "expires_at" .= iso8601Show reportedExpiry
4556
, "installation_id" .= instId
4657
])
4758
else respond $ responseLBS status400 [] "Bad Request"
4859

60+
-- Validate the Bearer token from the Authorization header.
61+
-- Returns Nothing if valid, or a 401 response if invalid/expired.
62+
validateToken :: Server -> Request -> IO (Maybe Response)
63+
validateToken server req = do
64+
tokens <- readIORef server.validTokens
65+
-- If no tokens have been issued yet, skip validation (backwards compat)
66+
if Map.null tokens
67+
then pure Nothing
68+
else do
69+
now <- getCurrentTime
70+
let mAuth = fmap snd $ find (\(k, _) -> k == "Authorization") (requestHeaders req)
71+
case mAuth of
72+
Just authHeader
73+
| Just tokenBS <- BS.stripPrefix "Bearer " authHeader -> do
74+
let tokenText = decodeUtf8 tokenBS
75+
case Map.lookup tokenText tokens of
76+
Just expiry
77+
| now < expiry -> pure Nothing -- Valid
78+
| otherwise -> pure $ Just $ responseLBS status401 [] "Token expired"
79+
Nothing -> pure $ Just $ responseLBS status401 [] "Unknown token"
80+
| otherwise -> pure $ Just $ responseLBS status401 [] "Invalid Authorization header"
81+
Nothing -> pure $ Just $ responseLBS status401 [] "Missing Authorization header"
82+
4983
handleCommitStatusRequest :: Server -> Text -> Text -> Text -> Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived
5084
handleCommitStatusRequest server owner repo commitSha req respond =
5185
if requestMethod req == methodPost
5286
then do
53-
body <- strictRequestBody req
54-
-- Store the status for later retrieval
55-
storeStatus server commitSha body
56-
-- Note: commit SHA omitted because it's nondeterministic
57-
addOutput server $ "Updated commit status for " <> owner <> "/" <> repo <> " to " <> decodeUtf8 body
58-
respond $ responseLBS status201 [("Content-Type", "application/json")]
59-
(encode $ object ["state" .= ("success" :: Text), "sha" .= commitSha, "repository" .= repo, "owner" .= owner])
87+
mReject <- validateToken server req
88+
case mReject of
89+
Just rejection -> respond rejection
90+
Nothing -> do
91+
body <- strictRequestBody req
92+
-- Store the status for later retrieval
93+
storeStatus server commitSha body
94+
-- Note: commit SHA omitted because it's nondeterministic
95+
addOutput server $ "Updated commit status for " <> owner <> "/" <> repo <> " to " <> decodeUtf8 body
96+
respond $ responseLBS status201 [("Content-Type", "application/json")]
97+
(encode $ object ["state" .= ("success" :: Text), "sha" .= commitSha, "repository" .= repo, "owner" .= owner])
6098
else respond $ responseLBS status400 [] "Bad Request"
6199

62100
handleGetCommitStatuses :: Server -> Text -> Text -> Text -> Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived
63101
handleGetCommitStatuses server _owner _repo commitSha req respond =
64102
if requestMethod req == methodGet
65103
then do
66-
statuses <- getStatuses server commitSha
67-
respond $ responseLBS status200 [("Content-Type", "application/json")] (encode statuses)
104+
mReject <- validateToken server req
105+
case mReject of
106+
Just rejection -> respond rejection
107+
Nothing -> do
108+
statuses <- getStatuses server commitSha
109+
respond $ responseLBS status200 [("Content-Type", "application/json")] (encode statuses)
68110
else respond $ responseLBS status400 [] "Bad Request"
69111

70112
data Server = Server
71113
{ tid :: ThreadId
72114
, output :: IORef [Text]
73115
, statuses :: IORef (Map Text [Value]) -- Map from commit SHA to list of status objects
74116
, tokenLifetimeSeconds :: IORef Int
117+
, tokenCounter :: IORef Int
118+
, validTokens :: IORef (Map Text UTCTime) -- Map from token to actual expiry time
119+
, tokenExpirationOffset :: IORef Int -- Seconds to add to reported expires_at (simulates clock skew)
75120
}
76121

77122
start :: Int -> IO Server
@@ -80,9 +125,12 @@ start port = do
80125
output <- newIORef []
81126
statuses <- newIORef Map.empty
82127
tokenLifetimeSeconds <- newIORef 3600 -- Default: 1 hour
128+
tokenCounter <- newIORef 0
129+
validTokens <- newIORef Map.empty
130+
tokenExpirationOffset <- newIORef 0
83131
let settings = Warp.setPort port $ Warp.setBeforeMainLoop (putMVar started ()) Warp.defaultSettings
84132
rec
85-
let server = Server {tid, output, statuses, tokenLifetimeSeconds}
133+
let server = Server {tid, output, statuses, tokenLifetimeSeconds, tokenCounter, validTokens, tokenExpirationOffset}
86134
tid <- forkIO $ Warp.runSettings settings $ app server
87135
takeMVar started
88136
pure server
@@ -94,10 +142,13 @@ addOutput :: Server -> Text -> IO ()
94142
addOutput (Server {output}) msg = modifyIORef output (msg :)
95143

96144
clearOutput :: Server -> IO ()
97-
clearOutput (Server {output, statuses, tokenLifetimeSeconds}) = do
98-
writeIORef output []
99-
writeIORef statuses Map.empty
100-
writeIORef tokenLifetimeSeconds 3600 -- Reset to default
145+
clearOutput server = do
146+
writeIORef server.output []
147+
writeIORef server.statuses Map.empty
148+
writeIORef server.tokenLifetimeSeconds 3600 -- Reset to default
149+
writeIORef server.tokenCounter 0
150+
writeIORef server.validTokens Map.empty
151+
writeIORef server.tokenExpirationOffset 0
101152

102153
getOutput :: Server -> IO [Text]
103154
getOutput (Server {output}) = reverse <$> readIORef output
@@ -116,3 +167,6 @@ getStatuses (Server {statuses}) commitSha = do
116167

117168
setTokenLifetime :: Server -> Int -> IO ()
118169
setTokenLifetime server seconds = writeIORef server.tokenLifetimeSeconds seconds
170+
171+
setTokenExpirationOffset :: Server -> Int -> IO ()
172+
setTokenExpirationOffset server seconds = writeIORef server.tokenExpirationOffset seconds

test/Spec.hs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ runTest fakeGithubServer source = do
9191
whenJust options.githubTokenLifetime $ \lifetime ->
9292
FakeGithubApi.setTokenLifetime fakeGithubServer lifetime
9393

94+
-- Set token expiration offset if specified in test
95+
whenJust options.githubTokenExpirationOffset $ \offset ->
96+
FakeGithubApi.setTokenExpirationOffset fakeGithubServer offset
97+
9498
(pipeRead, pipeWrite) <- createPipe
9599
path <- getEnv "PATH"
96100

@@ -175,6 +179,7 @@ data Options = Options
175179
, githubKeys :: Bool
176180
, quiet :: Bool
177181
, githubTokenLifetime :: Maybe Int
182+
, githubTokenExpirationOffset :: Maybe Int
178183
}
179184

180185
instance Default Options where
@@ -185,6 +190,7 @@ instance Default Options where
185190
, githubKeys = False
186191
, quiet = False
187192
, githubTokenLifetime = Nothing
193+
, githubTokenExpirationOffset = Nothing
188194
}
189195

190196
getOptions :: Text -> Options
@@ -207,6 +213,9 @@ getOptions source = flip execState def $ go (lines source)
207213
["#", "github", "token", "lifetime", n] -> do
208214
modify (\s -> s { githubTokenLifetime = readMaybe (toString n) })
209215
go rest
216+
["#", "github", "token", "expiration", "offset", n] -> do
217+
modify (\s -> s { githubTokenExpirationOffset = readMaybe (toString n) })
218+
go rest
210219
["#", "quiet"] -> do
211220
modify (\s -> (s :: Options) { quiet = True })
212221
go rest
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- output:
2+
[mytask] stdout | Task started, pending status posted
3+
[mytask] stdout | Task finishing (should retry on 401)
4+
[mytask] warn | GitHub API returned 401, force-refreshing token and retrying...
5+
-- github:
6+
Requested access token for installation 123
7+
Updated commit status for fakeowner/fakerepo to {"context":"mytask","description":"not cached","state":"pending","target_url":null}
8+
Requested access token for installation 123
9+
Updated commit status for fakeowner/fakerepo to {"context":"mytask","description":null,"state":"success","target_url":null}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# check output github
2+
# no toplevel
3+
# github keys
4+
# github token lifetime 2
5+
# github token expiration offset 5
6+
7+
export TASKRUNNER_ENABLE_COMMIT_STATUS=1
8+
export TASKRUNNER_GITHUB_TOKEN_REFRESH_THRESHOLD_SECONDS=0
9+
10+
git init -q
11+
git commit --allow-empty -q -m "Initial commit"
12+
13+
taskrunner -n mytask bash -e -c '
14+
snapshot -n --commit-status
15+
echo "Task started, pending status posted"
16+
sleep 3
17+
echo "Task finishing (should retry on 401)"
18+
'

0 commit comments

Comments
 (0)