Skip to content

feat: usage report transaction IDs and the usage history - #5

Draft
fuzcap wants to merge 1 commit into
mainfrom
feat/billing
Draft

fuzcap wants to merge 1 commit into
mainfrom
feat/billing

Conversation

@fuzcap

@fuzcap fuzcap commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

What does this PR change?

The Python SDK's side of two Kaiten features: idempotent usage reports (transactionId) and the usage history. Server side: kaitencloud/kaiten#16. The same features land in kaitencloud/sdk-go#1 and kaitencloud/sdk-js#2.

  • instances.report_usage(..., transaction_id=...). A keyed report is sent as idempotent, so it is retried like a read (connection errors, 408, 429, 5xx), every attempt under the same key. A report without a key is still sent once. A malformed key raises ValueError before any request.
  • Conflicts by code. The usage endpoint's 409 raises TransactionIdReusedError (a new ConflictError, never retried, original report in errors[0].value) or ThresholdExceededError as before.
  • instances.report_usage_detailed() returns a UsageReportResult: usage, replayed (Idempotent-Replayed) and metadata_dropped (Kaiten-Metadata-Dropped).
  • Older APIs. An API without transactionId refuses the field. The report is resent without it, and the client stops sending keys (and retrying reports) for its lifetime, logging one warning on the kaitencloud logger.
  • Usage history. instances.list_usage_reports() walks the afterSeq pages (from_, to, transaction_id, limit). It stops on a short page or at its limit, and raises PaginationError if the cursor ever fails to advance.
  • Exports. instances.export_usage_reports() and instances.export_organization_usage_reports() stream CSV or NDJSON into a binary destination and return the bytes written.
    • They use a new APIClient.stream_to, which retries only before the first byte.
    • The organization export takes instance_slug, instance_id, entitlement_slug and entitlement_id; the IDs reach deleted ones.
  • Generation. openapi/ is synced from kaitencloud/kaiten@ef755b4 (feat/billing) and the models regenerated, with UsageReport.behavior named UsageBehavior. scripts/unasync.py now rewrites aread and aiter_* for the sync client.

Why?

A usage report whose response was lost could not be retried safely, because it might count twice. A key makes the retry safe. The history lets an application show and keep what was counted.

Testing

task check passes locally: generated code up to date, ruff, mypy, and 783 tests at 96 % coverage.

  • Contract cases for the keyed report, the history and both exports (full and minimal), in both clients. They check every request against the spec and require the key in the body.
  • tests/test_usage_reports.py, 19 tests:
    • the key is sent only when given;
    • a keyed report is retried under the same key (sync and async); an unkeyed one is never retried;
    • each conflict code maps to the right error, with no retry;
    • malformed keys are refused with no request;
    • the result flags;
    • an older API gets one resend without the key, later reports go keyless, and one warning is logged; a newer API's InvalidTransactionId is not mistaken for it;
    • history: paging by afterSeq, the limit, and a cursor that doesn't advance;
    • exports: streaming into the destination, a refusal raised with its code (nothing written), and a 503 retried before the first byte.

Checklist

  • I have read CONTRIBUTING.md.
  • Every commit in this PR includes a valid DCO Signed-off-by line.
  • I have the right to submit all material in this PR.
  • I have not included secrets or confidential data.
  • I have updated tests where appropriate.
  • I have updated documentation where appropriate.
  • I have preserved required third-party licenses and attributions.

Kaiten now makes usage reports idempotent with a client key and keeps
their history.

- openapi/ is synced from kaitencloud/kaiten@ef755b4 (feat/billing) and the
  models regenerated; UsageReport.behavior is named UsageBehavior.
- instances.report_usage(transaction_id=...): a keyed report is sent as
  idempotent, so it is retried like a read under the same key; a report
  without a key is still sent once. A malformed key raises ValueError
  before any request.
- The usage endpoint's 409 is named by its code: TransactionIdReusedError
  (a ConflictError, never retried) or ThresholdExceededError.
- instances.report_usage_detailed() returns UsageReportResult: usage,
  replayed (Idempotent-Replayed) and metadata_dropped
  (Kaiten-Metadata-Dropped).
- An API without transaction_id refuses the field: the report is resent
  without it, and the client stops sending keys for its lifetime, logging
  once.
- instances.list_usage_reports() walks the history by afterSeq, stops on a
  short page or at its limit, and raises PaginationError on a cursor that
  does not move.
- instances.export_usage_reports() and export_organization_usage_reports()
  stream CSV or NDJSON into a binary destination through the new
  APIClient.stream_to, which retries only before the first byte.
- unasync rewrites aread and aiter_* for the sync client.

Signed-off-by: Tom Ribuot <[email protected]>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant