Skip to main content

Client & configuration

The client is the entry point: it owns a shared HTTP connection and token handling and exposes one accessor per service. It is safe to share — create one and reuse it for the lifetime of your application.

import ai.intellistream.datahub.sdk.client.DatahubClient;
import ai.intellistream.datahub.sdk.client.DatahubConfig;

// from the environment (and a .env file, if present)
DatahubClient client = DatahubClient.fromEnv();

// or explicitly
DatahubClient client = DatahubClient.create(DatahubConfig.builder()
.baseUrl("https://api.intellistream.ai")
.token(System.getenv("TOKEN"))
.build());

Services

ServiceJavaPythonRust
Resourcesclient.resources()client.resourcesapi.resources
Time-seriesclient.timeseries()client.timeseriesapi.time_series
Datasetsclient.datasets()client.datasetsapi.datasets
Eventsclient.events()client.eventsapi.events
Unitsclient.units()client.unitsapi.units
Filesclient.files()client.filesapi.files
Subscriptionsclient.subscriptions()client.subscriptionsapi.subscriptions

Authentication

All three clients read the same configuration: a base URL plus either a static bearer token or OAuth2 client-credentials (the SDK fetches and refreshes the token).

VariableMeaning
BASE_URLAPI base URL (required)
TOKENStatic bearer token
CLIENT_ID / CLIENT_SECRET / TOKEN_URIOAuth2 client-credentials (all three)
PROJECT_NAMEOptional project/tenant hint

fromEnv() / from_env() / create_api_service() read these from the environment, falling back to a .env file in the working directory (real environment variables win).

Provider-specific parameters

scope and audience are left out of the token request unless you set them. What DataHub needs is a token carrying the organization claim naming exactly one organization (tenant routing and dataset grants ride on it); whether that takes a scope depends on how your realm issues the claim. A realm using a client protocol mapper (the common production setup) puts it on every token, so leave SCOPE unset. A realm using Keycloak Organizations only issues it when the request names organization:* or organization:<alias>. When the claim is missing, every call fails 401 invalid_token, which looks like a credentials problem but is not.

When the claim is present but names an organization this deployment holds no tenant for (never onboarded, or since removed), every call fails 403 with an application/problem+json body of type: ".../errors/unknown-tenant" naming the refused organizationId. Retrying never helps: an administrator has to register the organization. This previously surfaced as a 500, so retry logic that keys on 5xx should be told to give up on it.

VariableJava builderPython kwargRust setterWhen you need it
SCOPE.scope(...)scope=set_scope(...)organization:* if your realm issues the organization claim through Keycloak Organizations (see above). Entra ID requires api://<app-id-uri>/.default. Space-separate several.
AUDIENCE.audience(...)audience=set_audience(...)Auth0 requires it. Keycloak ignores it.

When a call returns 401

401 invalid_token has two causes, and they need different fixes.

CauseWhat you seeFix
The token carries no organization claimEvery call fails, from the first oneSet SCOPE (see above)
The identity provider refused the tokenCalls succeed, then start failing part-way through a runGet a new token

The second one catches long-running processes. The API checks your token locally (signature, expiry, issuer) and separately reads your dataset grants from the identity provider's UserInfo endpoint, so a token can pass the first check and still be refused by the second: it is unexpired, but the session behind it has ended, because an idle or maximum session lifetime elapsed or somebody signed out. The response carries WWW-Authenticate: Bearer error="invalid_token" and a problem+json body with type: ".../errors/token-rejected".

Retrying does not clear that. Only a new token does.

Not the same as 503

503 with type: ".../errors/permissions-unavailable" means the API could not reach the identity provider to check your grants. That one is temporary, and worth retrying.

Exchanging an external token (jwt-bearer)

A token minted by one issuer is not accepted by an API that trusts another. To bridge them, the SDK can present an externally-issued JWT as an RFC 7523 assertion and exchange it for a token the API does accept. The common case is reaching a Keycloak-backed API with an Entra ID service principal:

