Skip to content

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
UTXOnly merged 22 commits into
mainfrom
audit-fixes
Sep 12, 2026

Conversation

@UTXOnly

@UTXOnly UTXOnly commented Sep 11, 2026

Copy link
Copy Markdown
Owner

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.yaml found 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.

⚠️ Breaking changes

v0.6.0 is a minor bump under the v0 policy (minor may break; always declared under ### Breaking in the CHANGELOG; CI enforces it — see below). gorelease -base=v0.5.0 reports exactly two API-incompatible changes; three more are behavioral.

Change Who is affected Migration
DoConcurrent(ctx, n, fn)DoConcurrent(ctx, n, maxInFlight, fn) — compile error. The old function claimed to be bounded and wasn't. Any caller of DoConcurrent Insert a limit; 0 reproduces the old unbounded behavior exactly.
types.CFBenchmarksAvgData fields 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. Anyone reading the typed 60s / 15-min averages Switch to the new names; you were reading zero values before.
Non-idempotent writes are retried on 429 only. Was: every request retried on 429, 5xx, and transport errors, so a DecreaseV2 whose connection dropped after the server applied it was replayed. Now: GET/PUT/DELETE and deduplicated POSTs (CreateV2/BatchCreateV2 with client_order_id on every order, Subaccounts.Transfer, SetTargetBalanceAllocation) keep the full policy; AmendV2, DecreaseV2, unkeyed creates, OrderGroups.Create, Subaccounts.Create retry on 429 only. Callers that relied on 5xx/transport retries for those writes Handle the error (reconcile with Orders.Get, then resend). Set client_order_id on creates to get full retries back.
WS slow consumer closes the connection. Was: silent drop past 256 buffered messages. Now: 4096 buffer, then ErrWSSlowConsumer, Messages() closes. Consumers that tolerated silent gaps Treat a closed Messages() as "reconnect + re-snapshot"; check conn.Err(). Consumers already doing that: no change.
WS read deadline (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. Consumers that never expected Messages() to close on its own Same as above. WSReadTimeout(0) / WSPingInterval(0) restore the old behavior.

Nothing else in the public API was renamed, removed, or retyped. Full gorelease diff is attached to the version consistency job summary.

Fixed

Bug Before After
Retry exhaustion on 429/5xx (internal/retry) retry.Do returned (nil, nil)client.do nil-pointer panic. No RetryConfig avoided it; MaxAttempts: 1 panicked on the first 429. Last response surfaced as *APIError{StatusCode: 429, Code: "rate_limited", ...}
Retry backoff vs. cancelled ctx time.Sleep ignored the context — blocked 3.0s past a 100ms deadline; up to MaxDelay (30s) in production Returns ctx.Err() at the deadline (101ms in the probe)
Multi-channel Subscribe (ws.go) Read loop dropped the pending slot after the first subscribed reply → hung until ctx deadline with subs=0. The README's ticker+orderbook_delta example was this case. One SubscribedResponse per channel, ~1ms
Concurrent WS commands WriteMessage outside any lock → DATA RACE in gorilla's frame writer; README claimed concurrency safety Serialized writes; 20 concurrent Subscribes clean under -race
Empty path parameter routed to a different endpoint (client.go) path.Join drops empty segments: Orders.CancelV2(ctx, "", nil) sent DELETE /portfolio/events/ordersCancelAll. Markets.Get(ctx, "") silently hit the list endpoint. Pre-existing on main. Segments must be non-empty and not ./.., each path-escaped; ErrEmptyPathParam returned before anything is sent.
UpdateSubscription(get_snapshot) (ws.go) Spec answers with orderbook_snapshot frames that carry no command id; client waited for an id-matched reply → hung until ctx deadline Completes on the first orderbook_snapshot for the sid (per-sid waiters, fan-out to concurrent callers) or an id-matched ok/error; snapshots still flow on Messages()
CFBenchmarksAvgData (types/ws.go) Tags didn't match cfbenchmarksAvgData; typed averages decoded empty; the test asserted a fabricated payload Fields match the schema; test uses the spec's example payload
MarketLifecycleV2Msg (types/ws.go) exchange_index on created events silently dropped ExchangeIndex *int (nil when absent, so shard 0 survives)
Subscribe partial failure (ws.go) Channel 2 errors after channel 1 is accepted → (nil, *WSError); sid 1 live on the server, caller never learns of it Accepted SubscribedResponses returned alongside the *WSError
Malformed command replies (ws.go) JSON decode error on reply msg ignored → sid: 0, err: nil Decode error returned

Changed (behavioral — read before upgrading a consumer)

  • WS messages are never dropped silently. Overflow (buffer now WSBufferSize, default 4096, was 256) fails the connection with ErrWSSlowConsumer and closes it. A gap in orderbook_delta is unrecoverable without a re-snapshot, so failing loudly is the correct default. Treat Messages() closing as "reconnect and re-subscribe".
  • WS keepalive / dead-connection detection. Pings every WSPingInterval (30s), read deadline WSReadTimeout (90s). A half-open socket now surfaces via conn.Err() instead of blocking forever.
  • DoConcurrent takes a maxInFlight arg and actually bounds concurrency (README had claimed it did).
  • Retry-After honored in HTTP-date form; MaxAttempts < 1 treated as 1; Close() idempotent; retried 429/5xx bodies drained so the connection is reused.

Added

  • WSConn.Err() / Done(); options WSBufferSize, WSPingInterval, WSReadTimeout; ErrEmptyPathParam.
  • Typed WS payloads for every server message (TickerMsg, OrderbookSnapshotMsg, OrderbookDeltaMsg, TradeMsg, FillMsg, MarketPositionMsg, UserOrderMsg, …) generated from asyncapi.yaml, WSType* constants, msg.Decode(&v). Previously only pyth/cfbenchmarks/lifecycle were typed — the channels a trader consumes were raw JSON.
  • Dollars / Count / ParseTime helpers — lossless int64 fixed-point at the spec's declared precision (1e-6 / 1e-2). No existing field types changed.
  • 22 new REST paths: Series (list/get/candlesticks/forecast history), OrderGroups (full lifecycle), Subaccounts, Markets.GetCandlesticks, Portfolio.GetTotalRestingOrderValue, multivariate collections. Coverage 44 → 64 of 96.
  • Tests for internal/retry and internal/auth (signature verified with rsa.VerifyPSS; query string proven excluded from the signed path). Both had zero coverage.

Removed

  • 8.5 MB compiled example binary and empty cmd/example/{key_id,private_key.pem} placeholders untracked and gitignored.
  • Dead internal/transport, internal/errors (duplicate of the public APIError), BearerToken, duplicate internal StaticHeaders.

CI and release automation (new)

.github/workflows/ci.yml runs on PRs and main:

  • lint — gofmt, go mod tidy drift, vet, staticcheck, govulncheck
  • testgo test -race -shuffle=on on Go 1.24 + stable, Linux/macOS/Windows
  • version consistencyoddrip/version.go, top CHANGELOG entry, and README pin must agree; a code-changing PR whose version is already tagged fails. Runs gorelease against the latest tag: API-incompatible changes without a ### Breaking CHANGELOG section fail; a ### Breaking section on a patch bump fails; a gorelease that didn't complete fails (no silent pass). The API diff lands in the job summary.
  • release (main only, needs all of the above) — tags 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 main requiring lint, version consistency, and the four test (...) 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

  • Commits are grouped by concern (retry, WS, types, endpoints, hygiene) and were developed in isolated worktrees, then merged; the merge commits are noise-free.
  • See Breaking changes above; everything else is additive per gorelease.
  • gofmt -w was run over types/ in its own commit; that's alignment-only whitespace on pre-existing files.

🤖 Generated with Claude Code

UTXOnly and others added 17 commits September 11, 2026 18:01
…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>
@UTXOnly UTXOnly changed the title Fix retry panic, WS subscribe hang and write race; add typed WS payloads, helpers, 22 endpoints (0.6.0) v0.6.0: fix retry panic, WS subscribe hang and write race; typed WS payloads, 22 endpoints, CI/release (BREAKING: DoConcurrent, WS slow-consumer/read-deadline) Sep 11, 2026
UTXOnly and others added 5 commits September 11, 2026 19:05
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>
@UTXOnly UTXOnly changed the title v0.6.0: fix retry panic, WS subscribe hang and write race; typed WS payloads, 22 endpoints, CI/release (BREAKING: DoConcurrent, WS slow-consumer/read-deadline) 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) Sep 12, 2026
@UTXOnly
UTXOnly merged commit 153fdc5 into main Sep 12, 2026
7 checks passed
@UTXOnly
UTXOnly deleted the audit-fixes branch September 12, 2026 15:04
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