Skip to content

Add the Kotlin SDK, generated from the Smithy model - #198

Merged
robzolkos merged 45 commits into
mainfrom
feature/kotlin-sdk
Sep 16, 2026
Merged

robzolkos merged 45 commits into
mainfrom
feature/kotlin-sdk

Conversation

@robzolkos

@robzolkos robzolkos commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a Kotlin SDK under kotlin/, generated from openapi.json and behavior-model.json like the Go, Rust and TypeScript ones, and laid out the way the company-wide basecamp-sdk Kotlin SDK is. Publishing is switched off: merging this and tagging a version still releases Go and Rust only.

  • Build: a Kotlin Multiplatform build with a JVM target (kotlin/), three modules: the hey-sdk library, the generator, and the conformance runner at conformance/runner/kotlin beside the others. JDK 17, pinned in .mise.toml. Consumers need Kotlin 2.3 or newer, which is the real floor set by the library's and Ktor's metadata; kt-consumer-check compiles a consumer against a staged publication at that version to hold it there.
  • Generator: kotlin/generator reads the model and kotlin/generator/names.toml (the twin of the Rust one) and writes generated/models (218 @Serializable data classes and typealiases), generated/services (27 services, 131 operations), Routes.kt (idempotency, empty-on statuses, pagination style and retry policy per route) and ServiceAccessors.kt. make kt-check-drift fails on a stale tree.
  • Runtime: HeyClient { accessToken(..) } with basecamp's builder properties, HeyConfig carrying VERSION/API_VERSION, per-route retry policies the client can only lower, an opt-in ETag cache partitioned by credentials, Page cursors with a same-origin check, forAccount scoping via filtered_account_id, SensitiveString redaction, HTTPS enforcement, bodies bounded while they stream, and hooks that hear each operation end the way the caller does.
  • Credentials: one refresh per set of credentials however many requests earned a 401; a failed refresh is shared with every request signed under those credentials; a token the provider renewed on its own is resent rather than refreshed again. Redirects keep credentials, account scope and cache validators on the resource they belong to, and an attachment's storage upload goes out unsigned and unscoped.
  • Conveniences: every hand-written service the Rust crate has, ported method for method with the same wire fields: 26 subclasses of the generated services plus WorldService.
  • Gate: make check runs kt-check, kt-check-drift and conformance-kt. CI adds a Kotlin Tests job that also runs kt-consumer-check. bump-version.sh and sync-api-version.sh move the Kotlin constants too.

Publishing is off

release-kotlin.yml publishes to GitHub Packages only when .github/kotlin-publish-enabled says true. It says false, exactly like the TypeScript switch, so release-languages.sh leaves kotlin out of the orchestrated release and a tag only rehearses the Kotlin build. Staging compares the artifacts byte-for-byte against anything already on GitHub Packages, and every archive carries the MIT notice with the licence named in the POM. Turning it on is a one-line reviewed change once GitHub Packages access is settled; CONTRIBUTING.md covers it.

Conformance fixtures

Kotlin models follow basecamp-sdk's strictness: a required member has no default, so a body missing one fails to decode rather than reading as a zero. That makes Kotlin the strictest reader of a mock body, and fixture bodies in nine files that Go and Rust accepted with required members missing (boxes without kind/name, recordings without type, object-typed responses mocked as []) now carry what the model requires. All four runners pass: Go 195/195, Rust 195/195, TypeScript 191/191 applicable, Kotlin 195/195.

Where it departs from basecamp-sdk, and why

  • There is no httpClient option, only engine: a plugin on a caller's client would run ahead of the retry policy, the credential handling on redirects, the error mapping and the timeout.
  • The operation the hooks hear runs until the answer is decoded, so an answer that will not read ends the operation with the caller's error rather than as a success.
  • Request bodies are the model's own serializable types rather than flattened Body classes, since HEY's request payloads are nested.
  • Paginated reads answer a Page walked with nextPage/eachPage/pages rather than an auto-aggregated ListResult, since HEY's paginated responses are objects with geared cursors.

