Add refresh buffer - #55
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens OAuth request authentication by refreshing tokens slightly before expiry and retrying once on a 401, addressing edge cases where a token expires between the client-side freshness check and server-side validation.
Changes:
- Add an expiry “refresh buffer” to proactively refresh near-expired tokens.
- Retry a request once on 401 by refreshing/reloading the stored token and re-sending the request.
- Update and expand specs to cover buffer behavior, 401 retry behavior, and concurrent refresh reuse.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| spec/booqable/oauth_client_spec.rb | Reworks middleware integration specs to cover refresh buffer, single-retry-on-401, and concurrent refresh behavior. |
| spec/booqable/client_spec.rb | Updates client specs to align with the new “expires_at-based” refresh behavior (no reliance on expired?). |
| lib/booqable/middleware/auth/oauth.rb | Implements refresh buffer logic and a single 401-triggered refresh+retry flow in the OAuth Faraday middleware. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
README.md:115
- The
around_refresh_tokencallable is invoked around the read + expiry-check sequence even when no refresh happens (and then an additional time on the 401 retry path). The current wording "around each refresh attempt" is misleading about when the callback runs.
serialize the read + expiry-check + refresh sequence. The middleware yields
to the callable around each refresh attempt — normally once per request, plus
one more time if a request is retried after the server rejects its token with a
401. The host application decides how to lock.
CHANGELOG.md:5
- Grammar: "refresh the access token a short buffer" is missing a preposition and reads incorrectly in the changelog entry.
- OAuth middleware: refresh the access token a short buffer
(`REFRESH_BUFFER_SECONDS`, 60s) before it expires, and retry a request once if
the server still rejects the token with a 401 (refreshing first). This avoids
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
lib/booqable/middleware/auth/oauth.rb:92
callloads/refreshes the stored OAuth token even when the request already has a caller-suppliedAuthorizationheader (i.e., whenmanages_authorization?is false). This can unexpectedly take locks viaaround_refresh_token, refresh/write tokens, and do extra work for requests that explicitly opted out of OAuth handling.
def call(env)
token = fresh_token
managed = manages_authorization?(env)
# (Re)apply our token on every managed pass — including a Faraday::Retry
# re-send, which re-enters with our previous attempt's header still set.
env.request_headers["Authorization"] = bearer_header(token) if managed
request_body = env[:body]
response = @app.call(env)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
lib/booqable/middleware/auth/oauth.rb:18
- The usage example sets
api_endpointto a token URL (.../oauth/token), butOAuthClientalready appends/oauth/tokeninternally (seelib/booqable/oauth_client.rb:38). Using the token URL here would produce an incorrecttoken_urllike.../oauth/token/oauth/token.
# api_endpoint: "https://company.booqable.com/api/v4/oauth/token",
# read_token: -> { stored_token },
# write_token: ->(token) { store_token(token) }
class OAuth < Base
# Refresh the token slightly before it actually expires. A request that
lib/booqable/middleware/auth/oauth.rb:104
- On the 401 retry path the request body is restored, but if the body is an IO/stream it may have been consumed by the first attempt. Rewinding when possible avoids retrying with an empty/partial body.
# flight, and refreshing again could fail on the rotated refresh token.
# A refresh that itself fails raises, surfacing the genuine auth error
# rather than retrying with a doomed token.
rejected = token.token
token = fresh_token { |reloaded| reloaded.token == rejected }
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
lib/booqable/middleware/auth/oauth.rb:104
- Use
env.body = ...rather than writingenv[:body]directly, for consistency with Faraday's public API (and to match how other middleware in this repo reads the body).
env[:body] = request_body
lib/booqable/middleware/auth/oauth.rb:93
- Use Faraday::Env's
bodyaccessor instead ofenv[:body]to align with the public API and existing middleware usage (e.g.lib/booqable/middleware/auth/single_use.rb:119).
This issue also appears on line 104 of the same file.
request_body = env[:body]
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
lib/booqable/middleware/auth/oauth.rb:74
- Use Faraday::Env's
bodyaccessor consistently (as done in other middleware) instead ofenv[:body]. It’s clearer and avoids relying on hash-style access semantics when saving/restoring the request body for the 401 retry.
body = env[:body]
env.request_headers["Authorization"] = "Bearer #{token.token}"
A token in its final second passes the strict expired? check here, goes out on the wire, and gets rejected server-side as "Token is invalid (unknown)" once network latency or clock skew pushes evaluation past the expiry boundary. Refresh REFRESH_BUFFER_SECONDS (60s) early instead. expires_at is coerced with to_i because host apps may hand back a Time (e.g. an ActiveRecord datetime) rather than the epoch integer OAuth2 uses. Spec doubles that stubbed expired?: true alongside a future or missing expires_at are updated to carry a genuinely past expires_at. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
65bad30 to
2af815a
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (3)
lib/booqable/middleware/auth/oauth.rb:85
expires_at.to_ican produce incorrect results whenexpires_atis aString(e.g., ISO8601) sinceString#to_idoes not parse timestamps (it often becomes 0 or the leading year). That can cause premature refreshes or missed refreshes. Consider explicitly handlingTime,Integer, and common string formats (e.g., parse ISO8601), or validating/normalizingexpires_atwhen reading the token hash so this comparison is reliable.
def expires_soon?(token)
token.expires_at.nil? || Time.now.to_i >= token.expires_at.to_i - REFRESH_BUFFER_SECONDS
end
spec/booqable/client_spec.rb:421
- WebMock header matching can be sensitive to how headers are normalized. To reduce brittleness, match the canonical header name (e.g.,
\"Authorization\") rather thanauthorization, or use a more flexible matcher for the header key. This makes the test less dependent on adapter/header normalization details.
orders_request = stub_request(:get, booqable_url("/orders")).with(headers: { authorization: "Bearer renewed_token" })
spec/booqable/client_spec.rb:412
- Using
Time.nowdirectly in specs can introduce timing-related flakiness and makes intent harder to reason about. Consider freezing time (e.g.,ActiveSupport::Testing::TimeHelpers#freeze_time/travel_to, or Timecop if used in the repo) and basingexpires_aton the frozen time to keep this deterministic.
expires_at: Time.now + 30 # not yet expired, but inside the buffer
pbalaban
left a comment
There was a problem hiding this comment.
Looks good for me, much better then the initial version 👍
Claude found this issue, I did not verify it myself:
refresh_token!rescue crashes withNoMethodError, and the buffer makes it fire on requests that used to work (oauth.rb:96)
e.response.response.envassumes e.response is a Faraday response. oauth2 alsoraises OAuth2::Error.new({...})with a plain Hash — when the stored token has no refresh token, or when the token endpoint answers without an access token.
It looks like it's not a regression and we did not see such an exceptions in AppSignal yet.
|
@pbalaban will be addressed in a separate PR |
`refresh_token!`'s rescue assumes every `OAuth2::Error` wraps an HTTP
response:
```ruby
rescue OAuth2::Error => e
response = e.response.response.env
Booqable::Error.from_response(response)
end
```
But oauth2 also raises `OAuth2::Error.new({...})` with a **plain Hash**
when the failure never reached the token endpoint — most notably when
the stored token has no refresh token ([`access_token.rb:218` in oauth2
2.0.24](https://github.com/oauth-xx/oauth2/blob/v2.0.24/lib/oauth2/access_token.rb#L218)).
`Error#response` returns whatever the error was constructed with, so
`e.response.response` dies with:
```
NoMethodError: undefined method 'response' for an instance of Hash
```
Reproduced against oauth2 2.0.24. Not a regression — this path has
existed as long as the rescue — but the refresh buffer from #55 widens
exposure slightly: a refresh-token-less token *inside* the 60s window
used to be sent as-is and now triggers a refresh attempt.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
We had a case when the payment option was attempted to be deleted one second before the token expiry time raising 401 when the network request reached Booqable. This adds buffer for expiry.