Skip to content

Release 0.6.2: external-api hosts, bounded WS writes, DoConcurrent workers, long error bodies, license - #19

Merged
UTXOnly merged 8 commits into
mainfrom
fix/issues-14-18
Sep 12, 2026
Merged

Release 0.6.2: external-api hosts, bounded WS writes, DoConcurrent workers, long error bodies, license#19
UTXOnly merged 8 commits into
mainfrom
fix/issues-14-18

Conversation

@UTXOnly

@UTXOnly UTXOnly commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Fixes #14, #15, #16, #17, #18. Bumps to 0.6.2 (0.6.1 is tagged). No API-incompatible changes — gorelease -base=v0.6.1 reports only two compatible additions (WSWriteTimeout, ErrWSWriteTimeout).

Changes

#14DoConcurrent started n goroutines even with a bounded maxInFlight
The semaphore is replaced by a worker pool of min(n, maxInFlight) goroutines fed by an atomic counter; the result buffer is sized to the workers rather than n. Results stay index-ordered; cancellation still returns the results collected so far plus ctx.Err(), after which workers take no further indices and never block on the result channel. Unbounded mode (maxInFlight <= 0) is unchanged except that a context already cancelled on entry no longer invokes fn (bounded mode already behaved that way).

#15 — WebSocket writes could block past context deadlines and hang Close
writeMu becomes a 1-slot writeSem channel so a waiter can honor its context. Every data frame is written under the sooner of the caller's deadline and the new WSWriteTimeout option (default 10s; <= 0 uses the default). A write that times out fails the connection with an error wrapping ErrWSWriteTimeoutMessages() closes, Err() reports it, same path as ErrWSSlowConsumer; the caller whose own deadline cut the write gets context.DeadlineExceeded. A caller that gives up waiting for the slot gets ctx.Err() and the connection stays healthy. Close sends the close frame as a bounded WriteControl, skips it if a command write is in flight, closes the socket (unblocking a stuck writer), and is documented to return within about WSWriteTimeout + 5s even when the peer is not reading. Control frames (ping/pong/close) keep using gorilla's WriteControl, which serializes itself and is safe alongside a data write.

#16APIError lost Code/Message/Details when a valid error body exceeded 512 bytes
Structured fields now decode from up to 64 KiB of the body; RawBody remains the first 512 bytes; bodies over 64 KiB are read no further and are not decoded. The three production body shapes from #13 are parsed exactly as before.

#17 — No license
MIT LICENSE. README license section notes that the vendored openapi.yaml / asyncapi.yaml are Kalshi's published specifications and are not covered by it.

#18 — Default to Kalshi's recommended hosts
external-api.kalshi.com (REST) and external-api-ws.kalshi.com (WS) are now the defaults; the shared api.elections.kalshi.com remains supported via BaseURL / WSHost. Signing is unaffected (the signed message excludes the host). The WebSocket example maps external-api.kalshi.com in BASE_URL to the -ws host, since the recommended hosts differ per protocol. README, example READMEs, AGENTS.md, and CHANGELOG describe the change and overrides.

Tests

20 new tests, all under -race:

  • DoConcurrent: goroutine growth < 100 at n=10000, maxInFlight=4; limit above n; cancellation while all workers are blocked (returns promptly, no new indices taken, goroutines settle); n == 0.
  • WebSocket: option storage; a caller whose ctx ends while waiting for the write slot returns ctx.Err() with the connection untouched; 20 concurrent callers half pre-cancelled; write timeout as a terminal error (16 MB payload to a non-reading peer); a caller deadline cutting a write; a blocked writer not holding other callers; bounded Close with a stuck writer and with a pre-jammed socket.
  • APIError: long valid bodies in all three shapes (fields populated, RawBody is a 512-byte prefix); malformed/truncated long JSON; a counting reader asserting the read is bounded at 64 KiB (exactly-at-limit decodes, one byte over does not).
  • Hosts: default REST request URL; the signed path is identical across the external-api, shared, and demo base URLs; default WS dial URL (option resolution factored into wsConfig so it can be asserted without dialing).

Verification

gofmt, go vet, staticcheck, go mod tidy no-op, go build, go test -race -count=1 -shuffle=on ./... (3× full; 10× on the new timing-sensitive tests), and gorelease -base=v0.6.1 -version=v0.6.2 all clean locally. govulncheck reports only Go standard-library advisories present in the local go1.26.0 toolchain (fixed in 1.26.6); identical on main.

Review notes

  • One assertion in the new TestWS_Close_BoundedWhenSocketFull originally required the close frame's control write to time out; whether a 6-byte frame fits into a jammed send buffer depends on kernel drain timing, so it now asserts the bound strictly and accepts nil-or-timeout for the frame write.
  • The host change is filed as a patch under ### Changed since both hosts work and no API changed. If a default-endpoint swap should count as breaking (egress allowlists), this should become ### Breaking + 0.7.0.

🤖 Generated with Claude Code