All four are recorded in AGENTS.md.

Fixes that went back into Go and Rust

Reviewing this SDK turned up defects the Kotlin port had inherited, and each was fixed at the source: #199 and #200 (redirects kept apart from the cache and credentials), #201 and #203 (idempotent PATCH resends, redirect methods, Retry-After, form writes), #204 (hooks ending a feed read's operation as the caller sees it), #205 and #206 (one refresh per set of credentials, failures shared), #211 and #212 (provider-side token rotation, and the edges of the Go refresh coordination). All are merged, and this branch is rebased on them.

Not verified here

  • A full make check on one machine: the Rust half needs more memory than this machine had, so the Kotlin, TypeScript, Go and drift targets were run individually, and CI runs them all.

Summary by cubic

Adds Kotlin as the fourth generated HEY SDK; the repository previously shipped only Go, Rust, and TypeScript. It provides a typed com.basecamp:hey-sdk library, but publication remains disabled, so version tags validate and stage releases without publishing Maven artifacts.

SDK

  • Generates 218 models, 27 services, and 131 operations from the Smithy model; make kt-check-drift rejects stale output.
  • HeyClient supports route-aware retries, shared token refreshes, ETag caching, pagination, account scoping, hooks, redaction, HTTPS enforcement, and bounded streaming reads.
  • Failed refreshes are shared by requests using the same credentials, while provider-side token rotation counts as a renewal before another refresh.
  • Redirects protect credentials and account scope, avoid carrying cache validators or queries to another resource, and remove content headers when a hop becomes a bodyless GET; storage uploads remain unsigned and unscoped.
  • Change feeds bypass caching, keep resume cursors separate from page links, return full-sync responses for stale cursors, and preserve nextPage at the page limit.
  • Ports all of the Rust crate's hand-written conveniences, with hooks covering decoding and multi-request operations.

Validation and release

  • CI and make check run the Kotlin build, generator drift check, conformance runner, and Kotlin 2.3+ consumer checks; development requires JDK 17.
  • The Kotlin conformance runner passes the shared 195-case suite, and forty fixtures now include required members enforced by Kotlin models.
  • Release staging compares Maven artifacts byte-for-byte before and after publishing; version and API synchronizers now include Kotlin.
  • .github/kotlin-publish-enabled is false, so the release workflow runs its gate and dry run without publishing to GitHub Packages.
  • A full one-machine make check and the Kotlin release-action integration remain unverified.

Written for commit 122221f. Summary will update on new commits.

Review in cubic

