Skip to content

fix: align client with API docs and rewrite documentation - #1

Merged
martende merged 9 commits into
mainfrom
fix/user-agent-validation
Oct 8, 2026
Merged

martende merged 9 commits into
mainfrom
fix/user-agent-validation

Conversation

@martende

@martende martende commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Aligns the client with the Glassnode API documentation and rewrites the project documentation.

API alignment

  • Bulk entries keep all selectors. Bulk entries carry every selector of their parameter combination (e, network, category, ...), but BulkEntry kept only a and network, so entries for different exchanges or object-metric categories were indistinguishable. BulkEntry.Params now holds all of them (Asset()/Network() read it), and the entry encodes back to the wire format.
  • Bulk requires Since. The API requires s for bulk requests; GetBulkMetric now rejects a request without it before spending a call. The godoc and README describe the per-request time-range limits and credit cost.
  • 429 waits for x-rate-limit-reset. The API reports its per-minute rate limit through x-rate-limit-reset, not Retry-After. Retrying after 0-1s/0-2s only hit the limit again. A 429 now uses that header as the minimum delay (or returns immediately if it exceeds MaxDelay), and the wait is exposed as APIError.RetryAfter.
  • parameters_defaults accepts strings. The docs describe the values as strings, recorded responses use lists; both are accepted now instead of failing the whole metadata call.
  • New ListExchanges, ListNetworks, ListMiners for the documented metadata endpoints.

Findings of a shape audit over all 1818 metrics (run against the live API)

  • ComputedAt on time-series points. Every point-in-time metric (843 of 1818) adds computed_at to each point; TimeSeriesPoint and ObjectTimeSeriesPoint dropped it. Two real PIT responses are added as contract fixtures.
  • Default retry cap 30s → 65s. The rate limit works in one-minute windows and x-rate-limit-reset goes up to 60; with a 30s cap the default client returned a 429 from the first half of a window without retrying at all.
  • WithMaxResponseBytes. No size limit by default (as in go-github, stripe-go, slack-go); callers can opt into a hard bound, which fails with *ResponseTooLargeError (unwrapping to ErrResponseTooLarge) and is not retried. The body is read inside the retry loop and decoded once, after it, so a failed attempt never leaves values in the caller's destination.
  • GetAPIUsage accepts accounts without add-ons (apiAddons null or absent).
  • Error bodies are drained so connections are reused on retry.

CSV downloads

  • GetMetricCSV(ctx, path, params, io.Writer) requests a metric with f=csv and copies the response to the writer as it arrives (the API serves regular metrics as CSV: timestamp,value, object keys, computed_at for PIT; bulk is not available in CSV and is rejected before a request). Failures before the body are retried as usual; a failure during the copy is never retried, since part of the output is already written.

OAuth refresh

  • TokenRefresher: a TokenSource that also implements Refresh is asked once for a new token after a 401, and the request is repeated with it outside the retry budget. Concurrent calls rejected with the same token share one refresh (mutex, as in golang.org/x/oauth2). An empty refreshed token is an AuthError; an unchanged one yields the 401. Lets glassnode-cli drop its refresh-on-401 transport.

Review round 2 follow-ups

  • GetMetricCSV: shared copyLimited so the writer never receives more than WithMaxResponseBytes; a read failure before any output is retried, a writer failure is returned as the caller's error wrapped (never a TransportError); godoc and README note that the per-attempt timeout covers the body read.
  • HTML error pages (gateway 401/5xx) are summarised by title with http.StatusText fallback and entity decoding instead of quoting markup; f=CSV is matched case-insensitively.

Next step (not in this PR): a per-point streaming decode API for full-history responses; json.Decoder buffers a whole value, so buffered Get and stream-decode have the same peak memory.

Other code fixes

  • WithUserAgent rejects any control character. Previously only CR/LF were rejected, so e.g. "app\x00" built a client whose every request failed in net/http and was retried as a TransportError.
  • Remove the unused (*Client).redact helper.

