Add elementary support for reauth.
Preemptively attempting to reauthenticate before a token expires is
fraught with dangers. Everything from buggy API implementations to
natural lack of determinism when an API races against a server can cause
opportunities to reauth to be missed. The recommended solution is to
just detect when a 401 Unauthorized error happens, and attempt to handle
it with a new attempt at reauthentication.
This commit does not implement the full logic to do this just yet.
However, the groundwork seems to be in place, if my unit tests are any
indication. Here's how I'd _like_ to use it:
Let's focus on ListServers. We'd end up rewriting this function like
so:
// See the CloudServersProvider interface for details.
func (gcp *genericServersProvider) ListServers() ([]Server, error) {
var ss []Server
url := gcp.endpoint + "/servers"
errOuter := gcp.context.WithReauth(func() error { // NEW CODE
errInner := perigee.Get(url, perigee.Options{
CustomClient: gcp.context.httpClient,
Results: &struct{ Servers *[]Server }{&ss},
MoreHeaders: map[string]string{
"X-Auth-Token": gcp.access.AuthToken(),
},
})
return errInner
}) // NEW CODE
return ss, errOuter
}
Note how small the change to the existing code is: two lines of code.
Context.WithReauth() works by invoking the supplied function,
optimistically hoping that it'd succeed (e.g., that error is nil). If
so, we bee-line the results back to the ListServers implementation,
where it returns its results in ss and a nil value for errOuter.
If it does fail with an error other than a 401 result, ss is undefined,
and the error will propegate back out to errOuter, where again it'll be
returned to the caller.
If the error is a 401 error, however, we invoke the configured
reauthentication handler for the given context. This code is
responsible for attempting the reauthentication process. If an error
occurs in this handler, it too will propegate out to errOuter.
If everything succeeds up to this point, however, the function defined
above is called a second time. Assuming it succeeds, it will overwrite
the desired result (ss in our case) with a valid value, and return a nil
error. This will, as above, produce the desired outcome and all is
well.
If it fails a second time, however, you're on your own. Even if the 2nd
failure is another 401 result, that error will propegate out to
errOuter, where it'll be returned to the caller.
Known problems with the code as it stands:
1) Procedure passed to WithReauth() is expected to return an
interface{}, being the desired, unmarshalled object from a web request
against an API. But, I had forgotten that I could use a free variable
(as I do in the example above) and avoid having to return it explicitly.
I'll need to remove this result slot, saving code and complexity.
2) I *may* need to pass an AccessProvider as part of the WithReauth()
call, so that the reauth handler has something to work with. Otherwise,
I'll need to define an interface for all xyzProvider entities to recover
the AccessProvider it uses. I'm not entirely sure if this breaks
encapsulation or not.
Otherwise, the logic is 99.44% finished, and unit tests all pass with
this commit. Acceptance tests aren't affected, as no production logic
has yet been changed to use this mechanism.
3 files changed