Release 0.6.2: external-api hosts, bounded WS writes, DoConcurrent workers, long error bodies, license - #19
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #14, #15, #16, #17, #18. Bumps to 0.6.2 (0.6.1 is tagged). No API-incompatible changes —
gorelease -base=v0.6.1reports only two compatible additions (WSWriteTimeout,ErrWSWriteTimeout).Changes
#14 —
DoConcurrentstartedngoroutines even with a boundedmaxInFlightThe 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 thann. Results stay index-ordered; cancellation still returns the results collected so far plusctx.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 invokesfn(bounded mode already behaved that way).#15 — WebSocket writes could block past context deadlines and hang
ClosewriteMubecomes a 1-slotwriteSemchannel so a waiter can honor its context. Every data frame is written under the sooner of the caller's deadline and the newWSWriteTimeoutoption (default 10s;<= 0uses the default). A write that times out fails the connection with an error wrappingErrWSWriteTimeout—Messages()closes,Err()reports it, same path asErrWSSlowConsumer; the caller whose own deadline cut the write getscontext.DeadlineExceeded. A caller that gives up waiting for the slot getsctx.Err()and the connection stays healthy.Closesends the close frame as a boundedWriteControl, skips it if a command write is in flight, closes the socket (unblocking a stuck writer), and is documented to return within aboutWSWriteTimeout+ 5s even when the peer is not reading. Control frames (ping/pong/close) keep using gorilla'sWriteControl, which serializes itself and is safe alongside a data write.#16 —
APIErrorlostCode/Message/Detailswhen a valid error body exceeded 512 bytesStructured fields now decode from up to 64 KiB of the body;
RawBodyremains 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 vendoredopenapi.yaml/asyncapi.yamlare Kalshi's published specifications and are not covered by it.#18 — Default to Kalshi's recommended hosts
external-api.kalshi.com(REST) andexternal-api-ws.kalshi.com(WS) are now the defaults; the sharedapi.elections.kalshi.comremains supported viaBaseURL/WSHost. Signing is unaffected (the signed message excludes the host). The WebSocket example mapsexternal-api.kalshi.cominBASE_URLto the-wshost, 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< 100atn=10000, maxInFlight=4; limit aboven; cancellation while all workers are blocked (returns promptly, no new indices taken, goroutines settle);n == 0.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; boundedClosewith a stuck writer and with a pre-jammed socket.APIError: long valid bodies in all three shapes (fields populated,RawBodyis 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).wsConfigso it can be asserted without dialing).Verification
gofmt,go vet,staticcheck,go mod tidyno-op,go build,go test -race -count=1 -shuffle=on ./...(3× full; 10× on the new timing-sensitive tests), andgorelease -base=v0.6.1 -version=v0.6.2all clean locally.govulncheckreports only Go standard-library advisories present in the local go1.26.0 toolchain (fixed in 1.26.6); identical onmain.Review notes
TestWS_Close_BoundedWhenSocketFulloriginally 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.### Changedsince 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