Documentation

  • README rewritten around common tasks, with a correct GOPRIVATE command, an accurate backoff description, a defaults table, a link to API key setup, a real CI badge and gofmt-formatted snippets. OAuth is described as "bring an existing token", since the client has no login flow.
  • docs/design.md turned from an implementation spec into design notes; the glassnode-cli migration plan is dropped (belongs in GN-159).
  • CHANGELOG reset to a single entry for the initial public release: v0.1.0 was a private tag nobody consumed, so behaviour changes relative to it are noted in the review threads, not the changelog.
  • Package doc, examples README and fixtures README rewritten.

CI

  • golangci-lint (standard set + gosec, revive, errorlint, bodyclose, ...) for both modules, replacing the separate gofmt/vet steps; its findings on existing code are fixed.
  • govulncheck job; actions pinned by SHA with weekly Dependabot updates.
  • Release workflow: a vX.Y.Z tag runs the tests and creates a GitHub release from that version's CHANGELOG section.

Test plan

  • go test -race ./..., go vet ./..., gofmt -l . clean; examples module vets and builds; each commit tested on its own
  • New tests: bulk selectors and wire round-trip, bulk without Since, 429/503 with x-rate-limit-reset, string and list parameters_defaults, new list methods, invalid user agents
  • Recorded bulk fixture keeps network in Params
  • README quick start and bulk snippet compile against the local module
  • GetMetricCSV: streamed body, 503→200 retry without duplicate output, bulk/nil-writer/JSON-body rejection, no retry after partial output, size limit; smoke-tested against the live API
  • Round 2: 10 concurrent 401s → 1 refresh; empty refreshed token → AuthError; exact/over limit for CSV with byte count; headers-only first attempt retried; writer error not a TransportError; <title>401</title> and entities; f=CSV Accept
  • Review follow-ups: epoch-vs-seconds guard for x-rate-limit-reset, decode-once (no partial-decode leakage), chunked exact-limit body, ResponseTooLargeError, numeric selector round trip, empty s
  • New tests: computed_at fixtures, usage without add-ons, interrupted body retried, invalid JSON not retried, WithMaxResponseBytes (Get, Raw, exact limit, negative), default MaxDelay ≥ 60s
  • Smoke-test against the live API with a real key (bulk with exchanges, a rate-limited loop)
  • golangci-lint run and govulncheck clean locally; workflows pass actionlint
  • CI green: test (1.24.x, stable), lint, vulncheck

@martende martende changed the title fix(options): reject control characters in WithUserAgent docs: rewrite documentation and fix WithUserAgent validation Oct 5, 2026
@martende martende changed the title docs: rewrite documentation and fix WithUserAgent validation fix: align client with API docs and rewrite documentation Oct 5, 2026
WithUserAgent only rejected CR and LF, although its error message
promised to reject control characters. A value such as "app\x00" built a
client whose every request failed in net/http with an invalid header
error, which was then retried as a TransportError.

Also remove the unused (*Client).redact helper; the redaction test now
exercises the per-call redactor directly.
Restructure the README around common tasks: installation, quick start,
fetching data, configuration with a defaults table, error handling and
examples. Fix the GOPRIVATE command, which overwrote existing entries,
and the backoff description, which implied a one-second minimum delay.
Link the API key setup page, use a real CI badge and gofmt-formatted
snippets, and describe OAuth support as accepting existing tokens rather
than implying a login flow.

Turn docs/design.md from an implementation spec into design notes that
explain the decisions and what is out of scope; the glassnode-cli
migration plan belongs in its tracker. Expand the package doc shown by
go doc, document the examples' flags and environment variables, and
describe v0.1.0 in the changelog by its features instead of pre-release
review history.
Compared the client against docs.glassnode.com and fixed what differed:

- Bulk entries carry every selector of their parameter combination (e,
  network, category, ...), but BulkEntry kept only a and network, so
  entries for different exchanges or object-metric categories were
  indistinguishable. BulkEntry.Params now holds all of them and encodes
  back to the wire format. The API requires s for bulk requests, so
  GetBulkMetric rejects a request without Since before sending it.