UTXOnly and others added 8 commits September 12, 2026 13:54
The repository had no LICENSE file, so nothing granted users permission
to use, modify, or redistribute the client. MIT, per the repository
owner. The README notes that the vendored openapi.yaml / asyncapi.yaml
are Kalshi's published specifications and are not covered by it.

Closes #17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…512-byte snippet (#16)

newAPIError read only the 512-byte RawBody snippet and decoded Code,
Message, Details, and Service from that same buffer, so any valid error
body longer than 512 bytes failed to parse and left the fields empty.
Read up to maxErrorBody (64 KiB) for decoding; RawBody stays the first
512 bytes. Bodies over 64 KiB are still bounded and are not decoded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A positive maxInFlight bounded the active fn calls but still started one
goroutine per index, all parked on the semaphore. Replace the semaphore
with a worker pool: min(n, maxInFlight) goroutines claim indices from an
atomic counter, so a large n with a small limit no longer creates n
goroutines. Results stay index-ordered and a cancelled ctx still returns
the results collected so far plus ctx.Err(). Workers check ctx before
each call and select on ctx.Done when sending a result, so they neither
take new work nor block on the result channel after the caller returns.
maxInFlight <= 0 keeps its unbounded behavior (n workers).

Tests cover the goroutine cap at n=10000, the cap at n when the limit
exceeds it, cancellation while every worker is blocked inside fn, and
n=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sendAndWait acquired writeMu and called WriteMessage with no deadline, so a
wedged or backpressured socket could block one command forever, queue every
later command behind the mutex, ignore caller deadlines, and keep Close from
ever reaching its bounded wait.

Data writes now go through a 1-slot channel semaphore (writeSem) so waiting
for the slot honors ctx and connection death, and each frame is written with
a deadline of min(ctx deadline, now + WSWriteTimeout). A timed-out write
leaves gorilla's writer unusable, so it fails the connection with an error
wrapping the new ErrWSWriteTimeout (Messages() closes, Err() reports it); the
caller whose own deadline caused it gets ctx.Err(). A caller that gives up
waiting for the slot gets ctx.Err() and leaves the connection healthy.

Close writes the close frame with WriteControl and the write timeout as its
deadline, skips it when a command write is in flight, then closes the socket
(which unblocks that write) and waits for the read loop with the existing
five-second backstop. It is documented to return within about WSWriteTimeout
plus five seconds even when the peer is not reading.

New option WSWriteTimeout (default 10s; <= 0 uses the default). Tests cover
the option, giving up on the slot, cancelled and live callers contending,
write timeout as a terminal error, a caller deadline cutting a write, a
blocked writer not holding other callers, and Close bounded with a stuck
writer and with a full socket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TestWS_Close_BoundedWhenSocketFull asserted that the close frame's
control write times out, but a few bytes can fit if the kernel drains
the jammed send buffer between the test's raw write timing out and
Close running. The bound is what the test guards; accept nil or a
timeout for the frame write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kalshi recommends external-api.kalshi.com for REST and
external-api-ws.kalshi.com for WebSocket; both specs list them as the
primary production servers. The client defaulted to the older shared
api.elections.kalshi.com host for both. Switch the defaults; the shared
host stays reachable through BaseURL / WSHost. Signing is unaffected:
the signed message is timestamp + METHOD + path and excludes the host.

Tests assert the default REST request URL, that the signed path is the
same across the external-api, shared, and demo base URLs, and the
default WebSocket dial URL (option resolution is factored into wsConfig
so it can be checked without dialing). The WebSocket example maps
external-api.kalshi.com in BASE_URL to the -ws host, since the
recommended hosts differ per protocol.

Closes #18

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bump the version, add the CHANGELOG section for #14, #15, #16, #17,
and #18, pin the README, and document the new behavior: DoConcurrent's
worker bound, the WebSocket write timeout and ErrWSWriteTimeout,
Close's bound, the 64 KiB error-body decode limit, and the host
defaults in AGENTS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…yload

The blocked-write tests carried a 16MB string inside the command so the
frame could not fit in loopback buffers. Two problems on CI:

- json.Marshal of 16MB runs before write() and, under -race on a
  two-core runner, exceeds TestWS_WriteTimeout_CallerDeadline's 200ms
  context deadline, so write() returned ctx.Err() without touching the
  socket and the connection never failed ("Done() did not close" on
  every platform).
- A fixed 16MB raw write does not block on Windows, whose receive-window
  autotuning reaches 16MB on loopback.

wsJamSocket now writes raw bytes underneath gorilla until a short-deadline
write times out, whatever the platform's buffers hold, and the command
payload drops to 1MB: large enough that it cannot slip into the few bytes
the kernel may free afterwards, and ~10ms to marshal under -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@UTXOnly
UTXOnly merged commit 8954e68 into main Sep 12, 2026
7 checks passed
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.

DoConcurrent starts n goroutines even when maxInFlight is bounded

1 participant