1. client_credentials 2. jwt-bearer 3. Bearer
────────────────► ────────────────► ────────────────►
Entra token endpoint Keycloak token endpoint DataHub API
→ the assertion → the token you use

Setting an assertion source switches the request at TOKEN_URI from client-credentials to jwt-bearer. CLIENT_ID/CLIENT_SECRET/TOKEN_URI then describe the client performing the exchange, and the ASSERTION_* keys describe where the assertion comes from:

VariableJava builderPython kwargRust setterMeaning
ASSERTION.assertion(...)assertion=set_assertion(...)A ready-made JWT. Never refreshed — prefer the credentials below.
ASSERTION_CLIENT_ID / ASSERTION_CLIENT_SECRET / ASSERTION_TOKEN_URI.assertionCredentials(...)assertion_client_id= / assertion_client_secret= / assertion_token_url=set_assertion_credentials(...)Fetch the assertion with client credentials from another provider (all three).
ASSERTION_SCOPE.assertionScope(...)assertion_scope=set_assertion_scope(...)scope for the assertion request.
ASSERTION_AUDIENCE.assertionAudience(...)assertion_audience=set_assertion_audience(...)audience for the assertion request.
Python names the URL parameters *_url

The Python client already spells TOKEN_URI as token_url, so the assertion equivalent is assertion_token_url. The environment variables keep the _URI spelling in all three SDKs.

DatahubClient client = DatahubClient.create(DatahubConfig.builder()
.baseUrl("https://api.intellistream.ai")
// leg 2 — the confidential Keycloak client that performs the exchange
.clientCredentials("datahub-jwt-grant", keycloakSecret,
"https://keycloak.example.com/realms/datahub/protocol/openid-connect/token")
// leg 1 — the Entra app registration the assertion comes from
.assertionCredentials(entraAppId, entraSecret,
"https://login.microsoftonline.com/" + tenantId + "/oauth2/v2.0/token")
.assertionScope("api://" + entraAppId + "/.default")
.build());

Or entirely from the environment:

BASE_URL=https://api.intellistream.ai
CLIENT_ID=datahub-jwt-grant
CLIENT_SECRET=...
TOKEN_URI=https://keycloak.example.com/realms/datahub/protocol/openid-connect/token
ASSERTION_CLIENT_ID=<entra-application-id>
ASSERTION_CLIENT_SECRET=...
ASSERTION_TOKEN_URI=https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token
ASSERTION_SCOPE=api://<entra-application-id>/.default

The exchanged token is cached and refreshed exactly like a client-credentials one. The assertion itself is never cached — providers commonly reject a replayed assertion, so every exchange starts from a fresh request.

Server-side setup is required

The identity provider must be configured to trust the external issuer, and the external identity must map to a real user on that side. For Keycloak that means an Identity Provider with JWT Authorization Grant enabled (Keycloak 26.5+), a client with the matching capability, and a linked user carrying the roles and tenant claim. See EntraID.md in the platform repository for the full walkthrough, including the audience and assertion-lifetime settings that trip up a first attempt.

From HashiCorp Vault (Java)

The Java client can also read the same keys from a Vault KV v2 secret, with a token or with AppRole:

DatahubConfig cfg = DatahubConfig.fromVault(vaultAddr, vaultToken, "datahub/sdk");
DatahubConfig cfg = DatahubConfig.fromVaultEnv("datahub/sdk"); // VAULT_ADDR + VAULT_TOKEN
DatahubConfig cfg = DatahubConfig.fromVaultAppRole(vaultAddr, roleId, secretId, "datahub/sdk");
DatahubConfig cfg = DatahubConfig.fromVaultAppRoleEnv("datahub/sdk"); // VAULT_ADDR + VAULT_ROLE_ID + VAULT_SECRET_ID

Durable ingest buffering

