v0.6.0: fix retry panic, WS subscribe/get_snapshot hangs, write race, CancelV2 empty-id → CancelAll; typed WS payloads, 22 endpoints, CI/release (BREAKING: DoConcurrent, CFBenchmarksAvgData, retry policy, WS slow-consumer/read-deadline) - #5
Merged
Conversation
…work Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…APIError retry.Do returned (nil, nil) when every attempt hit a retryable status, so client.do dereferenced a nil response. It now returns the last response with its body open so the caller parses it into *APIError; transport errors on the final attempt are returned as-is. MaxAttempts < 1 is treated as 1. Backoff sleeps are now a select on ctx.Done() vs a timer, so a cancelled or expired context returns promptly instead of blocking for up to MaxDelay. Retry-After accepts the HTTP-date form as well as integer seconds. Remove internal/errors: its APIError duplicated the public type and ParseJSONError was unused. IsRetryable moves to internal/retry and the 512-byte body snippet parse lives in oddrip.newAPIError. Replace the untyped 90e9/30e9 duration literals in New(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… key placeholders - DoConcurrent takes maxInFlight (<= 0 unbounded) and gates fn with a semaphore; waiting workers observe ctx so cancellation cannot leak them. Add tests for the bound, unbounded mode, index ordering, and cancel. - Delete unused oddrip/internal/transport and the unused BearerToken and duplicate StaticHeaders from oddrip/internal/auth. - Add signer tests: header set, RSA-PSS verification over timestamp+method+path (query excluded), and PEM parsing for PKCS#8, PKCS#1, garbage, and non-RSA keys. - go mod tidy: gorilla/websocket is a direct dependency. - Untrack the 8.5 MB example binary and the 0-byte key_id / private_key.pem placeholders; gitignore them and update the example README to say the credential files are created locally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… dead-connection detection - readLoop no longer deletes the pending reply channel after the first reply, so multi-channel Subscribe receives every per-channel 'subscribed' response; reply channel sized to expectCount - serialize all WriteMessage calls (subscribe/close frame) behind writeMu - replace silent message drops with a terminal ErrWSSlowConsumer that closes the connection; buffer configurable via WSBufferSize (default 4096) - add keepalive pings and read deadline (WSPingInterval 30s, WSReadTimeout 90s) with pong/ping handlers extending the deadline - add Err() and Done() so consumers can see why Messages() closed - Close() is idempotent, records ErrWSClosed, and sendAndWait returns promptly once the read loop has exited Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…helpers Add <Thing>Msg structs for every server message the AsyncAPI spec defines but the package lacked (ticker, orderbook_snapshot/delta, trade, fill, market_position, user_order, order_group_updates, multivariate lifecycle, event lifecycle/fee update, RFQ and quote events), WSType* constants for every server message type, WSMessage.Decode, and an OrderbookLevel pair type that strictly decodes the [price, count] arrays. Add Dollars (int64, 1e-6 scale) and Count (int64, 1e-2 scale) with Parse/String/Float64 helpers, Dollars.Cents (truncating), and ParseTime for the RFC3339 layouts the API emits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rtfolio/event endpoints Covers 22 spec paths: /series list/get, series market/event candlesticks and forecast percentile history, batch /markets/candlesticks, all /portfolio/order_groups and /portfolio/subaccounts operations, /portfolio/summary/total_resting_order_value, and the multivariate event collection list/get/create-market endpoints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI on PRs and main: gofmt, tidy drift, vet, staticcheck, govulncheck, and race tests on Go 1.24/stable across Linux, macOS, Windows. A version job requires oddrip/version.go, the CHANGELOG top entry, and the README pin to agree and fails code-changing PRs whose version is already tagged. On main, a release job gated on all checks tags v<Version>, publishes a GitHub Release from the matching CHANGELOG section, and warms the module proxy. Docs-only merges with an already-tagged version are a no-op. Also: gofmt cmd/example, drop unused ptr helper flagged by staticcheck, dependabot for gomod and actions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PowerShell split -coverprofile=coverage.out at the '=' and tried to test a package named .out; all Windows packages had actually passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CHANGELOG 0.6.0 gains a Breaking section: the DoConcurrent signature (the only API-incompatible change gorelease reports against v0.5.0) plus the two behavioral changes on the WebSocket path (slow-consumer failure, read deadline), each with a migration note. The header states the v0 policy. The version job now runs gorelease against the latest tag and fails if it finds incompatible API changes without a Breaking section, if a Breaking section ships on a patch bump, or if gorelease itself did not complete. The API diff is written to the job summary for reviewers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gorelease refuses to run on a dirty tree; the step's own section.md and gorelease.txt tripped it. The fail-closed guard caught this as intended. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ge_index to MarketLifecycleV2Msg CFBenchmarksAvgData shipped with tags (index_id, value_usd, source_ts_ms, window_sec) that the server never sends, so the typed 60s and quarter-hour averages always decoded empty. The fields are now value, window_size, window_start_ts_ms, window_end_ts_exclusive per cfbenchmarksAvgData, and the test uses the spec's example payload instead of a fabricated one. market_lifecycle_v2 created events carry exchange_index; the struct dropped it. Added as *int so shard 0 is distinguishable from absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ribe successes; surface reply decode errors get_snapshot is answered with orderbook_snapshot frames that carry no command id, so UpdateSubscription waited for an id-matched reply that never came and blocked until the context expired. The call now also completes on the first orderbook_snapshot for the target sid (waiters are per-sid and fan out to concurrent callers), while still honoring an id-matched ok/error. get_snapshot requires sid or a single-element sids, as the schema says. Subscribe used to drop the accepted channels when a later channel errored, leaving live sids the caller never learned about; the accepted responses are now returned alongside the *WSError. Subscribe, ListSubscriptions and UpdateSubscription no longer ignore a JSON decode failure on the reply msg (which returned sid 0 with a nil error). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
joinPath used path.Join, which drops empty segments: CancelV2(ctx, "", nil) sent DELETE /portfolio/events/orders, which is CancelAll, and Markets.Get with an empty ticker quietly called the list endpoint. Segments must now be non-empty and not "."/"..", each is path-escaped, and do() returns ErrEmptyPathParam before sending anything. Retries were applied uniformly to 429, 5xx and transport errors, so a DecreaseV2 whose connection dropped after the server applied it was replayed. retry.Do now takes an idempotent flag: GET/PUT/DELETE and POSTs the server deduplicates (CreateV2/BatchCreateV2 with client_order_id on every order, Subaccounts.Transfer, SetTargetBalanceAllocation) keep the full policy; other POSTs retry on 429 only. Retried response bodies are drained so the connection is reused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CHANGELOG: record the new Breaking/Fixed/Changed items, correct the path count to 64 of 96, set the release date. README: services list includes Series/OrderGroups/Subaccounts, define the ptr helper the examples use, document the idempotency-based retry policy, ErrEmptyPathParam and get_snapshot semantics. Example README no longer lists announcements. CI release job checks the tag and the GitHub release separately so a run that pushed the tag but failed to create the release is finished by the next push instead of skipped as already tagged. 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.
Summary
Audit of oddrip as a trading client turned up four reproducible bugs (two in the README's own examples), plus gaps that made the library thin for its actual use case. A second pre-release review against the vendored
openapi.yaml/asyncapi.yamlfound five more, including one that could cancel every open order. This branch fixes all of them, fills the gaps, adds CI + release automation, and bumps to v0.6.0.Every bug below was reproduced with a failing probe test before being fixed, and the shipped suite now covers each path.
v0.6.0 is a minor bump under the v0 policy (minor may break; always declared under
### Breakingin the CHANGELOG; CI enforces it — see below).gorelease -base=v0.5.0reports exactly two API-incompatible changes; three more are behavioral.DoConcurrent(ctx, n, fn)→DoConcurrent(ctx, n, maxInFlight, fn)— compile error. The old function claimed to be bounded and wasn't.DoConcurrent0reproduces the old unbounded behavior exactly.types.CFBenchmarksAvgDatafields renamed to the AsyncAPI names (Value,WindowSize,WindowStartTsMs,WindowEndTsExclusive) — compile error. The old fields (IndexID,ValueUSD,SourceTsMs,WindowSec) had tags the server never sends, so they were always zero.DecreaseV2whose connection dropped after the server applied it was replayed. Now: GET/PUT/DELETE and deduplicated POSTs (CreateV2/BatchCreateV2withclient_order_idon every order,Subaccounts.Transfer,SetTargetBalanceAllocation) keep the full policy;AmendV2,DecreaseV2, unkeyed creates,OrderGroups.Create,Subaccounts.Createretry on 429 only.Orders.Get, then resend). Setclient_order_idon creates to get full retries back.ErrWSSlowConsumer,Messages()closes.Messages()as "reconnect + re-snapshot"; checkconn.Err(). Consumers already doing that: no change.WSReadTimeout, default 90s). Was: half-open socket hung forever. Now:Messages()closes,Err()is a timeout. Keepalive pings (30s) keep idle-but-live connections up.Messages()to close on its ownWSReadTimeout(0)/WSPingInterval(0)restore the old behavior.Nothing else in the public API was renamed, removed, or retyped. Full
goreleasediff is attached to theversion consistencyjob summary.Fixed
internal/retry)retry.Doreturned(nil, nil)→client.donil-pointer panic. NoRetryConfigavoided it;MaxAttempts: 1panicked on the first 429.*APIError{StatusCode: 429, Code: "rate_limited", ...}time.Sleepignored the context — blocked 3.0s past a 100ms deadline; up toMaxDelay(30s) in productionctx.Err()at the deadline (101ms in the probe)Subscribe(ws.go)subscribedreply → hung until ctx deadline withsubs=0. The README'sticker+orderbook_deltaexample was this case.SubscribedResponseper channel, ~1msWriteMessageoutside any lock →DATA RACEin gorilla's frame writer; README claimed concurrency safetySubscribes clean under-raceclient.go)path.Joindrops empty segments:Orders.CancelV2(ctx, "", nil)sentDELETE /portfolio/events/orders— CancelAll.Markets.Get(ctx, "")silently hit the list endpoint. Pre-existing onmain../.., each path-escaped;ErrEmptyPathParamreturned before anything is sent.UpdateSubscription(get_snapshot)(ws.go)orderbook_snapshotframes that carry no commandid; client waited for an id-matched reply → hung until ctx deadlineorderbook_snapshotfor the sid (per-sid waiters, fan-out to concurrent callers) or an id-matchedok/error; snapshots still flow onMessages()CFBenchmarksAvgData(types/ws.go)cfbenchmarksAvgData; typed averages decoded empty; the test asserted a fabricated payloadMarketLifecycleV2Msg(types/ws.go)exchange_indexoncreatedevents silently droppedExchangeIndex *int(nil when absent, so shard 0 survives)Subscribepartial failure (ws.go)(nil, *WSError); sid 1 live on the server, caller never learns of itSubscribedResponses returned alongside the*WSErrorws.go)msgignored →sid: 0,err: nilChanged (behavioral — read before upgrading a consumer)
WSBufferSize, default 4096, was 256) fails the connection withErrWSSlowConsumerand closes it. A gap inorderbook_deltais unrecoverable without a re-snapshot, so failing loudly is the correct default. TreatMessages()closing as "reconnect and re-subscribe".WSPingInterval(30s), read deadlineWSReadTimeout(90s). A half-open socket now surfaces viaconn.Err()instead of blocking forever.DoConcurrenttakes amaxInFlightarg and actually bounds concurrency (README had claimed it did).Retry-Afterhonored in HTTP-date form;MaxAttempts < 1treated as 1;Close()idempotent; retried 429/5xx bodies drained so the connection is reused.Added
WSConn.Err()/Done(); optionsWSBufferSize,WSPingInterval,WSReadTimeout;ErrEmptyPathParam.TickerMsg,OrderbookSnapshotMsg,OrderbookDeltaMsg,TradeMsg,FillMsg,MarketPositionMsg,UserOrderMsg, …) generated fromasyncapi.yaml,WSType*constants,msg.Decode(&v). Previously only pyth/cfbenchmarks/lifecycle were typed — the channels a trader consumes were raw JSON.Dollars/Count/ParseTimehelpers — lossless int64 fixed-point at the spec's declared precision (1e-6 / 1e-2). No existing field types changed.Series(list/get/candlesticks/forecast history),OrderGroups(full lifecycle),Subaccounts,Markets.GetCandlesticks,Portfolio.GetTotalRestingOrderValue, multivariate collections. Coverage 44 → 64 of 96.internal/retryandinternal/auth(signature verified withrsa.VerifyPSS; query string proven excluded from the signed path). Both had zero coverage.Removed
examplebinary and emptycmd/example/{key_id,private_key.pem}placeholders untracked and gitignored.internal/transport,internal/errors(duplicate of the publicAPIError),BearerToken, duplicate internalStaticHeaders.CI and release automation (new)
.github/workflows/ci.ymlruns on PRs andmain:go mod tidydrift, vet, staticcheck, govulncheckgo test -race -shuffle=onon Go 1.24 + stable, Linux/macOS/Windowsoddrip/version.go, top CHANGELOG entry, and README pin must agree; a code-changing PR whose version is already tagged fails. Runsgoreleaseagainst the latest tag: API-incompatible changes without a### BreakingCHANGELOG section fail; a### Breakingsection on a patch bump fails; a gorelease that didn't complete fails (no silent pass). The API diff lands in the job summary.v<Version>, publishes a GitHub Release with the CHANGELOG section as notes, warms proxy.golang.org. Tag and release are checked separately, so a run that tagged but failed to publish is finished by the next push; no-op once both exist.So merging this PR tags and publishes
v0.6.0. Dependabot is configured for gomod and actions.Recommended after merge: branch protection on
mainrequiringlint,version consistency, and the fourtest (...)checks.Spec conformance check
Beyond the fixes above, every AsyncAPI message schema (26 types) and every OpenAPI schema with a same-named Go struct (130) was diffed field-by-field against the Go json tags and field types by script; no remaining mismatches on anything the client can reach.
Reviewer notes
gorelease.gofmt -wwas run overtypes/in its own commit; that's alignment-only whitespace on pre-existing files.🤖 Generated with Claude Code