BexioApiClient.Req.AccessTokenRefresher (bexio_api_client v1.0.0)

Req request step that fetches and caches access tokens for Bexio OAuth2 clients.

The access token cache lives in the ETS table :bexio_api_client_access_tokens and is keyed by {client_id, refresh_token}. Each entry stores {access_token, expires_at} with an expiry reserve of 60 seconds.

Bexio does not currently rotate refresh tokens (the same refresh token keeps working), so the refresher does not manage rotation: it always refreshes with the refresh token the host application passed to BexioApiClient.new/3. Should Bexio ever enable rotation (a Keycloak realm option), token responses would expose the new token through BexioApiClient.access_token/3 and host applications would have to persist it themselves.

The refresher has no background process: it only runs when a request flows through the client. Stopping it completely means stopping the requests (see the :on_refresh_gave_up callback below) or using the API token client BexioApiClient.new/1, which has no refresher attached.

Refresh failures are classified and stored with a backoff deadline in :bexio_api_client_refresh_failures:

  • 400/401 invalid_grant responses and 403 responses (Bexio blocks misbehaving OAuth clients, e.g. after a retry flood on failing refreshes) are permanent: requests fail fast without touching the token endpoint again. Call clear_refresh_failure/1 once the host supplies a new refresh token.
  • all other failures (including 429) back off exponentially with jitter (base 500ms, cap 30s); after 15 consecutive failures (roughly 4.5 minutes of probing) requests fail fast for 10 minutes, with at most one further attempt per 10-minute window.

On any refresh failure the request pipeline halts with BexioApiClient.RefreshError; requests are never sent unauthenticated. Retry policy for the token endpoint belongs to this module, so host applications should not wrap requests in their own retry loops.

giving-up-and-the-on_refresh_gave_up-callback

Giving up and the :on_refresh_gave_up callback

The refresher gives up on a refresh token in two cases:

  • a permanent failure (:invalid_refresh_token, :client_blacklisted) is recorded — giving up on the first occurrence,
  • 15 consecutive transient failures (roughly 4.5 minutes of probing) enter the 10-minute cooldown.

At that transition the optional :on_refresh_gave_up callback is invoked once (not on subsequent backoff-rejected requests):

BexioApiClient.new(client_id, client_secret, refresh_token,
  on_refresh_gave_up: {MyApp.Auth, :refresh_gave_up, [user_id]}
)

called as apply(module, function, args ++ [client_id, error_class]). Host applications can react by re-authenticating the user, disabling sync jobs or alerting, and re-enable the token afterwards with clear_refresh_failure/1. The callback runs synchronously in the failing request's process: keep it fast and do not make requests with the same client from it.

ets-table-ownership

ETS table ownership

The tables are owned by the process that created them: when that process exits, the tables are deleted together with it. Losing the tables is always safe (tokens are a cache and backoff state resets), but the cache silently stops working until the tables are recreated.

Call create_ets_table/0 once from a long-lived process at application startup, e.g. in your application's start/2:

BexioApiClient.Req.AccessTokenRefresher.create_ets_table()

Then the tables live as long as your application. When they are created lazily by request processes instead, any of those processes may end up owning the tables, and the cache disappears whenever that process exits. Concurrent creation is safe: the losing process ignores the already-exists error.

telemetry-events

Telemetry events

  • [:bexio_api_client, :refresh, :failed] - a refresh attempt failed. Measurements: %{count: 1}. Metadata: %{error_class: class, failure_count: count}
  • [:bexio_api_client, :refresh, :backoff] - a request was rejected while a backoff was active. Measurements: %{count: 1}. Metadata: %{error_class: class, retry_in_ms: milliseconds | :infinity}

Link to this section Summary

Functions

Removes all refresh failures recorded for the given client_id, re-enabling refresh attempts after a permanent failure (e.g. after the host persisted a new refresh token).

Creates the ETS tables used by the refresher: the access token cache and the refresh failure cache.

Remove all expired access tokens from the ETS table. Triggering this is part of the job of the caller process for now.

Link to this section Functions

Link to this function

attach(request, options \\ [])

@spec attach(
  Req.Request.t(),
  keyword()
) :: Req.Request.t()
Link to this function

clear_refresh_failure(client_id)

@spec clear_refresh_failure(String.t()) :: :ok

Removes all refresh failures recorded for the given client_id, re-enabling refresh attempts after a permanent failure (e.g. after the host persisted a new refresh token).

Link to this function

create_ets_table()

@spec create_ets_table() :: :ok

Creates the ETS tables used by the refresher: the access token cache and the refresh failure cache.

Link to this function

remove_expired_tokens()

@spec remove_expired_tokens() :: :ok

Remove all expired access tokens from the ETS table. Triggering this is part of the job of the caller process for now.