Packages
bexio_api_client
1.0.0
Bexio API Client for accessing the API described at https://docs.bexio.com.
Current section
Files
Jump to
Current section
Files
bexio_api_client
README.md
README.md
# Bexio API Client for Elixir
This is a simple client for the API described at <https://docs.bexio.com/>. It will support all functionality while also replacing enums with atoms and
making the structs more useful in elixir.
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `bexio_api_client` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:bexio_api_client, "~> 1.0"}
]
end
```
## Requirements:
The API uses [Req](https://github.com/wojtekmach/req/tree/v0.5.0) for the client access to also handle the exponential back-off required in the documentation.
The retry policy for API requests is the `BexioApiClient.Req.RetryPolicy`
behaviour, implemented by `BexioApiClient.Req.RewriteDelay` (honors Bexio's
`ratelimit-reset` header, retries transient server errors and transport errors,
emits retry telemetry). Provide your own policy via:
```elixir
config :bexio_api_client, :req_options, retry: &MyApp.RetryPolicy.retry/2
```
The token endpoint never uses this policy — refresh backoff belongs to
`BexioApiClient.Req.AccessTokenRefresher`.
## Refresh tokens
Bexio does not currently rotate refresh tokens — the same refresh token keeps
working — and the access token refresher does not manage rotation: it always
refreshes with the refresh token you 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`; host applications
would have to persist it themselves.
Separately, hammering the token endpoint after failing refreshes gets your
OAuth client **blacklisted** by Bexio (`403` with
`{"reason": "blacklisted", "status": "blocked"}`, enforced per IP).
Refresh failures are classified and backed off automatically:
- `400`/`401` `invalid_grant` and `403` (blacklisted) responses are **permanent**:
requests fail fast with `BexioApiClient.RefreshError` and the token endpoint is
not hit again. Call `BexioApiClient.Req.AccessTokenRefresher.clear_refresh_failure/1`
once you have a new refresh token.
- all other failures (including `429`) back off exponentially with jitter (base
500ms, cap 30s); after 15 consecutive failures (~4.5 minutes of probing),
requests fail fast for 10 minutes, with at most one further attempt per
10-minute window.
Do not wrap client requests in your own retry loops: transient token failures
are already backed off, and permanent ones will never succeed — hammering the
token endpoint with failing refreshes is exactly what gets an OAuth client
blacklisted.
When the refresher gives up on a refresh token — immediately on a permanent
failure (`invalid_grant`, blacklisted) or after 5 consecutive transient
failures (5-minute cooldown) — it invokes the optional `:on_refresh_gave_up`
callback once:
```elixir
req =
BexioApiClient.new(client_id, client_secret, refresh_token,
on_refresh_gave_up: {MyApp.Auth, :refresh_gave_up, [user_id]}
)
```
The MFA is called as `MyApp.Auth.refresh_gave_up(user_id, client_id, error_class)`
so the host application can react (re-authenticate the user, disable sync jobs,
alert) and re-enable the token afterwards with
`BexioApiClient.Req.AccessTokenRefresher.clear_refresh_failure/1`. The callback
runs synchronously while the refresh lock is held: keep it fast and do not make
requests with the same client from it.
The access token cache lives in the `:bexio_api_client_access_tokens` ETS
table keyed by `{client_id, refresh_token}`.
The ETS tables are owned by the process that created them and are deleted when
that process exits. Call
`BexioApiClient.Req.AccessTokenRefresher.create_ets_table/0` once from a
long-lived process (e.g. in your application's `start/2`) so the cache and the
refresh backoff state live as long as your application. Losing the tables is
always safe — tokens are re-fetched and backoff state resets — but the cache
silently stops working until they are recreated. Concurrent creation is safe.
`BexioApiClient.access_token/3` returns the full token map
(`%{access_token: ..., expires_in: ..., refresh_token: ..., id_token: ..., scope: ..., token_type: ...}`).
Every non-success response returns `{:error, {:unexpected_response, status, body}}`
and logs the full `Req.Response` for diagnostics.
### Stopping it
The refresher has no background process — it only runs when a request flows
through a client built with `BexioApiClient.new/3`. To stop it completely,
stop making requests through that client (e.g. disable the jobs that use it —
`:on_refresh_gave_up` is the hook that tells you when to do that), or switch
to the static API token client `BexioApiClient.new/1`, which has no refresher
attached.
## Telemetry
The client emits telemetry events that you can use to monitor API usage, rate limiting, and performance. All events are emitted from the request/retry layer, providing comprehensive visibility into all HTTP interactions.
### Available Events
#### `[:bexio_api_client, :request, :success]`
Emitted when a request completes successfully (status 200-399).
- **Measurements**: `%{count: 1}`
- **Metadata**: `%{status: integer()}` - HTTP status code (200, 201, 301, etc.)
#### `[:bexio_api_client, :request, :error]`
Emitted when a request completes with an error (status 400+).
- **Measurements**: `%{count: 1}`
- **Metadata**: `%{status: integer()}` - HTTP status code (401, 404, etc.)
#### `[:bexio_api_client, :rate_limit, :hit]`
Emitted when a rate limit (429) response is received from the API.
- **Measurements**: `%{reset_seconds: integer()}` - The number of seconds to wait before retrying (from the `ratelimit-reset` header)
- **Metadata**: `%{has_reset_header: boolean()}` - Whether the `ratelimit-reset` header was present in the response
#### `[:bexio_api_client, :request, :retry]`
Emitted when a request will be retried due to server errors (408, 500, 502, 503, 504).
- **Measurements**: `%{count: 1}`
- **Metadata**: `%{status: integer()}` - HTTP status code that triggered the retry
#### `[:bexio_api_client, :refresh, :failed]`
Emitted when a refresh attempt failed.
- **Measurements**: `%{count: 1}`
- **Metadata**: `%{error_class: :invalid_refresh_token | :client_blacklisted | :transient, failure_count: integer()}`
#### `[:bexio_api_client, :refresh, :backoff]`
Emitted when a request was rejected while a refresh backoff was active (the token
endpoint was not contacted).
- **Measurements**: `%{count: 1}`
- **Metadata**: `%{error_class: atom(), retry_in_ms: integer() | :infinity}`
### Usage Example
```elixir
# Track successful requests
:telemetry.attach(
"bexio-success-metrics",
[:bexio_api_client, :request, :success],
fn _event, _measurements, metadata, _config ->
IO.puts("Successful API call with status #{metadata.status}")
end,
nil
)
# Track error requests
:telemetry.attach(
"bexio-error-metrics",
[:bexio_api_client, :request, :error],
fn _event, _measurements, metadata, _config ->
Logger.warning("API error with status #{metadata.status}")
end,
nil
)
# Track rate limiting
:telemetry.attach(
"bexio-rate-limit-metrics",
[:bexio_api_client, :rate_limit, :hit],
fn _event, measurements, metadata, _config ->
if metadata.has_reset_header do
IO.puts("Rate limit hit, waiting #{measurements.reset_seconds} seconds")
else
IO.puts("Rate limit hit without reset header, using default retry")
end
end,
nil
)
# Track retry attempts
:telemetry.attach(
"bexio-retry-metrics",
[:bexio_api_client, :request, :retry],
fn _event, _measurements, metadata, _config ->
IO.puts("Request will be retried due to status #{metadata.status}")
end,
nil
)
# Track specific error codes like unauthorized
:telemetry.attach(
"bexio-unauthorized-metrics",
[:bexio_api_client, :request, :error],
fn _event, _measurements, metadata, _config ->
if metadata.status == 401 do
Logger.warning("Unauthorized API request detected")
end
end,
nil
)
```
You can also use libraries like [TelemetryMetrics](https://github.com/beam-telemetry/telemetry_metrics) to aggregate these events into metrics for monitoring dashboards.
## Documentation
Documentation can be generated with [ExDoc](https://github.com/)
and can be found at <https://hexdocs.pm/bexio_api_client>.
## Building a release
1. Update the versions in README.md, and mix.exs
2. Update the CHANGELOG.md
3. (optional, recommended) Run `mix test` and `mix dialyzer`
5. Create a tag for the new version.
6. Push it, github actions will do the release.