@robzolkos
robzolkos force-pushed the feature/kotlin-sdk branch 2 times, most recently from dab24f3 to 065159e Compare September 15, 2026 18:32
robzolkos added a commit that referenced this pull request Sep 16, 2026
… credentials it could not renew (#205)

Client::refresh_credentials coalesced the 401s a stale credential earned on
the count of refreshes that had renewed it: a request signed before the last
refresh was resent on the new credentials rather than refreshing again. A
refresh that answered false did not move that count, so every other request
already signed under the same credentials — queued on the refresh turn behind
the first — found the count where it had left it and asked the provider again,
in turn. During an outage at the token's issuer that was one token-endpoint
call per stale request rather than one per set of credentials, and a provider
holding a rotating refresh token spent it several times over. The doc comment
said as much — "the next 401 asks again rather than trusting a failure" —
which is right for a request signed after the failure, whose 401 is news, and
wrong for one signed before it, whose credentials are the very ones the
refresh just failed to renew.

A request is now signed under a Generation: the count of refreshes that
renewed the credentials and the count of refreshes that ran to an answer at
all, both read under the signing lock so no refresh moves either between the
two. On a 401 the refresh task takes its turn and reads them again. If a
refresh renewed the credentials since the signing, the request is resent
without refreshing, as before; if one ran and did not, that failure is this
request's answer and the provider is not asked; a caller gone before its turn
still has no refresh started for it; otherwise the provider is asked, and the
counts move with its answer. A request signed after the failure carries the
new run count, so its 401 refreshes again. The Kotlin client made the same
change in #198. The public API is untouched.

Tests send two requests signed with the same stale credentials, the second
answered only once the first has failed, and see one provider call, no resend
and a 401 each, then a third request on the same client refreshed again; hold
the failing refresh open so a second 401 comes back while it runs, and see it
wait its turn and share the answer; and see four 401s arriving together cost
one provider call rather than four. The tokio dev-dependency gains test-util
so the in-flight case can hold the refresh on paused time.
@robzolkos
robzolkos marked this pull request as ready for review September 16, 2026 02:22
Copilot AI balanced review requested due to automatic review settings September 16, 2026 02:22
Copilot stopped reviewing on behalf of robzolkos due to an error September 16, 2026 02:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

robzolkos added a commit that referenced this pull request Sep 16, 2026
…d ask what it would sign with before spending a refresh (#211)

* Take a token the provider renews on its own for the renewal it is, and ask what it would sign with before spending a refresh

A TokenProvider that renews ahead of expiry hands access_token a new token
without being asked to refresh, which is what OAuth libraries do. The client
counted only the refreshes it asked for, so a 401 on the old token, arriving
after the provider had already rotated to the new one, read as a 401 on
current credentials: the refresh task called refresh again, over the top of a
token the provider had just been issued. With a rotating refresh token that
second use is a replay, and an issuer that treats a replay as theft revokes
the whole grant. Kotlin fixed this on #198 and Go on #206.

The SDK's own BearerAuth now marks the request it signs, and the client reads
the bearer it put on: one other than the last signed with is a renewal the
provider made of its own accord, and moves the counts as a refresh would, so a
401 on the old token is resent on the new one rather than refreshed. Only the
SDK's own strategy is read this way — a strategy of the caller's may sign every
request differently, and is asked to refresh as it always was. Before a
refresh task asks the provider to refresh, it asks what the provider would
sign with now, by signing a request that goes nowhere; a token other than the
one the 401 came back on is a renewal already made, and the request is resent
with it without the refresh being spent. Only a token the provider would still
sign with is refreshed. After either kind of renewal the comparison is reset,
so the first signing after a refresh is not counted a second time.

Signings are serialised: the token is taken from the provider, compared with
the last, and the counts read under one mutex, taken inside the read half of
the refresh lock and always in that order. Copilot found on the Go port that
concurrent signers let a token issued first be recorded second, so a request
that came out with the newer token read the counts of the older and a genuine
401 on the newer was taken for one already answered; and that a request signed
under a newer generation could join a refresh in flight for an older one and
take its answer. The mutex rules out the first. The second cannot happen here:
every 401 gets a task and a turn of its own, and each reads the counts against
the generation its own request was signed under only once it holds the turn,
so there is no refresh in flight to join. The public API is untouched.

Tests sign two requests t0 and t1, hold both at the server, answer the t0 one
401 and see it resent with t1 and refresh never called, then a 401 on t1
refreshed once; send one request on t0 with the provider rotating to t1 before
anything else is signed, and see the 401 resent with t1 without a refresh;
hand the first of two concurrent signers its t0 slowly and the second its t1
at once, and see t0 recorded first and the 401 on t1 refreshed exactly once;
and sign every request differently from a strategy of the caller's, and see a
401 on the first still ask it to refresh, once.

* Assert the ordering test's credentials as a set, since only the signings are serialised

The test that hands the first of two concurrent signers its t0 slowly and the
second its t1 at once asserted the order the three credentials reached the
server in. The mutex serialises the signings, not the sends after them: once
the t0 signer lets go, the t1 task can reach the transport first, and on the
multi-thread runtime whether it does is the scheduler's to decide. What the
test guards is that the genuine 401 on t1 is refreshed rather than resent,
which the refresh count and the t1 call's success already say, so the
credentials are now compared as a set.

* Know the SDK's own bearer strategy by how the client was built, and take a provider that cannot hand over a token for the refresh failing

The bearer a signing put on was read for a renewal whenever the request carried the mark
BearerAuth leaves. BearerAuth is public, so a strategy of the caller's that signs through
it and then rewrites the header — a per-request signature, say — carried the mark too:
every signing differed from the last and was counted as a renewal, and a genuine 401 on
one of its requests was resent once and surfaced without its refresh ever being asked
for. The client now knows it is the SDK's own strategy from being built with
token_provider, as Go and Kotlin know it by its type, and nothing on a request says so.

Before a refresh is spent the provider is asked what it would sign with. A provider that
could not hand over any token was taken as having nothing to say, and its refresh was
asked for straight after — so one whose own renewal inside access_token had just failed
was made to try again at once. That failure is the refresh's answer now, shared with
every request signed under the same credentials as any other failed refresh is, as
Kotlin takes it.

The ordering test says what it can and cannot promise: with signings serialised it passes
whatever the scheduler does, and without them it fails only when the second signer
reaches the provider inside the first one's delay, since whether a signer is waiting on
the lock is not something a test outside the client can see.
Re-applies the Kotlin wiring onto the Makefile, scripts, workflows and docs
as the TypeScript merge left them: Kotlin joins the release language list
through release-languages.sh, the bump and API-version scripts move
HeyConfig.kt alongside the TypeScript and Go constants, and the TypeScript
tests that stage those scripts in a temp tree stage the Kotlin files too.
The generated Recording model picks up main's discriminator documentation.
…ders, page retry policy, hook balance, release verification

A hop to another origin now goes out without every header the auth strategy
set, whatever it called them, not only the four the SDK knew by name; a
Cache-Control: no-store answer is neither held nor left in the cache; a 304
answers the cached body under the headers it was first read with, so a
revalidated page keeps its Link and X-Total-Count; a page after the first is
read under the first's retry policy rather than the client's own; and every
operation and request start the hooks hear gets exactly one end, whether a
token provider threw, a refresh threw or the coroutine was cancelled.

The release workflow no longer takes a 409 for a finished publish. The
publication is laid out in a staging repository first and every file of it
is compared with GitHub Packages, before and after publishing: absent means
publish, identical means nothing to do, anything else fails naming the files,
since a Maven version there can be neither finished nor overwritten. The
archives are built reproducibly so that comparison is byte for byte.

The Kotlin version targets join the staged, atomic version synchroniser, so a
malformed one fails before any Go or TypeScript file is rewritten.
`.github/kotlin-publish-enabled` says false. release-kotlin.yml reads it from
the tagged commit: a tag still runs the Kotlin gate and rehearses the
publication in the job without packages: write, and nothing goes to GitHub
Packages. release-languages.sh reads the same file, so release-github.yml
waits for Go and Rust only, and a target from before the switch existed has
no Kotlin release to wait for. make release refuses a malformed switch before
tagging. The GitHub Packages comparison script is now kotlin-packages-state.sh,
leaving kotlin-publish-state.sh for the switch, as typescript-publish-state.sh is.
…cope and drop the query on redirects, persist what a 304 moves

The transport now reads each response through Ktor's streaming form and
refuses a body the moment it passes its bound, cancelling the channel, rather
than after Ktor had held all of it; a producer of 64 MiB is stopped within a
few MiB. A redirect that stays on HEY keeps the client's account scope
whatever the Location said, and a Location or Link target no longer inherits
the query the request went out with, which the scope test turned up. A 304
writes the validator and headers it carried back to the cache entry, and a
no-store on a 304 ends the entry. A form write completes only with a 302 or
303; a 301, 307 or 308 is an error naming the status. PostingsService.moveTo
refuses an empty selection before it reads the box index. The generator fails
generation for a request body it cannot send — another representation, a
$ref request body, a schema that is not a $ref — instead of emitting a method
that sends nothing.
… raw queries through, bound every parsed document, walk pages without recursion

A caller-supplied httpClient built with Ktor's default followRedirects would
have followed redirects before the SDK could keep credentials off another
origin or the account scope on a hop; the builder now refuses one. The query
of a path handed to request() or form() goes out as written rather than
decoded and re-emitted, so a %26 stays one parameter. The body bound reads
the Accept list the way the Rust crate does, so a form's browser Accept and a
+json type are held to the configured cap rather than the fixed one. The HTML
reader and the stage parser walk the page with an explicit stack, since its
nesting is the server's to decide.
…only transport, root-only close, decode inside the operation, discriminator aliases

CalendarEventsService gains updateEvent and updateOccurrence over
UpdateCalendarEventParams, EventContent, Countdown and Repeat, ported from
the Rust crate, so a caller can send back everything HEY clears on a write;
the partial update says plainly what it clears. The builder takes an engine
only: a caller's configured HttpClient could carry a retry, a default
request, redirect following or response validation that runs ahead of what
this client is responsible for. Only the client the builder made closes the
transport; one derived with forAccount closes nothing. The operation the
hooks hear now runs until the answer is decoded or parsed, so a body that
will not read ends it with the error the caller gets. The generator reads
discriminatorValues, so a recording is recognised by either spelling of its
type. The README names the retry settings as the builder does, and a JVM test
checks every setting it names against the builder.
…inator aliases as the other generators do

The whole-event update decodes its recording inside the operation the hooks
hear and through Response.json, so an answer that is not a recording ends the
operation with the HeyException the caller gets; the redirect fallback no
longer invents a type. The generator refuses a discriminatorValues entry for
a variant the schema does not declare, as the TypeScript generator does, and
reads an empty list as the name alone, as the Rust one does, so neither can
become a check that is never true.
… bodiless, and let the retry ceiling saturate

The page within a change-feed increment is a whole cursor, as HEY issued
it: the link can move the since, the version and the size along with the
page, and the read that follows sends what HEY issued. A link to it off the
origin is refused. A 304, a status the operation takes for nothing there,
and the redirect a form takes for its answer carry nothing the SDK reads, so
their bodies are not read and cannot be refused for their size. A retry
ceiling as high as an Int holds no longer wraps into no retries at all.
…is refused, say what the partial calendar update clears, and refuse a timeout below a millisecond

The body buffer never grows past the bound plus the one byte that proves it
was passed, so a body at the bound is held once and one past it costs no
more than the bound. A failure whose body the client refused to hold is
still the error its status maps to — a 422 is a Validation, a 429 a
RateLimit with its Retry-After — told why its body is missing, with the
too-large flag on every error rather than the generic one alone. The partial
calendar update's own documentation now says which fields it clears and
points at the whole-event update, and a test holds it to that. A timeout
under a millisecond, which Ktor would refuse as zero, is refused up front as
a usage error.
…and document Validation as the 422 it is

Each credential header's name and each of its values go into the cache
partition with their length in front, so two values can never read as one
and one as two, and two identities whose headers only happen to spell the
same never share an entry. A Retry-After of zero, or a date already past,
means now, and is honoured as given rather than replaced by the backoff.
The Validation error's documentation names the 422 it is mapped from; a 400
is an Api error with that status, as it is in the Go and Rust SDKs.
…ead stray close tags in linear time, and recognise pasted URLs

A request on a closed client, or a refresh awaited across one, is refused as
a usage error rather than surfacing as a cancellation the caller never
asked for. A token with any control character, not only a line break, is
refused before the transport can quote it back, and so is any header a
strategy sets that the transport would refuse. The HTML reader keeps a count
of open tags, so a page of stray close tags reads in time linear in its
length. A Router recognises a pasted HEY URL or path — with or without its
.json suffix, query, fragment or trailing slash — as Rust's and Go's do,
naming the operation, the resource and the id. The test fixture records
requests under a lock, since two in flight run on the engine's own threads.
…to nowhere

A Location or Link target that starts at the query or the fragment keeps the
base's path in front of it, as RFC 3986 resolves one, where Ktor read it as
a path segment: a Link of ?page=2 is the next page, not a cursor. A
reference that names nothing resolves to nothing, so a redirect with an
empty Location is the answer rather than a re-read of the same path with
its query dropped.
…rom the Rust crate

CalendarEventsService gains create over CreateCalendarEventParams, encoding
the content, attendees, highlight, countdown, repeat, zones and reminders
through the same helpers the whole-event update uses. Publications publishes
and unpublishes a topic as a form post with a quiet read-back, so the hooks
hear one operation. Attachments uploads a file: the blob reserved through
the generated operation, then the bytes put to the storage URL unsigned, on
another origin, with HEY's own headers. Journal reads and writes a day's
entry, answering null for a day without one. Calendars lists with change
streams, toggles a calendar's selection and reads the calendar and
recording change feeds from a cursor, uncached, with a 409 answered as
fullSyncRequired. CalendarPeriods, CalendarTodos and Habits gain the
conveniences Go and Rust have. An operation can carry headers of its own and
go out unsigned, which the storage put needs.
…extenzion and bulk reply conveniences from the Rust crate

Workflows lists an account's workflows, reads a workflow's stages, creates,
renames and deletes workflows and stages, and stages, moves and unstages a
topic, with the read-back quiet so the hooks hear one operation. World
publishes, updates and deletes a post and exports and imports a list's
subscribers. TimeTracks creates, renames and deletes a category and exports
the tracks as CSV. Snippets, Clips, Collections, Extenzions and BulkReplies
gain the form-backed writes the generated surface has no route for. A raw
request can go out without its .json suffix, which the autocomplete list and
the CSV exports need.
…entity and topic conveniences from the Rust crate

Postings gains the rest of its sugar: a walk of a whole change increment,
moves to the Feed, the Paper Trail and the Trash, trash for everyone,
unmute, spam, box groups, folders and the bubble-up trio, every one over a
selection refused when empty and named to the hooks when it is one posting.
Clearances screens, rescreens and lists what is pending and screened.
Contacts creates and updates a contact, screens one and sets its note, with
HEY's 409 read into a ContactConflict. Stickies, Search, Designations,
Identity and Topics gain the conveniences Go and Rust have.
…ion's own headers the SDK's

An unsigned request goes to a URL that authenticates itself, so it goes out
as built even when the storage service shares HEY's origin: the account
scope is HEY's parameter, not storage's. The header an operation carries of
its own is the SDK's to set, for the storage put, rather than a caller's,
since a caller's header would need to partition the cache and the Rust
crate offers none. A 401 from storage refreshes nothing and a redirect on
storage's origin is not signed, each now held by a test.
…two requests once both are in

An extenzion's membership is replaced when the field is present at all, so
an explicitly empty list goes out as one blank value, which HEY reads as a
membership of none, while null sends nothing and leaves it alone. A
convenience made of two requests — staging a topic and selecting its stage,
publishing a topic and reading the link back — runs both quiet inside one
operation, so the hooks hear it end only once the second is in, with the
error the caller gets when either fails, rather than as a success after the
first.
…end, and end an upload once its bytes are stored

A 301 or 302 is only allowed to turn a POST into a GET; a PUT, PATCH or
DELETE keeps its method and body, since a GET in its place would report a
mutation done that never reached where it was sent. A 303 still says fetch
the answer whatever the method, and a 307 or 308 keeps everything. A
Retry-After is honoured on any status that earns a resend, a 503's outage
window as much as a 429's back-off. An attachment upload runs its
reservation and its storage put quiet inside one operation, so the hooks
hear it end only once the bytes are stored, with the storage service's
refusal when there is one.
… headers out of the cache, and read a negative Retry-After as none

A base URL with a query, a userinfo or a fragment is refused at
construction: the query would ride along on every request and the rest into
every URL the hooks and logs see. What the cache keeps beside a body leaves
out the headers the auth strategy signed the request with, should HEY echo
them, as it already leaves out the usual suspects. A negative Retry-After is
not a wait of nothing but no wait HEY named, so the backoff applies.
… import filename, and ship the MIT notice in every artifact

A successful answer the cache cannot hold — no validator, or nothing to
hold — has replaced what was held, so the old entry goes rather than being
sent back as a validator for a body HEY has moved on from. A subscriber
import filename goes into a header line of the multipart body as written,
so one with a line break is refused before anything is sent. Every archive
the library publishes carries the repository's MIT notice under META-INF,
the POM names the licence, and the consumer check holds both to that.
…refuse a blank token as a usage error, name an upload by its route, and time the hooks on a monotonic clock

A posting or calendar recording feed answers a stale cursor with a 409, which the
convenience hands back as a full-sync answer rather than throwing. The hooks were told
otherwise: the request went through the operation lifecycle before the Conflict was
caught, so a trace or a failure count saw an operation fail whose caller got an answer.
Both feeds now send their request quiet inside an operation of their own, which ends
with what the caller gets, as the two-request conveniences already did.

A blank static token — what an unset environment variable hands over — threw a bare
IllegalArgumentException from the builder, past the sealed hierarchy the README says every
failure belongs to. It is a HeyException.Usage now, from the provider and the builder
alike, and says nothing of what it was handed.

The attachment upload's operation named its resource `direct_upload`; the route, and Go's
and Rust's hooks, say `attachment`. The operation is now the reservation's own, so the
resource type and everything else about it comes from the model.

Operation and request durations subtracted two wall-clock readings, so a clock stepped
while a request was out landed in them, negative or long. They are measured on the
client's monotonic time source now, which a test can hand over.
…refusal inside its operation, and make a hand-built config a usage error

The calendar walks failed at the client's page limit while the postings walk answered
what it had read and named the page it did not, and the README promised the failure for
all three. The calendar walks now stop as the postings walk does, so a capped answer
carries `nextPage` and cannot pass for the end of the feed, which is how Go and Rust read
all three feeds too.

A time track started while one was running, and a contact write HEY refused, were
reworded after the operation had ended, so the hooks heard one error and the caller got
another. Both reword inside the operation now, and the calendar changes read runs inside
its operation as the recording feed's does.

HeyConfig's own checks threw a bare IllegalArgumentException from its constructor and
copy(), which the builder wrapped but a caller constructing one did not meet. They are
usage errors from the class itself now.
…g inside the operation

A storage URL authenticates itself, which is why an upload goes out unsigned and without
the account scope even when storage shares HEY's origin. A 307 from storage was scoped
all the same: the hop gained filtered_account_id, an edit to a URL the storage service
had signed. An unsigned request's hop now goes exactly where it was sent.

Trashing a shared topic and publishing a HEY World post each read where HEY sent the
caller after the operation had ended, so the hooks heard a success for a call that
threw. Both read the redirect inside the operation now, as the other form-backed
conveniences do, and the hooks hear the refusal the caller gets.
Setting the first week day or the time format read what HEY stored after the operation
had ended, so a stored value the SDK could not read failed the caller while the hooks
heard a success. Both reads happen inside the operation now, as every other answer that
may not read does. A time format HEY stored that is neither of the two is HEY's answer
failing to read, not a mistake of the caller's, so it is an API error rather than a usage
error; parsing a format by hand still refuses one as usage.

The redirect prose said a hop on HEY always keeps the account scope, and the unsigned
request's said such a URL is never on HEY's origin; neither was true once storage may
live there. The README now names the upload as the one request the scope stays off.
A 303 turns a PUT into a bodyless GET, and the hop dropped the body's type and length
but kept the rest of what described it: an upload's Content-MD5 went out on a GET with
nothing for storage to check it against, which is a request a destination may refuse.
The hop now drops every content header, as RFC 9110 has a client do when a redirect
changes the method, checksums included. A 307 or 308 keeps the body and keeps them all.
…s it was for, and drop every Content header when a hop drops the body

A refresh held the signing lock for its whole run, and a stale request found out whether
one was in flight by taking that same lock. So a second 401 on the same credentials could
not see the refresh already running for them: it waited for the lock instead, and when
the refresh had failed — not renewed, or thrown — it started another. An issuer outage
cost one refresh per stale request, in a row. A successful refresh hid this by moving the
count the second request checked first.

The refresh in flight is now guarded by a gate held only for a moment, and the counts a
request is signed under say two things: how many refreshes had renewed the credentials,
and how many had run at all. A 401 on credentials someone else has since renewed is
resent; one on credentials a refresh is renewing joins it; one on credentials a refresh
has already failed to renew gets that refresh's answer; and only a request signed after
the failure earns a refresh of its own. The strategy is called once per set of
credentials, whichever way the refresh ends.

A hop that drops the body dropped a named list of the headers that described it, and HEY
names the upload's Content-Disposition too. Every Content header goes now, with the few
that describe the body under another name.
A cancellation thrown out of a strategy's refresh was read as the client closing, so a
timeout the strategy put on its own call was neither recorded nor shared: every other
request signed under the same credentials started a refresh of its own, and each got a
cancellation it never asked for. Only the refresh coroutine itself being cancelled is the
client closing now. Anything else the strategy throws is the refresh's answer, shared like
a refusal, and reaches each request as an authentication error carrying it as the cause,
so every failure a caller sees is an SDK one, as the README promises.

The tests for a shared failure are deterministic now: the second request is answered only
once the first has failed, or only once the refresh it is to join is running, and a third
request on the same client shows the failure is not held against credentials signed after
it.
…r refresh serves a request signed before a failed one

The tests for a shared refresh answered the first request's 401 at once. The mock's
handler may run inside the request's own coroutine, so on a slow runner that answer let
the first request refresh before the second was signed, and the second, signed after
the failure, refreshed again as it should. Neither of the first two answers goes back now
until both requests have arrived, in the new tests and in the older one for signing and
refreshing never interleaving, which was open to the same order. Eight runs pinned to two
CPUs pass where two in six failed before.

A failed refresh leaves the credentials as they were, so a later refresh of them is a
refresh of every request's still signed under them: a 401 arriving while it runs joins it,
and is resent if it renews them, rather than being handed the earlier failure. That was
already so; a test pins it and the doc says it.

CI runs Gradle quietly, which left a failing test unnamed in its log. A failure is now
logged at that level, with what it failed on.
The test for a late request being resent after a later refresh told the two requests
apart by the order they reached the mock, and answered the second only once a third
request had renewed the credentials. The mock's handlers run on whichever thread the
engine gives them, so the second request could arrive first; the test then waited on the
first request, whose answer waited on a step that came after that wait. The second
request is not sent now until the first has arrived, so the first arrival is the first
request whatever thread it came in on.
A provider that renews ahead of expiry hands accessToken a new token without being asked
to refresh, which is what OAuth libraries do. The client counted only the refreshes it
asked for, so a 401 on the old token, arriving after the new one had signed a request,
was read as a 401 on current credentials: it refreshed again, over the top of a token
the provider had just been issued, which with a rotating refresh token is the one thing
an issuer treats as theft. The SDK's own bearer strategy now notices a token that
differs from the one it last put on a request and counts it as a renewal, so the 401 is
answered by resending, and a 401 on the new token itself is refreshed once, as ever. A
strategy of the caller's is not read this way, since it may sign every request
differently.
A provider that renews ahead of expiry may have done so with no request signed since,
so a 401 on the old token found the counts unmoved and refreshed the new one over the
top. Before the SDK's own bearer strategy is asked to refresh, its provider is now asked
what it would sign with: a token other than the rejected one is a renewal already made,
and the request is resent with it rather than the new token's refresh token being spent.
A token the provider would still sign with is refreshed once, as ever.
@robzolkos
robzolkos merged commit 0f19a87 into main Sep 16, 2026
32 checks passed
@robzolkos
robzolkos deleted the feature/kotlin-sdk branch September 16, 2026 17:49
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.

2 participants