- The API reports its per-minute rate limit through x-rate-limit-reset
  and does not document Retry-After; retrying a 429 after the default
  jittered delays only hit the limit again. A 429 without Retry-After
  now uses x-rate-limit-reset as the minimum delay and returns the error
  immediately when that exceeds MaxDelay. The requested wait is exposed
  as APIError.RetryAfter.
- parameters_defaults values are strings in the documentation and lists
  in recorded responses; accept both instead of failing the whole
  metadata call.
- Add ListExchanges, ListNetworks and ListMiners for the documented
  metadata endpoints.

Document the bulk time-range limits and credit cost, the rate-limit
handling and the new methods.
Requested every metric listed by the API over a short range and compared
the responses with what the client expects.

- Every point-in-time metric (843 of 1818) adds computed_at to each
  point, the time at which the value was computed; TimeSeriesPoint and
  ObjectTimeSeriesPoint silently dropped it. Add ComputedAt as a nullable
  field and record two real PIT responses as contract fixtures.
- GetAPIUsage required apiAddons; an account without add-ons can report
  null or omit the field. Only creditsUsed is required now.
- A non-2xx body was read up to 4 KiB and closed, which discards the
  connection when the body is longer. Drain a bounded remainder so
  retries after 429 and 5xx reuse the connection.
…eams

The API's rate limit works in one-minute windows and a 429 reports the
seconds until the window resets, up to 60. With the default MaxDelay of
30 seconds the client returned a 429 from the first half of a window
without retrying at all. Default to 65 seconds; explicit policies are
unchanged.

Get decoded responses from a fully buffered body, holding both the bytes
and the decoded values in memory. Decode from the response stream
instead, so memory use is roughly the decoded values. Read failures
during decoding are still transport errors and are retried; invalid JSON
is still a DecodeError and is not.

Public API clients such as go-github, stripe-go and slack-go impose no
response size limit, since the server decides how much a request
returns. Follow that default, and add WithMaxResponseBytes for callers
who prefer a failing call to unbounded memory; such a response fails
with ErrResponseTooLarge and is not retried.
Standard linters plus gosec, revive, errorlint, bodyclose and a few
others. Fixes: unchecked Close and io.Copy results, a never-used return
value of do, an unnecessary conversion, an ineffectual assignment in a
test and missing comments on exported UnmarshalJSON methods. Retry
jitter keeps math/rand (G404 excluded): it needs no cryptographic
randomness.
- Lint both modules with golangci-lint; gofmt and go vet move into it.
- Run govulncheck on the latest Go.
- Pin actions by commit SHA and let Dependabot update them weekly.
- On a vX.Y.Z tag, run the tests and create a GitHub release whose notes
  are that version's CHANGELOG section; the tag fails without one.
- Run CI only for pull requests and pushes to main, cancelling stale
  runs of the same ref.
Install with a plain go get and link pkg.go.dev; the GOPRIVATE
instructions and the internal issue-tracker link go away. Add a security
policy pointing at GitHub's private vulnerability reporting, contributing
guidelines, the Contributor Covenant 2.1, issue forms and a pull request
template.

The release workflow builds release notes from the changelog, so an
Unreleased section records this pull request's changes; it becomes
v0.2.0 at release.
@martende
martende force-pushed the fix/user-agent-validation branch from 6d0716b to 9375448 Compare October 7, 2026 06:50
Comment thread examples/README.md Outdated

@planadecu planadecu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Inline notes from a code review. The 429 / x-rate-limit-reset change reads as intended, and there's one question about the header format inline.

Bugs: partial-decode leakage across retries (client.go:415), an exact-limit body rejected as too large (client.go:274), and the bulk Params round-trip changing value types (metrics.go:101). Breaking changes are flagged for awareness only, since they need follow-up in glassnode-cli. These came from reading the code; none were reproduced by running it.