Optional and off by default. When enabled, datapoint and event ingestion that can't reach the API — or is rejected with an auth failure (HTTP 401/403, e.g. an expired or rotated token) — spools to disk and is flushed automatically on the next ingest call, so neither a transient outage nor a credential hiccup loses data or raises. The buffer is a segmented, compressed log (gzip in Java, zstd in Rust/Python) bounded on two axes, either of which may be left unset; an unset axis defaults to 72 hours / 5 GiB once buffering is on:

  • time — datapoints/events older than the window are dropped.
  • size — when the on-disk spool exceeds the cap, the oldest segment is dropped.

It is memory-safe: the spool is drained in segments, so even a multi-gigabyte buffer never loads into memory, and it is recovered from disk on the next start.

DatahubClient client = DatahubClient.create(DatahubConfig.builder()
.baseUrl("https://api.intellistream.ai")
.token(System.getenv("TOKEN"))
.enableBuffering() // 72 h / 5 GiB defaults
// .bufferRetention(Duration.ofMinutes(60)) // override the time window
// .bufferMaxBytes(2L * 1024 * 1024 * 1024) // override the size cap
// .bufferDirectory(Path.of("datahub-spool")) // default: .datahub-spool
.build());

IngestResult r = client.timeseries().ingest(byExternalId);
if (r.buffered() > 0) {
// server unreachable: r.buffered() datapoints are spooled, retried on the next call
}

fromEnv() instead reads BUFFER_RETENTION (an ISO-8601 duration, e.g. PT72H), BUFFER_MAX_BYTES and BUFFER_DIRECTORY — setting either bound turns buffering on.

One 403 is never spooled

A lifetime ceiling answers 403 too, and that one is surfaced, not buffered. The auth failures are worth spooling because a rotated token or a missing grant is fixed out of band and the data then flushes; a ceiling never becomes acceptable by being replayed, so spooling it would fill the buffer with data the server refuses every time. The client matches the problem type, so an ordinary permission 403 still buffers exactly as before.

Retries are idempotent

A flush re-sends buffered data, which is safe: datapoints are keyed by (series, timestamp) and events by id, so the backend collapses duplicates. The SDK stamps each event with a time-ordered UUID v7 before the first send, so a retried event keeps the same id (see Events).

Results & errors

Most calls return the entity (or a thin wrapper around a list of them); a non-2xx response surfaces as an exception/error carrying the HTTP status and the raw body.

Methods return DataWrapper<T>getItems() holds the results. Non-2xx throws DatahubApiException:

import ai.intellistream.datahub.models.IdCollection;
import ai.intellistream.datahub.sdk.http.DatahubApiException;

try {
DataWrapper<NodeModel> r = client.resources().byIds(List.of(IdCollection.createFromExternalId("pump_1")));
r.getItems().forEach(System.out::println);
} catch (DatahubApiException e) {
System.err.println(e.statusCode() + ": " + e.body());
}
Entity ids are JSON strings on the wire

64-bit ids are serialized as JSON strings so they survive JavaScript's 2⁵³ number limit. Each client reads them back into a native integer, so this only matters if you inspect raw responses.

Which failures are worth retrying

The API answers a limit it will forgive differently from one it will not, so a client can tell them apart from the status alone:

ResponseMeaningDo
429 + Retry-AfterA rate limit or daily quotaWait the seconds it names and replay
413The request body is too largeSplit the batch, never retry as-is
403 with type: ".../errors/tenant-limit-reached"A lifetime ceilingNothing to wait for: it is raised by asking
400 / 422Validation, including the field and batch capsFix the request

The ingest paths act on that split for you: 429, 5xx and network failures are retried with backoff, and everything else is surfaced. Limits & quotas has the numbers.

Batch writes are all-or-nothing

Every call that takes a list is validated in full before anything is written, so one bad item in 500 creates nothing and the error names every offending item rather than the first. Retry the whole batch once you have fixed them.

Two responses are worth recognising by shape:

  • 400 with type: ".../errors/naming-policy" — one or more external ids broke the configured naming policy. Nothing was created; the violations array names each one and suggests a replacement.
  • A warnings array beside items on a 2xx — the write succeeded, and the ids in it are in a data steward's queue. The field is absent when empty, so existing code is unaffected.

Both shapes, and the rules behind them, are in External ids & naming.