Comment thread client.go
Comment thread client.go Outdated
return c.do(ctx, endpoint, params, func(body io.Reader) error {
decoder := json.NewDecoder(body)
decoder.UseNumber()
if err := decoder.Decode(dst); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: with streaming decode, a read failure partway through the body is retried into the same dst. encoding/json merges into existing maps and leaves struct fields alone when they're missing from the input. So if attempt 1 decodes keys A and B and then the connection resets, and attempt 2's body has only A, the caller gets a stale B from the failed attempt and no error.

The doc comment mentions this, but callers won't expect it. Suggest decoding each attempt into a fresh value, reflect.New(reflect.TypeOf(dst).Elem()), and copying it into dst only on success.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch, with one correction to the scenario: a cut in the middle of the value doesn't leak, because json.Decoder.Decode buffers the whole value before unmarshalling it — I tested that and dst stays empty. The leak is real one step later: a complete value followed by a failed read before EOF (e.g. Content-Length longer than the body) is a transport error, the retry decoded into the same dst, and the maps merged. Reproduced: attempt 1 {"a":1,"b":2} then cut, attempt 2 {"a":1} → result map[a:1 b:2].

Fixed differently from the reflect.New suggestion: the body is now read inside the retry loop and decoded once, after it, from the first successful attempt — the same approach stripe-go and go-github take. dst is written at most once, and a failed request or invalid JSON leaves it untouched. Since json.Decoder buffers the value anyway, the streaming decode saved no memory; it's dropped and the docs that claimed otherwise are corrected. Test added for your scenario.

Comment thread client.go Outdated
Comment thread client.go Outdated
Comment thread metrics.go
Comment thread metrics.go
Comment thread metrics.go Outdated
Comment thread metrics.go
Comment thread metrics.go Outdated

@planadecu planadecu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review at f4e924b. All six fixes from the previous round hold up: decode once after the retry loop, ResponseTooLargeError, a body exactly at the limit is accepted, large reset values are read as timestamps, the Asset()/Network() accessors, and empty s is rejected. I didn't re-raise anything the replies already settled.

The new notes below are mostly edge cases in TokenRefresher and GetMetricCSV, and none of them block. They come from reading the code; none were reproduced by running it.

For glassnode-cli: entry.Asset used as a value ("a": entry.Asset) still compiles but stores a func, so JSON output fails at runtime. Both uses need (). CLI PR #6 already handles this.

Comment thread client.go
Comment thread client.go Outdated
Comment thread metrics.go Outdated
Comment thread metrics.go
Comment thread metrics.go
Comment thread client.go
Comment thread client.go
Comment thread client.go Outdated
Comment thread client.go
Review round 1:
- Decode once, after the retry loop, so a failed attempt never leaves
  values in the caller's destination; the streaming decode is dropped
  since json.Decoder buffers a whole value anyway.
- A Retry-After or x-rate-limit-reset value above a year is read as a
  Unix timestamp, so a format change cannot silently disable retries.
- ResponseTooLargeError replaces ErrResponseTooLarge-in-TransportError;
  a body of exactly the limit is accepted however it is chunked.
- BulkEntry keeps every selector in Params with Asset() and Network()
  accessors; selectors are documented as strings; a is not required.
- CHANGELOG starts from a single initial-release entry.

Features requested in review:
- GetMetricCSV streams a metric's CSV to an io.Writer; bulk is rejected,
  Content-Type is checked, writer errors are the caller's and a failure
  before any output is retried.
- TokenRefresher lets a TokenSource replace a token the API rejected;
  one refresh per rejected token across goroutines, repeated once.

Review round 2:
- copyLimited holds the size-limit rule once; the writer never receives
  more than the limit.
- HTML error pages are summarised by title (status-text fallback, entity
  decoding) instead of quoting markup; f=CSV matches case-insensitively.
- Docs: the per-attempt timeout covers the body read; examples use the
  API key only.
@martende
martende force-pushed the fix/user-agent-validation branch from d138c1e to 778245e Compare October 8, 2026 13:56
@martende
martende merged commit 79081d3 into main Oct 8, 2026
4 checks passed
@martende
martende deleted the fix/user-agent-validation branch October 8, 2026 13:59
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.

3 participants