diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..212cb72 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,39 @@ +name: Bug report +description: The client does something wrong or fails to decode an API response. +labels: [bug] +body: + - type: input + id: version + attributes: + label: Client version + description: Output of `go list -m github.com/glassnode/glassnode-api-go-client`, or the commit. + placeholder: v0.1.0 + validations: + required: true + - type: input + id: go + attributes: + label: Go version + placeholder: go1.24.5 linux/amd64 + validations: + required: true + - type: textarea + id: what + attributes: + label: What happened + description: What you called, what you expected and what you got. Paste the error message; the client never includes credentials in it, but check that your own logs don't either. + validations: + required: true + - type: textarea + id: repro + attributes: + label: How to reproduce + description: A short program or the metric path and parameters. For a decoding problem, a few points of the raw JSON response are the most useful thing you can include. + render: go + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I have removed API keys and tokens from everything pasted above. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec81676 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Security issue + url: https://github.com/glassnode/glassnode-api-go-client/security/advisories/new + about: Report a vulnerability privately instead of opening an issue. + - name: Glassnode API documentation + url: https://docs.glassnode.com + about: Questions about metrics, parameters, credits and plans are answered there; the client only transports what the API returns. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..aabc427 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,22 @@ +name: Feature request +description: Support for an API endpoint, response shape or option the client lacks. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: What you are trying to do + description: The API call or workflow, and what makes it hard or impossible with the client today. + validations: + required: true + - type: input + id: docs + attributes: + label: API documentation + description: Link to the relevant page on docs.glassnode.com, if any. + placeholder: https://docs.glassnode.com/basic-api/... + - type: textarea + id: proposal + attributes: + label: Proposed change + description: Optional. How you imagine the client API for it, or a workaround you use now with `GetMetric`, `Get` or `Raw`. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..da701fc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +## Summary + + + +## Test plan + + + +- [ ] `go test -race ./...` and `golangci-lint run ./...` pass +- [ ] CHANGELOG.md updated (for user-visible changes) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cf1fd4e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ['*'] + # The client has no dependencies; this keeps the Go directive current. + - package-ecosystem: gomod + directories: [/, /examples] + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 388e2f5..9620536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,13 @@ name: CI on: push: + branches: [main] pull_request: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test: strategy: @@ -12,25 +16,50 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go }} - cache: false - - name: Format - run: test -z "$(gofmt -l .)" + cache: false # no dependencies to cache - name: Check module files run: go mod tidy -diff - - name: Vet - run: go vet ./... - name: Test with race detector run: go test -race ./... - name: Check example module files working-directory: examples run: go mod tidy -diff - - name: Vet examples - working-directory: examples - run: go vet ./... - name: Build examples working-directory: examples run: go build ./... + + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.14 + - name: Lint examples + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.14 + working-directory: examples + # The first step already installed this version; skip its cache setup. + install-mode: none + + vulncheck: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..99344ac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release +on: + push: + tags: ['v*.*.*'] +permissions: + contents: write +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false + - name: Test + run: go test -race ./... + - name: Check that the changelog mentions this version + run: grep -q "^## ${GITHUB_REF_NAME}" CHANGELOG.md + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + # Release notes are the changelog section for this version. + awk -v tag="$GITHUB_REF_NAME" ' + /^## / { printing = ($2 == tag) ; next } + printing { print } + ' CHANGELOG.md > notes.md + gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file notes.md --verify-tag diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..7ba3cfe --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,39 @@ +version: "2" + +linters: + default: standard + enable: + - bodyclose + - copyloopvar + - errorlint + - gocritic + - gosec + - misspell + - nilerr + - noctx + - revive + - unconvert + - unparam + settings: + gosec: + excludes: + # Retry jitter does not need cryptographic randomness. + - G404 + errorlint: + # errors.Is on io.EOF and context errors is idiomatic for sentinel errors. + comparison: false + revive: + rules: + - name: exported + arguments: ["checkPrivateReceivers", "disableStutteringCheck"] + exclusions: + rules: + - path: _test\.go + linters: [gosec, noctx, errcheck, unparam] + - path: examples/ + linters: [gosec] + +formatters: + enable: + - gofmt + - goimports diff --git a/CHANGELOG.md b/CHANGELOG.md index 70af857..ba3ed4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,28 +1,24 @@ # Changelog -## v0.1.0 — 2026-10-01 +## v0.1.0 — 2026-10-08 -Initial private SDK release. One dependency-free root `glassnode` package, with -typed metrics, metadata and usage endpoints, context cancellation, configurable -HTTP transport and bounded retries. Runnable examples are a separate module and -excluded from the SDK distribution. +Initial public release. -Changes following the Fable review: - -- Preserve metric `parameters_defaults` from recorded API responses. -- Include redacted diagnostic causes in decode and transport errors, preserving - the original error chain for `errors.Is` and `errors.As`. -- Add `TokenSource`, `TokenSourceFunc`, `WithTokenSource` and `AuthError` for - application-owned OAuth refresh. Tokens are obtained per HTTP attempt and - redacted per call, including tokens used on earlier retries. -- Reject conflicting authentication, invalid credential whitespace/control - characters and OAuth combined with query API-key authentication. Previously, - bearer tokens silently took precedence over a configured API key. -- Make explicit `WithTimeout` override a supplied HTTP client's timeout in - either option order. -- Extract JSON API error messages and bound detail after redaction. -- Add recorded API contract coverage and tests for token-source concurrency, - cancellation, failures and redaction, interrupted reads, per-attempt timeouts, - HTTP-date Retry-After and malformed metadata/usage responses. - -OAuth login and token storage remain application-owned. x402 is deferred. +- Metric time series: scalar (`GetTimeSeries`), object (`GetObjectTimeSeries`), + bulk (`GetBulkMetric`) and custom shapes (`GetMetric`). Point-in-time metrics + expose `ComputedAt`; bulk entries keep every selector in `Params`. +- Asset and metric metadata, metric lag statistics, and lists of metric tags, + asset tags, categories, blockchains, exchanges, networks and miners. +- API credit usage (`GetAPIUsage`). +- CSV downloads of a metric to an `io.Writer` (`GetMetricCSV`). +- Generic `Get` and `Raw` for endpoints without a dedicated method. +- Authentication with an API key (header or query) or an existing OAuth access + token, fixed or from a `TokenSource`; a `TokenRefresher` can replace a token + the API rejected with `401`. +- Context cancellation, a per-attempt timeout and retries with jittered + backoff that honour `Retry-After` and `x-rate-limit-reset`, with the wait + exposed as `APIError.RetryAfter`. +- Optional response size limit (`WithMaxResponseBytes`). +- Typed errors with credentials redacted from messages; redirects are never + followed. +- No dependencies outside the standard library. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..02f8947 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at the maintainers listed in [CODEOWNERS](CODEOWNERS), by opening a private security advisory on GitHub or by contacting any of them directly. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e759cc0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# Contributing + +Thanks for taking the time to contribute. This document explains how to get a +change into the project. + +## Reporting bugs and requesting features + +Open an [issue](https://github.com/glassnode/glassnode-api-go-client/issues). +For bugs, include the client version, the Go version, the metric path or +endpoint, and the error message; the client never puts credentials into error +messages, but check your own logs before pasting them. For a response that the +client decodes wrongly, the raw JSON of a few points helps most. + +Security issues go through [SECURITY.md](SECURITY.md), not the issue tracker. + +## Development setup + +You need Go 1.24 or later and [golangci-lint](https://golangci-lint.run) v2. +The client has no dependencies outside the standard library, so there is +nothing else to install. + +```sh +git clone https://github.com/glassnode/glassnode-api-go-client +cd glassnode-api-go-client +go test -race ./... +golangci-lint run ./... +``` + +Tests run against local HTTP servers and the recorded responses in +[`testdata/`](testdata), so they need no API key and make no network requests. +The programs in [`examples/`](examples) do call the API; see their +[README](examples/README.md). + +## Making a change + +1. Fork the repository and create a branch from `main`. +2. Make the change, with tests. A behaviour change needs a test that fails + without it; a new response shape needs a recorded response in + `testdata/contract/` (see its [README](testdata/contract/README.md)). +3. Run `go test -race ./...` and `golangci-lint run ./...` in the repository + root, and `go build ./... && golangci-lint run ./...` in `examples/`. +4. Add a line to the `Unreleased` section of [CHANGELOG.md](CHANGELOG.md) if + the change is visible to users. +5. Open a pull request. Describe what changes for a user of the client and + why; a link to the relevant part of the + [API documentation](https://docs.glassnode.com) helps the review. + +CI runs the tests on the oldest supported and the latest Go release, the +linter and `govulncheck`. A maintainer listed in [CODEOWNERS](CODEOWNERS) +reviews every pull request. + +## Guidelines + +- **Keep the standard library as the only dependency.** This is a deliberate + property of the client; see [docs/design.md](docs/design.md) for the + reasoning behind this and other decisions. +- **Follow the API documentation.** Field names, parameter names and response + shapes come from the API; the client does not rename or reinterpret them. +- **Don't break callers.** Exported names, struct fields and behaviour are the + public API. Additions are fine; changes and removals need a discussion in an + issue first and a major version bump once the client is at 1.0. +- **Write Go that looks like the surrounding code.** `gofmt`, short names where + the scope is short, comments on every exported identifier, errors wrapped so + `errors.Is` and `errors.As` keep working. + +## Commit messages + +Use the imperative mood and a `type(scope): summary` first line, for example +`fix(client): drain error bodies so connections are reused`. Types are `feat`, +`fix`, `docs`, `test`, `refactor`, `ci` and `chore`. Explain the why in the +body when the summary is not enough. + +## Releases + +Maintainers release by tagging `main` with `vX.Y.Z`. The tag must have a +matching section in `CHANGELOG.md`; CI then runs the tests and publishes a +GitHub release with that section as its notes. Until 1.0, minor versions may +change behaviour and say so in the changelog. + +## License + +By contributing you agree that your contributions are licensed under the +[Apache License 2.0](LICENSE) that covers the project. diff --git a/README.md b/README.md index ed01ee4..8692cad 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,364 @@ -# Glassnode API client for Go +# Glassnode Go client -[![CI](https://img.shields.io/badge/CI-GitHub_Actions-2088FF.svg?logo=githubactions)](https://github.com/glassnode/glassnode-api-go-client/actions/workflows/ci.yml) -[![Go version](https://img.shields.io/badge/Go-%E2%89%A51.24-00ADD8.svg?logo=go)](./go.mod) -[![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](./go.mod) -[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) +[![Go Reference](https://pkg.go.dev/badge/github.com/glassnode/glassnode-api-go-client.svg)](https://pkg.go.dev/github.com/glassnode/glassnode-api-go-client) +[![CI](https://github.com/glassnode/glassnode-api-go-client/actions/workflows/ci.yml/badge.svg)](https://github.com/glassnode/glassnode-api-go-client/actions/workflows/ci.yml) +[![Go version](https://img.shields.io/badge/go-%E2%89%A51.24-00ADD8.svg?logo=go)](go.mod) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -An idiomatic, dependency-free Go client for the Glassnode API. Extracted from -[`glassnode-cli`](https://github.com/glassnode/glassnode-cli), with typed metric -helpers, metadata and usage endpoints, cancellation and bounded retries. +A Go client for the [Glassnode API](https://docs.glassnode.com). It covers +metric time series, bulk metrics, asset and metric metadata, and API usage. +It has no dependencies outside the standard library. -Go 1.24 or later. This repository is **private**; your GitHub account needs read -access. The API key is separate from your GitHub authentication. - -## Install - -Authenticate Git with GitHub (for example, `gh auth setup-git`) and configure -private-module resolution. Include this path in your existing `GOPRIVATE` value: +## Installation ```sh -go env -w GOPRIVATE=github.com/glassnode/glassnode-api-go-client -go get github.com/glassnode/glassnode-api-go-client@v0.1.0 +go get github.com/glassnode/glassnode-api-go-client ``` -Do not replace an existing `GOPRIVATE` list without preserving its entries. -The private setting keeps the module out of the public proxy and checksum -database. In applications, commit `go.mod` and `go.sum` to pin your SDK version. +The client requires Go 1.24 or later. Full API documentation is on +[pkg.go.dev](https://pkg.go.dev/github.com/glassnode/glassnode-api-go-client). + +## Quick start -## Fetch a metric +You need a Glassnode API key; see [API Setup](https://docs.glassnode.com/basic-api/api) +for how to create one. ```go package main import ( - "context" - "fmt" - "log" - "os" - "time" + "context" + "fmt" + "log" + "os" + "time" - glassnode "github.com/glassnode/glassnode-api-go-client" + glassnode "github.com/glassnode/glassnode-api-go-client" ) func main() { - client, err := glassnode.NewClient(os.Getenv("GLASSNODE_API_KEY")) - if err != nil { log.Fatal(err) } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - points, err := client.GetTimeSeries(ctx, "market/price_usd_close", &glassnode.MetricParams{ - Asset: "BTC", - Since: time.Now().AddDate(0, 0, -30), - Interval: "24h", - }) - if err != nil { log.Fatal(err) } - for _, point := range points { - if point.Value != nil { - fmt.Printf("%s: %.2f\n", time.Unix(point.Timestamp, 0).UTC(), *point.Value) - } - } + client, err := glassnode.NewClient(os.Getenv("GLASSNODE_API_KEY")) + if err != nil { + log.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + points, err := client.GetTimeSeries(ctx, "market/price_usd_close", &glassnode.MetricParams{ + Asset: "BTC", + Since: time.Now().AddDate(0, 0, -30), + Interval: "24h", + }) + if err != nil { + log.Fatal(err) + } + + for _, p := range points { + if p.Value == nil { + continue // no data for this timestamp + } + fmt.Printf("%s %.2f\n", time.Unix(p.Timestamp, 0).UTC().Format(time.DateOnly), *p.Value) + } } ``` -Create a client once and reuse it across goroutines. The SDK reads no environment -variables or files; the example chooses to read the API key from the environment. -Timestamps are Unix **seconds**. Scalar/bulk gaps are `nil`, distinct from zero. +A `Client` is safe for concurrent use; create one and share it. The client +never reads environment variables or files on its own: the example above reads +the key itself and passes it in. -## Choose the right response shape +## Fetching data -| Operation | Method | +Pick the method that matches the shape of the metric's response: + +| Data | Method | | --- | --- | -| Scalar `{t,v}` metrics | `GetTimeSeries(ctx, path, params)` | -| OHLC/breakdown `{t,o}` metrics | `GetObjectTimeSeries(ctx, path, params)` | -| Bulk metrics | `GetBulkMetric(ctx, basePath, params)` | -| Custom/nested metric shapes | `GetMetric(ctx, path, params, &destination)` | -| Asset metadata, with optional CEL filter | `ListAssets(ctx, filter)` | -| Metric paths, with optional selectors | `ListMetrics(ctx, params)` | -| Metric metadata and variants | `GetMetricMetadata(ctx, path, params)` | -| Lag percentiles | `GetMetricStats(ctx, path, params)` | -| Named metadata lists | `ListMetricTags`, `ListAssetTags`, `ListAssetCategories`, `ListAssetBlockchains` | -| Current API credits | `GetAPIUsage(ctx)` | -| New GET endpoints | `Get(ctx, endpoint, query, &destination)` or `Raw(ctx, endpoint, query)` | - -`params` may be `nil`. Metric paths accept either `market/price_usd_close` or -`/market/price_usd_close`; full URLs and query strings are rejected. For bulk, -pass the base path without `/bulk`; the method appends it and unwraps `data`. +| Scalar metric, `{"t", "v"}` | `GetTimeSeries` | +| Object metric (OHLC, breakdowns), `{"t", "o"}` | `GetObjectTimeSeries` | +| Several assets in one request | `GetBulkMetric` | +| Any other shape | `GetMetric` with your own destination type | +| A metric as CSV, written to an `io.Writer` | `GetMetricCSV` | +| Metric metadata, variants and availability | `GetMetricMetadata` | +| Data lag percentiles | `GetMetricStats` | +| Available metric paths | `ListMetrics` | +| Asset metadata | `ListAssets` | +| Metric tags, asset tags, categories, blockchains | `ListMetricTags`, `ListAssetTags`, `ListAssetCategories`, `ListAssetBlockchains` | +| Exchanges, networks, miners | `ListExchanges`, `ListNetworks`, `ListMiners` | +| Account credit usage | `GetAPIUsage` | +| Endpoints without a dedicated method | `Get` (decodes JSON) or `Raw` (returns bytes) | + +Things to keep in mind: + +- Metric paths are written as in the API docs, with or without a leading slash: + `market/price_usd_close`. +- Timestamps are Unix **seconds**. +- Missing values are `nil`, so a gap is never confused with a zero. +- Point-in-time metrics (`*_pit`) also report when each value was computed, in + `ComputedAt`; for other metrics it is `nil`. +- `params` may be `nil`. The asset list methods take an optional + [CEL](https://cel.dev) filter expression; pass `""` for no filter. + +### Bulk metrics + +[Bulk endpoints](https://docs.glassnode.com/basic-api/bulk-metrics) return a +metric for many assets, exchanges or other selectors in one request. Pass the +base metric path; the client adds the `/bulk` suffix and unwraps the response +envelope. ```go -bulk, err := client.GetBulkMetric(ctx, "market/marketcap_usd", &glassnode.MetricParams{ - Assets: []string{"BTC", "ETH"}, - Interval: "24h", +points, err := client.GetBulkMetric(ctx, "distribution/balance_exchanges", &glassnode.MetricParams{ + Assets: []string{"BTC", "ETH"}, + Exchanges: []string{"binance", "coinbase"}, + Since: time.Now().AddDate(0, 0, -7), + Interval: "24h", }) +for _, point := range points { + for _, entry := range point.Bulk { + fmt.Println(point.Timestamp, entry.Asset(), entry.Params["e"], entry.Value) + } +} ``` -Use `Extra url.Values` for metric-specific selectors. Values are encoded as -repeated parameters, never comma-joined. Do not set the same parameter through -both a typed field and `Extra`. The SDK copies query maps and slices. +Each entry carries the selectors that identify it in `Params` (for example +`a`, `e`, `network`, or `category` for object metrics). Selector values are +strings; a value of another JSON type, should the API ever send one, is kept as +its JSON text and written back as a string by `json.Marshal`. Bulk requests differ +from regular ones: + +- `Since` is required, and one request covers at most 10 days at `10m` and + `1h`, 31 days at `24h`, and 93 days at `1w` and `1month` resolution. +- Every combination of selectors is billed like a separate request, and + leaving out `Assets` selects all assets. Always set the selectors you need. +- Bulk endpoints are not available on the Light API. + +### Additional parameters + +`MetricParams` has typed fields for the common parameters (asset, exchanges, +time range, interval, currency). Use `Extra` for anything else that a metric +accepts. Values with several entries are sent as repeated query parameters. ```go params := &glassnode.MetricParams{ - Asset: "BTC", - Exchanges: []string{"binance"}, - Extra: url.Values{"quote_symbol": {"USDT"}, "bps": {"100"}}, + Asset: "BTC", + Exchanges: []string{"binance"}, + Extra: url.Values{"quote_symbol": {"USDT"}}, } ``` -`GetMetric` supports caller-owned structs, custom `UnmarshalJSON` validation and -`json.RawMessage`. JSON decoded into `any` uses `json.Number` to preserve large -integers; the numeric convenience methods use `float64` for arithmetic. Nullable -values in custom structs should use pointers. +Set each parameter in one place only: setting it both in a typed field and in +`Extra` is an error. + +### CSV downloads -## Configure HTTP and authentication +`GetMetricCSV` asks the API for CSV and copies the response to a writer as it +arrives, so a file of any size can be downloaded without holding it in memory: + +```go +file, err := os.Create("price.csv") +if err != nil { + return err +} +defer file.Close() +err = client.GetMetricCSV(ctx, "market/price_usd_close", &glassnode.MetricParams{ + Asset: "BTC", + Since: time.Now().AddDate(0, -1, 0), + Interval: "24h", +}, file) +``` + +The columns are the API's (`timestamp,value`, the object keys for object +metrics, `computed_at` for point-in-time metrics). Bulk metrics are not +available in CSV. A failure before the body starts is retried like any other +request; a failure while copying is not, since part of the file has been +written. + +### Custom response types + +`GetMetric` decodes into any pointer you pass, so you can define a struct for an +unusual metric, add your own `UnmarshalJSON` validation, or keep the raw +response with `json.RawMessage`. Use pointer fields for values that can be +`null`. When decoding into `any`, numbers become `json.Number`, so large +integers keep their precision. + +## Configuration + +`NewClient` works without options. The defaults are: + +| Setting | Default | Option | +| --- | --- | --- | +| Authentication | API key in the `X-Api-Key` header | `WithAPIKeyInQuery`, `WithBearerToken`, `WithTokenSource` | +| Timeout | 1 minute per HTTP attempt | `WithTimeout` | +| Retries | 2 retries, backoff capped at 65s | `WithRetryPolicy` | +| Response size | Unlimited | `WithMaxResponseBytes` | +| HTTP client | Fresh `http.Client` | `WithHTTPClient` | +| Base URL | `https://api.glassnode.com` | `WithBaseURL` | +| User agent | `glassnode-api-go-client` | `WithUserAgent` | ```go client, err := glassnode.NewClient(apiKey, - glassnode.WithTimeout(15*time.Second), - glassnode.WithRetryPolicy(glassnode.RetryPolicy{ - MaxRetries: 2, - BaseDelay: time.Second, - MaxDelay: 30*time.Second, - }), + glassnode.WithTimeout(15*time.Second), + glassnode.WithRetryPolicy(glassnode.RetryPolicy{ + MaxRetries: 3, + BaseDelay: 500 * time.Millisecond, + MaxDelay: 10 * time.Second, + }), + glassnode.WithUserAgent("my-service/1.2"), ) ``` -The default sends the key in `X-Api-Key`, avoiding credentials in request URLs. -`WithAPIKeyInQuery()` enables legacy `api_key` query authentication. -`NewClient("", WithBearerToken(token))` uses an existing OAuth access token. -For refreshable OAuth credentials, pass a token source. It is called before -**each HTTP attempt**, including retries, and the actual token is redacted from -that call's errors: +### Authentication + +Most applications should use an API key. The client sends it in the +`X-Api-Key` header, which keeps it out of URLs and access logs. +`WithAPIKeyInQuery` sends it as the `api_key` query parameter instead, for +setups that require it. + +If your application already has a Glassnode OAuth access token, pass an empty +API key and use one of the bearer-token options instead. The client does not +implement the OAuth login flow; it only sends the token you give it. + +- `WithBearerToken(token)` sends a fixed token. Use it for short-lived scripts. +- `WithTokenSource(source)` calls `source` before every HTTP attempt, including + retries, so it can return a refreshed token. It must be safe for concurrent + use and should respect context cancellation. + +For example, to reuse a token source from +[`golang.org/x/oauth2`](https://pkg.go.dev/golang.org/x/oauth2): ```go source := glassnode.TokenSourceFunc(func(ctx context.Context) (string, error) { - return session.AccessToken(ctx) // your application's refresh/storage helper + token, err := oauthTokens.Token() // an oauth2.TokenSource + if err != nil { + return "", err + } + return token.AccessToken, nil }) client, err := glassnode.NewClient("", glassnode.WithTokenSource(source)) ``` -`session` is application-owned; the SDK implements neither login nor token -storage. A `TokenSource` must honor context cancellation and support concurrent -calls. It should return a usable token, refreshing before expiry. Token-source -errors and HTTP 401 responses are never retried. `AuthError` identifies token -acquisition failures. Sources must not put unknown credentials into their error -messages; the SDK can redact only tokens returned to it. - -Configure exactly one API key, static bearer token or token source. Query auth -requires an API key. Surrounding API-key whitespace, bearer-token whitespace and credential control -characters are rejected before sending requests. - -`WithHTTPClient` copies the client's configuration and preserves its transport, -jar and timeout. Supply your own `http.RoundTripper` for tracing, metrics or -authentication. **Redirects are never followed**, even with a custom client, -to avoid forwarding credentials. Point `WithBaseURL` at the final origin. - -Two retries are enabled by default for GET transport/read failures, 429 and -5xx responses. Backoff uses full jitter from one second to a 30-second cap. -`Retry-After` is a minimum wait; if it exceeds the cap, the call returns the -HTTP error instead of retrying early. `WithRetryPolicy(RetryPolicy{})` disables -retries. Decode errors, other HTTP statuses and caller cancellation never retry. - -The default HTTP timeout is one minute per attempt. A context deadline bounds -the whole operation, including retries and waits; use one when latency matters. -`WithTimeout(0)` disables the per-attempt timeout. A supplied HTTP client's -timeout replaces the default; explicit `WithTimeout` always overrides it, -regardless of option order. -The client does not cache data or impose a global rate limiter. - -## Handle errors +Configure exactly one way to authenticate; `NewClient` returns an error +otherwise. A failing token source produces an `AuthError` and is not retried. +A `401` response is returned as is, unless the token source also implements +`TokenRefresher`: then `Refresh` is called once and the request is repeated +with the new token, so an application can recover from a token revoked before +its expiry. + +### Timeouts and retries + +The timeout applies to each HTTP attempt separately and covers the whole +exchange, including reading the response body. A large response over a slow +link, such as a full history with `GetMetricCSV` or a long `Get`, can exceed +the default minute; raise it with `WithTimeout` or remove it with +`WithTimeout(0)` and bound the call with a context deadline instead. A context +deadline limits the total time of a call, including retries and the waits +between them. + +GET requests are retried after network errors and `429` or `5xx` responses. +Other errors, including `4xx` responses and responses that fail to decode, are +returned immediately. The delay before retry *n* is a random duration between +zero and `BaseDelay × 2ⁿ⁻¹`, capped at `MaxDelay` (with the defaults: up to +1s, then up to 2s). A `Retry-After` header raises the delay to at least the +value the server asks for; if that is longer than `MaxDelay`, the client returns +the error instead of waiting. When a `429` response has no `Retry-After`, the +client uses the API's `x-rate-limit-reset` header the same way. The wait the +server asked for is available as `APIError.RetryAfter`, so you can wait and +retry yourself when it exceeds `MaxDelay`. `WithRetryPolicy(glassnode.RetryPolicy{})` +disables retries. + +The client has no cache and no rate limiter. + +### Large responses + +A response is read completely before it is decoded, so a call holds the +response body and the decoded values in memory at the same time. The API does +not page results: a full history at `10m` resolution is hundreds of megabytes. +Limit the time range with `Since` and `Until` where possible. To bound memory +where failing is preferable to growing, set `WithMaxResponseBytes`; a larger +response then fails with a `*ResponseTooLargeError` and is not retried. + +### Custom HTTP client + +`WithHTTPClient` lets you supply your own transport, for example for tracing, +metrics or a proxy. The client copies the `http.Client` and keeps its +transport, cookie jar and timeout. An explicit `WithTimeout` takes precedence +over the supplied client's timeout. + +Redirects are never followed, even with a custom client, so credentials are +never sent to another host. A `3xx` response is returned as an `APIError`. + +If the supplied client already retries, for example one built with +`go-retryablehttp`, disable the client's own retries with +`WithRetryPolicy(glassnode.RetryPolicy{})`; otherwise both layers retry and a +failing request can take many times longer than either policy intends. + +## Error handling + +All errors can be inspected with `errors.As` and `errors.Is`: ```go +points, err := client.GetTimeSeries(ctx, path, params) + var apiErr *glassnode.APIError switch { -case errors.Is(err, context.Canceled): - // The caller stopped the operation. case errors.Is(err, context.DeadlineExceeded): - // A context deadline or the HTTP client's timeout expired. + // The context deadline or the HTTP timeout expired. case errors.As(err, &apiErr): - fmt.Printf("status=%d endpoint=%s\n", apiErr.StatusCode, apiErr.Endpoint) + log.Printf("API returned %d for %s: %s", apiErr.StatusCode, apiErr.Endpoint, apiErr.Detail) +case err != nil: + log.Print(err) } ``` -`InputError`, `AuthError`, `DecodeError` and `TransportError` distinguish other -failures. SDK-generated decode and transport messages include their redacted -cause. JSON API errors prefer the server's `message` or `error` field; response detail -is capped at 300 characters after redaction. -Error text and `APIError.Detail` redact configured credentials and tokens used -within that call, including earlier retry attempts. Original causes remain -available through `errors.Is` / `errors.As`; do not log unwrapped causes or raw -requests when they may include secrets. A custom authenticating transport must -protect credentials unknown to the SDK; prefer `WithTokenSource` for refresh. - -## Packaging and examples - -Install the root Go module and import its single `glassnode` package. There is -no binary to install and no build or bundling step. The SDK has no third-party -runtime dependencies. Releases use root Git tags such as `v0.1.0`; unreleased -commits resolve to Go pseudo-versions. - -Runnable programs live in [examples](examples/README.md), a separate nested Go -module. Its `go.mod` makes Go exclude the entire directory from the SDK module -zip downloaded by consumers. The examples use a relative `replace` to run -against the checkout, without fetching a private dependency or changing the -SDK's module file. Clone the repository to run them: +| Error | Meaning | +| --- | --- | +| `*APIError` | The API responded with a non-2xx status. `Detail` holds the server's message, shortened to 300 characters; `RetryAfter` is the wait the server asked for, if any. | +| `*InputError` | Invalid configuration or arguments; no request was sent. | +| `*TransportError` | The request failed on the network or while reading the response. | +| `*DecodeError` | The response did not match the expected type. | +| `*AuthError` | The token source failed or returned an invalid token. | +| `*ResponseTooLargeError` | The response exceeded `WithMaxResponseBytes`; also matches `errors.Is(err, ErrResponseTooLarge)`. | + +Context cancellation and deadlines are returned as `context.Canceled` and +`context.DeadlineExceeded`. + +Error messages never contain the API key or tokens used for the call. The +original underlying error is still available through `errors.Unwrap`, and it is +not redacted, so avoid logging it on its own. + +## Examples + +The [examples](examples) directory contains runnable programs for the main use +cases. Clone the repository and run them with your API key: ```sh -export GLASSNODE_API_KEY='your-api-key' cd examples +export GLASSNODE_API_KEY=your-api-key go run ./price -go run ./ohlc -go run ./bulk -go run ./metadata -go run ./usage -go run ./custom ``` -For OAuth, set `GLASSNODE_ACCESS_TOKEN` instead of `GLASSNODE_API_KEY`. See -[examples/README.md](examples/README.md) for output, credentials and permissions. +See [examples/README.md](examples/README.md) for the full list. -## Develop +## Development ```sh go test -race ./... -go vet ./... -cd examples -go vet ./... -go build ./... +golangci-lint run ./... +(cd examples && go build ./... && golangci-lint run ./...) ``` -Tests use local HTTP servers and need no API credentials. CI checks the minimum -Go version and the latest stable Go version. API design, TS feature mapping and -CLI boundaries are in [docs/design.md](docs/design.md). Tracking issue: -[GN-159](https://glassnode.atlassian.net/browse/GN-159). +Tests run against local HTTP servers and recorded API responses, so they need +no API key. CI runs the same checks on the oldest supported and the latest Go +release, plus `govulncheck`. Releases are tagged `vX.Y.Z`; the tag's section +in [CHANGELOG.md](CHANGELOG.md) becomes the GitHub release notes. Design decisions are described in [docs/design.md](docs/design.md). + +## Contributing + +Bug reports and pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md) +for how to set up a development environment and what a change needs. To report +a security issue, follow [SECURITY.md](SECURITY.md) rather than opening a +public issue. This project follows the +[Contributor Covenant](CODE_OF_CONDUCT.md) code of conduct. + +## License -The module is pre-1.0 and uses Go module versions. OAuth bearer tokens are -supported through static tokens or refreshable token sources. Login flows remain -application-owned; x402 payment support is deferred. License: Apache 2.0; see -[NOTICE](NOTICE) for provenance. +Apache License 2.0; see [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..891f37f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# Security policy + +## Reporting a vulnerability + +Please do not report security issues through public GitHub issues. + +Use GitHub's private vulnerability reporting instead: open the +[Security tab](https://github.com/glassnode/glassnode-api-go-client/security/advisories/new) +of this repository and choose **Report a vulnerability**. The report reaches the +maintainers listed in [CODEOWNERS](CODEOWNERS) only. + +Include what you can of the following: + +- the affected version or commit, +- a description of the issue and its impact, +- steps or a short program that reproduces it. + +You will get an acknowledgement within five working days. We will keep you +informed about the fix and credit you in the release notes unless you prefer +otherwise. + +## Supported versions + +Security fixes are released for the latest minor version. Update to the most +recent release before reporting. + +## Scope + +This repository contains the Go client only. Issues in the Glassnode API itself +or in other Glassnode products should be reported to Glassnode through the +contact options at [glassnode.com](https://glassnode.com). + +## What the client does to protect credentials + +- The API key is sent in the `X-Api-Key` header by default, not in the URL. +- Redirects are never followed, so credentials are not forwarded to another host. +- API keys and bearer tokens are removed from error messages. +- Request paths and query parameters are validated so a metric path cannot + change the host or inject parameters. + +If you find a way around any of these, that is a vulnerability we want to hear +about. diff --git a/auth.go b/auth.go index 06b20eb..c5c13fe 100644 --- a/auth.go +++ b/auth.go @@ -8,11 +8,24 @@ import ( // TokenSource supplies an OAuth access token for each HTTP attempt. Implementations // own refresh and storage, must honor ctx and must be safe for concurrent calls. -// The SDK does not automatically refresh on 401 or retry token-source errors. +// Token-source errors are never retried. A 401 response is returned as is +// unless the source also implements TokenRefresher. type TokenSource interface { Token(ctx context.Context) (string, error) } +// TokenRefresher is a TokenSource that can replace a token the API rejected. +// When a request gets a 401, the client calls Refresh once and repeats the +// request with the new token if it differs from the rejected one; a second +// 401 is returned to the caller. Returning the same token means nothing newer +// exists, and the 401 is returned as is. Refresh errors and an empty or +// malformed token are reported as AuthError. Concurrent calls rejected with the +// same token share one Refresh. +type TokenRefresher interface { + TokenSource + Refresh(ctx context.Context) (string, error) +} + // TokenSourceFunc adapts a function to TokenSource. type TokenSourceFunc func(context.Context) (string, error) diff --git a/auth_test.go b/auth_test.go index 04de80d..2f2c0ae 100644 --- a/auth_test.go +++ b/auth_test.go @@ -159,3 +159,150 @@ func TestTokenSource401DoesNotRetry(t *testing.T) { t.Fatalf("err=%v calls=%d", err, calls.Load()) } } + +type refreshingSource struct { + mu sync.Mutex + token string + next string + err error + delay time.Duration // widens the window in which concurrent 401s overlap + refreshs int +} + +func (s *refreshingSource) Token(context.Context) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.token, nil +} + +func (s *refreshingSource) Refresh(context.Context) (string, error) { + time.Sleep(s.delay) + s.mu.Lock() + defer s.mu.Unlock() + s.refreshs++ + if s.err != nil { + return "", s.err + } + if s.next != "" { + s.token = s.next + } + return s.token, nil +} + +func TestTokenRefresherRetriesOnceAfter401(t *testing.T) { + var headers []string + var mu sync.Mutex + handler := func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + headers = append(headers, r.Header.Get("Authorization")) + n := len(headers) + mu.Unlock() + if r.Header.Get("Authorization") != "Bearer fresh-token" { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"message":"token %s rejected"}`, strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + return + } + _ = n + fmt.Fprint(w, `[]`) + } + t.Run("new token is used and reused", func(t *testing.T) { + headers = nil + source := &refreshingSource{token: "stale-token", next: "fresh-token"} + client, _ := testClientWithKey(t, "", handler, WithTokenSource(source), WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond})) + if _, err := client.GetTimeSeries(context.Background(), "market/price", nil); err != nil { + t.Fatal(err) + } + if _, err := client.GetTimeSeries(context.Background(), "market/price", nil); err != nil { + t.Fatal(err) + } + if len(headers) != 3 || headers[0] != "Bearer stale-token" || headers[1] != "Bearer fresh-token" || headers[2] != "Bearer fresh-token" || source.refreshs != 1 { + t.Fatalf("headers=%v refreshes=%d", headers, source.refreshs) + } + }) + t.Run("second 401 is returned and both tokens are redacted", func(t *testing.T) { + headers = nil + source := &refreshingSource{token: "stale-token", next: "other-token"} + client, _ := testClientWithKey(t, "", handler, WithTokenSource(source), WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond})) + _, err := client.GetTimeSeries(context.Background(), "market/price", nil) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 401 || len(headers) != 2 { + t.Fatalf("err=%v headers=%v", err, headers) + } + if strings.Contains(err.Error(), "stale-token") || strings.Contains(err.Error(), "other-token") || !strings.Contains(err.Error(), "[redacted]") { + t.Fatalf("token leaked: %v", err) + } + }) + t.Run("unchanged token is not retried", func(t *testing.T) { + headers = nil + source := &refreshingSource{token: "stale-token"} + client, _ := testClientWithKey(t, "", handler, WithTokenSource(source)) + _, err := client.GetTimeSeries(context.Background(), "market/price", nil) + var apiErr *APIError + if !errors.As(err, &apiErr) || len(headers) != 1 || source.refreshs != 1 { + t.Fatalf("err=%v headers=%v refreshes=%d", err, headers, source.refreshs) + } + }) + t.Run("empty refreshed token is an AuthError", func(t *testing.T) { + headers = nil + source := &refreshingSource{token: "stale-token", next: " "} + client, _ := testClientWithKey(t, "", handler, WithTokenSource(source)) + _, err := client.GetTimeSeries(context.Background(), "market/price", nil) + var authErr *AuthError + if !errors.As(err, &authErr) || len(headers) != 1 || !strings.Contains(err.Error(), "token refresher") { + t.Fatalf("err=%v headers=%v", err, headers) + } + }) + t.Run("refresh failure is an AuthError", func(t *testing.T) { + headers = nil + source := &refreshingSource{token: "stale-token", err: errors.New("session expired, run login (stale-token)")} + client, _ := testClientWithKey(t, "", handler, WithTokenSource(source)) + _, err := client.GetTimeSeries(context.Background(), "market/price", nil) + var authErr *AuthError + if !errors.As(err, &authErr) || len(headers) != 1 || strings.Contains(err.Error(), "stale-token") { + t.Fatalf("err=%v headers=%v", err, headers) + } + }) + t.Run("concurrent 401s share one refresh", func(t *testing.T) { + headers = nil + source := &refreshingSource{token: "stale-token", next: "fresh-token", delay: 20 * time.Millisecond} + client, _ := testClientWithKey(t, "", handler, WithTokenSource(source)) + var group sync.WaitGroup + errs := make(chan error, 10) + for range 10 { + group.Add(1) + go func() { + defer group.Done() + _, err := client.GetTimeSeries(context.Background(), "market/price", nil) + errs <- err + }() + } + group.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + stale, fresh := 0, 0 + for _, h := range headers { + switch h { + case "Bearer stale-token": + stale++ + case "Bearer fresh-token": + fresh++ + } + } + if source.refreshs != 1 || stale != 10 || fresh != 10 { + t.Fatalf("refreshes=%d stale=%d fresh=%d", source.refreshs, stale, fresh) + } + }) + t.Run("plain token source gets the 401", func(t *testing.T) { + headers = nil + client, _ := testClientWithKey(t, "", handler, WithTokenSource(TokenSourceFunc(func(context.Context) (string, error) { return "stale-token", nil }))) + _, err := client.GetTimeSeries(context.Background(), "market/price", nil) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 401 || len(headers) != 1 { + t.Fatalf("err=%v headers=%v", err, headers) + } + }) +} diff --git a/client.go b/client.go index 150dedd..c2c3f42 100644 --- a/client.go +++ b/client.go @@ -5,14 +5,17 @@ import ( "context" "encoding/json" "errors" + "html" "io" "math/rand/v2" + "mime" "net/http" "net/url" "reflect" "regexp" "strconv" "strings" + "sync" "time" "unicode" ) @@ -29,8 +32,40 @@ type Client struct { retry RetryPolicy userAgent string timeout *time.Duration + maxBytes int64 + refresh *refreshState } +// refreshState merges concurrent token refreshes: calls that were rejected +// with the same token share one Refresh instead of each asking the +// authorization server, which may rotate refresh tokens and invalidate the +// others' results. +type refreshState struct { + mu sync.Mutex + rejected string + replacement string +} + +// refreshToken returns a replacement for a token the API rejected, calling +// the TokenRefresher only if no other call has replaced that token yet. +func (c *Client) refreshToken(ctx context.Context, refresher TokenRefresher, rejected string) (string, error) { + c.refresh.mu.Lock() + defer c.refresh.mu.Unlock() + if c.refresh.rejected == rejected && c.refresh.replacement != "" { + return c.refresh.replacement, nil + } + fresh, err := refresher.Refresh(ctx) + if err != nil { + return fresh, err + } + c.refresh.rejected, c.refresh.replacement = rejected, fresh + return fresh, nil +} + +// defaultMaxDelay covers the API's one-minute rate-limit window, so a 429 at +// the start of a window is still retried once the window resets. +const defaultMaxDelay = 65 * time.Second + // NewClient constructs a client with header API-key authentication, a one-minute // timeout per attempt and two GET retries. Configure exactly one of an API key, // WithBearerToken or WithTokenSource. Explicit WithTimeout overrides a supplied @@ -39,8 +74,9 @@ func NewClient(apiKey string, options ...Option) (*Client, error) { c := &Client{ baseURL: DefaultBaseURL, apiKey: apiKey, httpClient: &http.Client{Timeout: time.Minute}, - retry: RetryPolicy{MaxRetries: 2, BaseDelay: time.Second, MaxDelay: 30 * time.Second}, + retry: RetryPolicy{MaxRetries: 2, BaseDelay: time.Second, MaxDelay: defaultMaxDelay}, userAgent: "glassnode-api-go-client", + refresh: &refreshState{}, } for _, option := range options { if option == nil { @@ -73,6 +109,13 @@ func NewClient(apiKey string, options ...Option) (*Client, error) { return c, nil } +// Error bodies are short JSON messages; anything larger is not worth keeping +// or draining, and closing the body discards the connection instead. +const ( + maxErrorBody = 4096 + maxErrorDrain = 1 << 20 +) + var endpointPattern = regexp.MustCompile(`^/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+$`) var queryKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) var credentialPattern = regexp.MustCompile(`(?i)(api_key=|X-Api-Key["\s:=]+|Bearer\s+)[^\s&"<>]+`) @@ -101,8 +144,8 @@ func cloneQuery(params url.Values) (url.Values, error) { return nil, &InputError{k, "must not contain an empty value"} } } - if k == "f" && (len(values) != 1 || !strings.EqualFold(values[0], "json")) { - return nil, &InputError{"f", "only JSON is supported"} + if k == "f" && (len(values) != 1 || !strings.EqualFold(values[0], "json") && !strings.EqualFold(values[0], "csv")) { + return nil, &InputError{"f", "only json and csv are supported"} } q[k] = append([]string(nil), values...) } @@ -132,79 +175,149 @@ func (c *Client) buildURL(endpoint string, params url.Values) (*url.URL, error) // escape hatch for new endpoints; it does not decode or validate response JSON. // Query maps are copied and reserved authentication parameters are rejected. func (c *Client) Raw(ctx context.Context, endpoint string, params url.Values) ([]byte, error) { - body, _, err := c.raw(ctx, endpoint, params) + body, _, err := c.do(ctx, endpoint, params) return body, err } -func (c *Client) raw(ctx context.Context, endpoint string, params url.Values) ([]byte, *redactor, error) { +// do sends a GET request with retries and returns the body of the first +// successful attempt. Nothing is decoded here, so a failed attempt leaves no +// trace in the caller's values. +func (c *Client) do(ctx context.Context, endpoint string, params url.Values) ([]byte, *redactor, error) { + var body []byte + r, err := c.doWith(ctx, endpoint, params, func(resp *http.Response) error { + data, tooLarge, err := readBody(resp.Body, c.maxBytes) + if err != nil { + return &TransportError{endpoint, err} + } + if tooLarge { + return &ResponseTooLargeError{endpoint, c.maxBytes} + } + body = data + return nil + }) + return body, r, err +} + +// doWith sends a GET request with retries and hands each 2xx response to +// consume. consume returns the error to report: a TransportError is retried +// unless it wraps a deliveredError, which marks output already handed to the +// caller that a retry would duplicate. +func (c *Client) doWith(ctx context.Context, endpoint string, params url.Values, consume func(*http.Response) error) (*redactor, error) { r := &redactor{secrets: []string{c.apiKey, c.bearerToken}} u, err := c.buildURL(endpoint, params) if err != nil { - return nil, r, err + return r, err } + var refreshed string // token obtained after a 401, used for the rest of the call + refreshTried := false for attempt := 0; ; attempt++ { if err := ctx.Err(); err != nil { - return nil, r, err + return r, err + } + token, err := c.token(ctx, r, refreshed) + if err != nil { + return r, err } - body, header, err := c.send(ctx, endpoint, u, r) + err = c.send(ctx, endpoint, u, token, r, consume) if err == nil { - return body, r, nil + return r, nil } if ctx.Err() != nil { - return nil, r, ctx.Err() + return r, ctx.Err() } var authErr *AuthError - if errors.As(err, &authErr) { - return nil, r, err - } - if attempt >= c.retry.MaxRetries { - return nil, r, err + var tooLarge *ResponseTooLargeError + var delivered *deliveredError + if errors.As(err, &authErr) || errors.As(err, &tooLarge) || errors.As(err, &delivered) { + return r, err } var apiErr *APIError var transportErr *TransportError if errors.As(err, &apiErr) { + if apiErr.StatusCode == http.StatusUnauthorized && !refreshTried { + if refresher, ok := c.tokenSource.(TokenRefresher); ok { + refreshTried = true + fresh, rerr := c.refreshToken(ctx, refresher, token) + if fresh != "" { + r.secrets = append(r.secrets, fresh) + } + if rerr != nil { + return r, &AuthError{r.safe(rerr)} + } + if !validBearerToken(fresh) { + return r, &AuthError{errors.New("token refresher returned an empty or whitespace-containing bearer token")} + } + if fresh != token { + refreshed = fresh + attempt-- // the repeat does not count against the retry budget + continue + } + // The refresher has nothing newer: the API's 401 is the answer. + } + } if !apiErr.Retryable() { - return nil, r, err + return r, err } } else if !errors.As(err, &transportErr) { - return nil, r, err + return r, err + } + if attempt >= c.retry.MaxRetries { + return r, err } delay := c.retryDelay(attempt) - if retryAfter, ok := parseRetryAfter(header.Get("Retry-After"), time.Now()); ok { - if retryAfter > c.retry.MaxDelay { - return nil, r, err - } - if retryAfter > delay { - delay = retryAfter + if apiErr != nil { + if apiErr.RetryAfter > c.retry.MaxDelay { + return r, err } + delay = max(delay, apiErr.RetryAfter) } if err := wait(ctx, delay); err != nil { - return nil, r, err + return r, err } } } -func (c *Client) send(ctx context.Context, endpoint string, u *url.URL, r *redactor) ([]byte, http.Header, error) { - token := c.bearerToken - if c.tokenSource != nil { - var err error - token, err = c.tokenSource.Token(ctx) - if token != "" { - r.secrets = append(r.secrets, token) - } - if err != nil { - return nil, nil, &AuthError{r.safe(err)} - } - if !validBearerToken(token) { - return nil, nil, &AuthError{errors.New("token source returned an empty or whitespace-containing bearer token")} - } +// token returns the bearer token for an attempt: the refreshed one if a 401 +// was answered by TokenRefresher, otherwise the source's current token. It is +// empty for API-key authentication. +func (c *Client) token(ctx context.Context, r *redactor, refreshed string) (string, error) { + if refreshed != "" { + return refreshed, nil + } + if c.tokenSource == nil { + return c.bearerToken, nil } + token, err := c.tokenSource.Token(ctx) + if token != "" { + r.secrets = append(r.secrets, token) + } + if err != nil { + return "", &AuthError{r.safe(err)} + } + if !validBearerToken(token) { + return "", &AuthError{errors.New("token source returned an empty or whitespace-containing bearer token")} + } + return token, nil +} + +// deliveredError wraps a failure that happened after part of the response was +// already written to the caller's destination; such a call is never retried. +type deliveredError struct{ cause error } + +func (e *deliveredError) Error() string { return e.cause.Error() + " (partial output was written)" } +func (e *deliveredError) Unwrap() error { return e.cause } + +func (c *Client) send(ctx context.Context, endpoint string, u *url.URL, token string, r *redactor, consume func(*http.Response) error) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { - return nil, nil, &TransportError{endpoint, r.safe(err)} + return &TransportError{endpoint, r.safe(err)} } req.Header.Set("User-Agent", c.userAgent) - req.Header.Set("Accept", "application/json") + accept := "application/json" + if strings.EqualFold(u.Query().Get("f"), "csv") { + accept = "text/csv" + } + req.Header.Set("Accept", accept) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } else if !c.queryAuth { @@ -212,30 +325,65 @@ func (c *Client) send(ctx context.Context, endpoint string, u *url.URL, r *redac } resp, err := c.httpClient.Do(req) if err != nil { - return nil, nil, &TransportError{endpoint, r.safe(err)} + return &TransportError{endpoint, r.safe(err)} } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - detail := []rune(r.redact(apiErrorDetail(body))) + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody)) + // Drain a bounded remainder so the connection can be reused on retry. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorDrain)) + detail := []rune(r.redact(apiErrorDetail(resp.StatusCode, resp.Header.Get("Content-Type"), body))) if len(detail) > 300 { detail = append(detail[:300], []rune("...")...) } - return nil, resp.Header, &APIError{resp.StatusCode, endpoint, string(detail)} + return &APIError{resp.StatusCode, endpoint, string(detail), serverDelay(resp)} } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, resp.Header, &TransportError{endpoint, r.safe(err)} + if err := consume(resp); err != nil { + var transportErr *TransportError + if errors.As(err, &transportErr) { + transportErr.Err = r.safe(transportErr.Err) + } + return err } - return body, resp.Header, nil + return nil +} + +// readBody reads a response body into memory, enforcing the optional size +// limit through copyLimited. +func readBody(body io.Reader, limit int64) (data []byte, tooLarge bool, err error) { + var buf bytes.Buffer + if _, tooLarge, err = copyLimited(&buf, body, limit); err != nil || tooLarge { + return nil, tooLarge, err + } + return buf.Bytes(), false, nil } -func (c *Client) redact(text string) string { - return (&redactor{secrets: []string{c.apiKey, c.bearerToken}}).redact(text) +// copyLimited copies src to dst, enforcing the optional size limit: at most +// limit bytes reach dst, and a body of exactly limit bytes is accepted. If +// one more byte can be read, it is not written and tooLarge is reported. +// written counts the bytes dst received, including before an error. +func copyLimited(dst io.Writer, src io.Reader, limit int64) (written int64, tooLarge bool, err error) { + if limit <= 0 { + written, err = io.Copy(dst, src) + return written, false, err + } + written, err = io.Copy(dst, io.LimitReader(src, limit)) + if err != nil || written < limit { + return written, false, err + } + var probe [1]byte + n, err := src.Read(probe[:]) + if n > 0 { + return written, true, nil + } + if err != nil && err != io.EOF { + return written, false, err + } + return written, false, nil } -// A redactor belongs to one call, retaining tokens across retries without -// shared mutable state or keeping refreshed credentials on the Client. +// A redactor belongs to one call, retaining the tokens that call used; the +// Client itself keeps only the latest token replacement, under refreshState. type redactor struct{ secrets []string } func (r *redactor) redact(text string) string { @@ -264,7 +412,23 @@ type redactedError struct { func (e *redactedError) Error() string { return e.message } func (e *redactedError) Unwrap() error { return e.cause } -func apiErrorDetail(body []byte) string { +var htmlTitlePattern = regexp.MustCompile(`(?is)\s*(.*?)\s*`) + +// apiErrorDetail extracts a human-readable message from an error body: the +// JSON message or error field, the title of an HTML page (gateways answer 401 +// and 5xx with HTML, which must not end up in error messages) or the text. +func apiErrorDetail(status int, contentType string, body []byte) string { + text := strings.TrimSpace(string(body)) + mediaType, _, _ := mime.ParseMediaType(contentType) + if mediaType == "text/html" || strings.HasPrefix(text, "<") { + if m := htmlTitlePattern.FindStringSubmatch(text); m != nil { + title := strings.TrimSpace(strings.TrimPrefix(html.UnescapeString(m[1]), strconv.Itoa(status))) + if title != "" { + return title + } + } + return http.StatusText(status) + } var response struct { Message string `json:"message"` Error string `json:"error"` @@ -277,7 +441,7 @@ func apiErrorDetail(body []byte) string { return response.Error } } - return strings.TrimSpace(string(body)) + return text } func (c *Client) retryDelay(attempt int) time.Duration { @@ -298,13 +462,36 @@ func (c *Client) retryDelay(attempt int) time.Duration { return time.Duration(rand.Int64N(int64(upper))) } +// serverDelay returns the wait requested by a response. The API reports rate +// limits through x-rate-limit-reset, the seconds until the window resets, and +// may not send Retry-After. +func serverDelay(resp *http.Response) time.Duration { + now := time.Now() + if delay, ok := parseRetryAfter(resp.Header.Get("Retry-After"), now); ok { + return delay + } + if resp.StatusCode == http.StatusTooManyRequests { + if delay, ok := parseRetryAfter(resp.Header.Get("X-Rate-Limit-Reset"), now); ok { + return delay + } + } + return 0 +} + +// epochThreshold separates a number of seconds to wait from a Unix timestamp. +// No server asks a client to wait a year; a larger value is a point in time. +const epochThreshold = 365 * 24 * 60 * 60 + +// parseRetryAfter reads a Retry-After or x-rate-limit-reset value: a number of +// seconds, a Unix timestamp or an HTTP date. The API sends seconds; the +// timestamp form guards against a format change silently disabling retries. func parseRetryAfter(value string, now time.Time) (time.Duration, bool) { if seconds, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil { if seconds < 0 { return 0, false } - if seconds > int64((1<<63-1)/int64(time.Second)) { - return time.Duration(1<<63 - 1), true + if seconds > epochThreshold { + return max(time.Unix(seconds, 0).Sub(now), 0), true } return time.Duration(seconds) * time.Second, true } @@ -332,11 +519,19 @@ func wait(ctx context.Context, delay time.Duration) error { // Get decodes a GET response into dst. Use a non-nil pointer (including // *json.RawMessage). Dynamic values decode as json.Number rather than float64. // Custom UnmarshalJSON methods allow application-specific validation. +// +// The response is read completely, with retries, before anything is decoded, +// so dst is written to at most once, from a successful attempt. A failed +// request or invalid JSON leaves dst untouched; a type mismatch may leave it +// partially filled, as json.Unmarshal does. func (c *Client) Get(ctx context.Context, endpoint string, params url.Values, dst any) error { if dst == nil || reflect.ValueOf(dst).Kind() != reflect.Pointer || reflect.ValueOf(dst).IsNil() { return &InputError{"destination", "must be a non-nil pointer"} } - body, r, err := c.raw(ctx, endpoint, params) + if strings.EqualFold(params.Get("f"), "csv") { + return &InputError{"f", "Get decodes JSON; use GetMetricCSV for CSV"} + } + body, r, err := c.do(ctx, endpoint, params) if err != nil { return err } diff --git a/client_test.go b/client_test.go index be8c052..315aa83 100644 --- a/client_test.go +++ b/client_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "sync" "sync/atomic" @@ -134,6 +135,13 @@ func TestInputRejectedBeforeRequest(t *testing.T) { if _, err := client.GetBulkMetric(context.Background(), "market/price/bulk", nil); err == nil { t.Error("accepted bulk suffix") } + if _, err := client.GetBulkMetric(context.Background(), "market/price", &MetricParams{Assets: []string{"BTC"}}); err == nil { + t.Error("accepted bulk request without Since") + } + // An empty s is rejected by the query validation, not treated as present. + if _, err := client.GetBulkMetric(context.Background(), "market/price", &MetricParams{Extra: url.Values{"s": {""}}}); err == nil { + t.Error("accepted bulk request with an empty s") + } if _, err := client.GetMetricMetadata(context.Background(), "market/price", &MetricParams{Extra: url.Values{"path": {"/other"}}}); err == nil { t.Error("accepted reserved path") } @@ -159,6 +167,37 @@ func TestErrorsAndRedaction(t *testing.T) { } } +func TestHTMLErrorBodiesAreSummarised(t *testing.T) { + page := "401 Authorization Required

401

" + for _, tt := range []struct{ name, contentType, body, want string }{ + {"html with title", "text/html", page, "Authorization Required"}, + {"html without title", "text/html; charset=utf-8", "nope", "Unauthorized"}, + {"title is only the status code", "text/html", "401", "Unauthorized"}, + {"title with entities", "text/html", "Access & Authorization Required — 401", "Access & Authorization Required — 401"}, + {"html without content type", "", " Blocked", "Blocked"}, + {"json message", "application/json", `{"message":"bad key"}`, "bad key"}, + {"plain text", "text/plain", "resource not found", "resource not found"}, + } { + t.Run(tt.name, func(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if tt.contentType != "" { + w.Header().Set("Content-Type", tt.contentType) + } + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, tt.body) + }) + _, err := client.Raw(context.Background(), "/v1/test", nil) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Detail != tt.want { + t.Fatalf("detail=%q err=%v", apiErr.Detail, err) + } + if strings.Contains(err.Error(), "<") || strings.Contains(err.Error(), "test-secret-key") { + t.Fatalf("markup or secret leaked: %v", err) + } + }) + } +} + func TestRetriesAndRetryAfter(t *testing.T) { for _, status := range []int{429, 500, 503, 400, 401, 403, 404} { t.Run(fmt.Sprint(status), func(t *testing.T) { @@ -196,6 +235,59 @@ func TestRetriesAndRetryAfter(t *testing.T) { if _, ok := parseRetryAfter("-1", now); ok { t.Fatal("negative retry delay") } + // A Unix timestamp is a point in time, not a 56-year wait. + epoch := strconv.FormatInt(now.Add(45*time.Second).Unix(), 10) + if delay, ok := parseRetryAfter(epoch, now); !ok || delay != 45*time.Second { + t.Errorf("epoch %s: %v %v", epoch, delay, ok) + } + if delay, ok := parseRetryAfter(strconv.FormatInt(now.Add(-time.Hour).Unix(), 10), now); !ok || delay != 0 { + t.Errorf("past epoch: %v %v", delay, ok) + } +} + +// The API's rate limit works in one-minute windows and reports the seconds +// until the window resets; 60 is the value seen right after a reset. +func TestRateLimitResetHeaderIsSeconds(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Rate-Limit-Limit", "600") + w.Header().Set("X-Rate-Limit-Remaining", "0") + w.Header().Set("X-Rate-Limit-Reset", "60") + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(w, `{"message":"rate limited"}`) + }) + _, err := client.Raw(context.Background(), "/v1/test", nil) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.RetryAfter != 60*time.Second { + t.Fatalf("error %#v", err) + } +} + +func TestRateLimitReset(t *testing.T) { + for _, tt := range []struct { + status int + wantAttempts int32 + wantDelay time.Duration + }{ + {429, 1, 100 * time.Second}, + {503, 3, 0}, + } { + t.Run(fmt.Sprint(tt.status), func(t *testing.T) { + var attempts atomic.Int32 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.Header().Set("X-Rate-Limit-Reset", "100") + w.WriteHeader(tt.status) + }, WithRetryPolicy(RetryPolicy{2, time.Millisecond, time.Second})) + _, err := client.Raw(context.Background(), "/v1/test", nil) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.RetryAfter != tt.wantDelay { + t.Fatalf("error %#v", err) + } + if attempts.Load() != tt.wantAttempts { + t.Errorf("attempts=%d want %d", attempts.Load(), tt.wantAttempts) + } + }) + } } func TestCancellationDuringRequestAndBackoff(t *testing.T) { @@ -277,7 +369,7 @@ func TestConcurrentClientAndQueryIsolation(t *testing.T) { } fmt.Fprint(w, `{"data":[]}`) }) - params := &MetricParams{Assets: []string{"BTC", "ETH"}, Extra: url.Values{"network": {"eth", "sol"}}} + params := &MetricParams{Assets: []string{"BTC", "ETH"}, Since: time.Unix(1700000000, 0), Extra: url.Values{"network": {"eth", "sol"}}} var group sync.WaitGroup for i := 0; i < 20; i++ { group.Add(1) @@ -367,13 +459,10 @@ func TestRetryAfterMinimumWait(t *testing.T) { func TestCredentialRedactionVariants(t *testing.T) { secret := `secret"with spaces&chars` - client, err := NewClient(secret) - if err != nil { - t.Fatal(err) - } + r := &redactor{secrets: []string{secret}} encoded, _ := json.Marshal(secret) for _, text := range []string{secret, url.QueryEscape(secret), url.PathEscape(secret), string(encoded)} { - if got := client.redact(text); strings.Contains(got, "secret") { + if got := r.redact(text); strings.Contains(got, "secret") { t.Errorf("failed redaction: %s", got) } } diff --git a/contract_test.go b/contract_test.go index 3cdabc9..26a841c 100644 --- a/contract_test.go +++ b/contract_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func TestRecordedAPIContracts(t *testing.T) { @@ -48,13 +49,39 @@ func TestRecordedAPIContracts(t *testing.T) { _, err := c.GetTimeSeries(context.Background(), "market/price_usd_close", nil) return err }}, + {"timeseries-active-count-pit-btc.json", func(c *Client) error { + points, err := c.GetTimeSeries(context.Background(), "addresses/active_count_pit", &MetricParams{Asset: "BTC"}) + if err != nil { + return err + } + if points[0].ComputedAt == nil || *points[0].ComputedAt != 1790985731 || *points[0].Value != 729981 { + return fmt.Errorf("lost computed_at: %+v", points[0]) + } + return nil + }}, + {"timeseries-ssr-pit-btc.json", func(c *Client) error { + points, err := c.GetObjectTimeSeries(context.Background(), "indicators/ssr_pit", &MetricParams{Asset: "BTC"}) + if err != nil { + return err + } + if points[0].ComputedAt == nil || *points[0].ComputedAt != 1790985823 || points[0].Object["h"] == nil { + return fmt.Errorf("lost computed_at: %+v", points[0]) + } + return nil + }}, {"timeseries-price-usd-ohlc-btc.json", func(c *Client) error { _, err := c.GetObjectTimeSeries(context.Background(), "market/price_usd_ohlc", nil) return err }}, {"bulk-marketcap-usd.json", func(c *Client) error { - _, err := c.GetBulkMetric(context.Background(), "market/marketcap_usd", nil) - return err + bulk, err := c.GetBulkMetric(context.Background(), "market/marketcap_usd", &MetricParams{Since: time.Unix(1735689600, 0)}) + if err != nil { + return err + } + if eth := bulk[0].Bulk[1]; eth.Asset() != "ETH" || eth.Network() != "eth" || eth.Params["network"] != "eth" { + return fmt.Errorf("lost recorded bulk selectors: %+v", eth) + } + return nil }}, } { t.Run(tt.file, func(t *testing.T) { @@ -82,6 +109,7 @@ func TestMalformedMetadataAndUsage(t *testing.T) { return err }}, {"missing credits", `{"apiAddons":[]}`, func(c *Client) error { _, err := c.GetAPIUsage(context.Background()); return err }}, + {"non-array usage", `[]`, func(c *Client) error { _, err := c.GetAPIUsage(context.Background()); return err }}, } { t.Run(tt.name, func(t *testing.T) { client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(tt.body)) }) diff --git a/doc.go b/doc.go index fbccbed..9dfff6f 100644 --- a/doc.go +++ b/doc.go @@ -1,6 +1,25 @@ -// Package glassnode provides a context-aware client for the Glassnode HTTP API. +// Package glassnode is a client for the Glassnode API +// (https://docs.glassnode.com). // -// Create one Client and reuse it across goroutines. Authentication and HTTP -// behavior are configured explicitly; the package never reads environment -// variables or files. Timestamps in responses are Unix seconds. +// Create a Client with NewClient and reuse it; it is safe for concurrent use. +// Every method takes a context.Context, which controls cancellation and the +// overall deadline of the call, including retries: +// +// client, err := glassnode.NewClient(apiKey) +// if err != nil { +// return err +// } +// points, err := client.GetTimeSeries(ctx, "market/price_usd_close", &glassnode.MetricParams{ +// Asset: "BTC", +// Interval: "24h", +// }) +// +// Timestamps are Unix seconds, and missing values are nil pointers. Methods +// such as GetTimeSeries, GetObjectTimeSeries and GetBulkMetric cover the common +// response shapes; GetMetric, Get and Raw handle everything else. +// +// The package reads no environment variables or files: all configuration, +// including credentials, is passed to NewClient as options. Errors can be +// inspected with errors.As for APIError, InputError, TransportError, +// DecodeError and AuthError. package glassnode diff --git a/docs/design.md b/docs/design.md index 3397b53..0eeeaed 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,121 +1,169 @@ -# Go API client design — GN-159 - -Status: implementation specification. Tracking: https://glassnode.atlassian.net/browse/GN-159 - -Extract the reusable HTTP client and endpoint models from -`glassnode/glassnode-cli/internal/api` into a private, independently versioned -`github.com/glassnode/glassnode-api-go-client` module. The root package is -`glassnode`; it uses only the Go standard library and supports Go 1.24+. - -## Public API and developer experience - -- `NewClient(apiKey, ...Option) (*Client, error)` validates configuration once. - No implicit environment reads, config files, or global state. Authentication - options are mutually exclusive. Explicit timeouts do not depend on option order. -- All calls take `context.Context`. Context deadlines bound the entire call, - including retries. The default HTTP timeout is one minute per attempt. -- `MetricParams` gives common parameters readable Go field names and `time.Time` - ranges. `Extra url.Values` accepts new metric-specific selectors and repeated - values. Caller-owned maps/slices are copied, never mutated. -- `GetTimeSeries` and `GetObjectTimeSeries` return timestamped, nullable numeric - values. `GetMetric(..., dst)` decodes custom metric shapes into caller-owned - structs or `json.RawMessage`. Unix timestamps stay in seconds. Dynamic JSON - decoding uses `json.Number` to preserve precision. -- Bulk calls return a slice of timestamped entries, unwrapping the API's `data` - envelope. Metadata and usage get concrete Go response types. Unknown JSON - fields are tolerated; raw decoding is available when callers need them. -- `Raw(ctx, endpoint, url.Values)` and `Get(ctx, endpoint, url.Values, dst)` are - escape hatches for new GET endpoints. Paths cannot replace the configured host - or add query strings. Query credentials are controlled by the client. -- Errors support `errors.As` for HTTP/input/decode/transport failures and - `errors.Is` for context cancellation/deadlines. Error text and exported - response detail exclude configured credentials. Transport causes are retained - for inspection and may contain credentials: callers must not log unwrapped - transport causes. -- Clients can be reused concurrently. Configure once; do not mutate a supplied - HTTP transport concurrently. `WithHTTPClient` copies the client configuration - and preserves its transport, timeout, cookie jar and instrumentation. - -## Authentication and resilience - -API keys default to the `X-Api-Key` header for server-side Go applications; -`WithAPIKeyInQuery` supports legacy query authentication. `WithBearerToken` -supports an existing OAuth token. `WithTokenSource` calls an application-owned, -concurrent-safe token source -before each attempt, allowing refresh without a placeholder token. Each call -retains its own tokens for error redaction without mutable Client state. Login, -persistence and expiry-aware refresh remain in the token-source implementation. -Token-source failures are `AuthError`s and never retried, even when their cause -is a transport error. HTTP 401 does not trigger refresh or retry. - -Never follow redirects, in either auth mode. A 3xx is an HTTP error: this avoids -forwarding credentials to a different host. User HTTP-client redirect behavior -is deliberately overridden. OAuth token sources are supported; x402 payments -are deferred. - -Default retries: two retries (three attempts) for GET transport/read failures, -429 and 5xx responses. Exponential backoff with full jitter, one-second base, -30-second cap. Honor `Retry-After` seconds or HTTP dates as a minimum wait; if it -exceeds the configured cap, return the HTTP error rather than retry early. -Caller cancellation never retries and also interrupts retry waits. Applications should supply usable OAuth tokens before the request; 401 responses -are returned immediately. - -Input validation rejects malformed paths, empty repeated values, overridden -credentials, non-JSON formats and invalid time ranges. Decode failures never -retry. The SDK does not cache results or add a global rate limiter. - -## TypeScript capability map - -| TS feature | Go implementation | +# Design notes + +This document explains the main decisions behind the client and what it +deliberately leaves out. For usage, see the [README](../README.md). + +The client was extracted from the HTTP layer of +[`glassnode-cli`](https://github.com/glassnode/glassnode-cli) and aligned with the +features of the TypeScript client, +[`glassnode-api-ts-client`](https://github.com/glassnode/glassnode-api-ts-client). + +## Goals + +- **Standard library only.** The client is a single package with no third-party + dependencies. It is easy to vendor and audit, and it never forces a version of + another library on its users. +- **Explicit configuration.** Everything is passed to `NewClient`. The package + never reads environment variables, config files or global state, so two + clients in one process never affect each other. +- **Immutable, shareable client.** Options are validated once in `NewClient`. + After that the `Client` has no mutable state except a lock-protected record + of the last token replacement, so it can be shared between goroutines. +- **Typed where the API is stable, open where it is not.** Common response + shapes have typed methods. Everything else goes through `GetMetric`, `Get` + or `Raw`, so new metrics and endpoints work without a client release. + +## Requests and parameters + +`MetricParams` names the common query parameters (`a`, `e`, `s`, `u`, `i`, `c`) +and uses `time.Time` for the time range. Less common parameters go in `Extra`, +a `url.Values`, because the API adds metric-specific parameters regularly. +Setting the same parameter in both places is rejected rather than resolved +silently. + +Lists are sent as repeated parameters (`a=BTC&a=ETH`), which is the form the +API expects for bulk requests. The client copies every map and slice it +receives, so callers can reuse their parameters. + +Paths are validated against a strict pattern. A path cannot contain a scheme, +host, query string or `..`, so it can never point a request, and the +credentials attached to it, at a different server. + +## Responses + +- **Nullable values are pointers.** The API returns `null` for missing data. A + `*float64` keeps that distinct from a real zero. +- **Timestamps stay as Unix seconds** (`int64`), as the API returns them. The + client does not convert them to `time.Time`, so no time zone or precision + choice is hidden in it. +- **Numbers in untyped JSON are `json.Number`.** Decoding into `any` would + otherwise turn large integers into imprecise `float64` values. +- **Unknown fields are ignored**, so new fields in API responses do not break + existing code. Required fields such as `t` and `v` are checked, and a missing + one produces a `DecodeError` instead of a silent zero. +- **CSV is passed through, not parsed.** `GetMetricCSV` copies the API's CSV + to a writer as it arrives; the client adds no columns and no parsing, so + large downloads need no memory and the file is exactly what the API sent. +- **Bulk responses are unwrapped.** `GetBulkMetric` returns the contents of the + `data` envelope directly. Each entry keeps all of its selectors in `Params`, + because they depend on the metric (`e`, `network`, `category`, ...) and are + the only way to tell entries apart. Bulk requests without `Since` are + rejected before sending, since the API requires it. + +## Authentication + +API keys are sent in the `X-Api-Key` header by default. Query authentication +(`api_key=...`) is still supported, but it puts the key in URLs, which end up +in proxy and access logs. + +OAuth access tokens can be supplied as a fixed token or through a +`TokenSource`. The token source is called before every HTTP attempt, so a +refreshed token is picked up on retries. A source that also implements +`TokenRefresher` is asked for a new token once when the API answers `401`, and +the request is repeated with it; this covers a token revoked before its +expiry without the client knowing how tokens are obtained. The client does not +perform the OAuth login or store tokens: how a session is obtained and kept is +specific to each application. `glassnode-cli`, for example, keeps its own login +and refresh logic and exposes it through a `TokenRefresher`. + +Exactly one authentication method must be configured. Accepting several and +picking one would hide configuration mistakes. + +## Retries + +Only GET requests exist, so every request is idempotent and safe to retry. +Retries happen after network errors, `429` and `5xx` responses, with two +retries by default. + +The delay uses exponential backoff with full jitter: a random duration between +zero and an upper bound that starts at `BaseDelay` and doubles with each retry, +up to `MaxDelay`. Jitter keeps many clients +from retrying at the same moment after an outage. + +The default cap is 65 seconds because the API's rate limit works in one-minute +windows: a 429 at the start of a window asks for a wait of up to 60 seconds, +and a 30-second cap would have returned the error without retrying at all. + +`Retry-After` (seconds or an HTTP date) is treated as a minimum delay. An +integer above one year in either header is read as a Unix timestamp, so a +change of format on the server would not silently turn every `429` into an +immediate failure. The API +reports rate limits per minute through `x-rate-limit-reset`, so a `429` without +`Retry-After` uses that header instead; retrying a rate-limited request after +a second would only fail again. If the delay exceeds `MaxDelay`, the client +returns the error rather than retrying early or blocking for longer than the +caller configured. The delay is exposed as `APIError.RetryAfter`. + +The response body is read completely inside the retry loop and decoded once, +after it, from the first successful attempt. Decoding inside the loop would +let a failed attempt leave values in the caller's destination: `encoding/json` +merges into existing maps and keeps struct fields that the input lacks. +stripe-go and go-github buffer for the same reason, and aws-sdk-go-v2 +deserialises every attempt into a fresh output value. + +The per-attempt timeout comes from the HTTP client. The total duration of a +call is controlled by the context, which also interrupts the waits between +retries. + +## Security + +- **No redirects.** Redirects are disabled even on a user-supplied + `http.Client`, because a redirect would resend the credentials to whatever + host it points at. A `3xx` response is returned as an `APIError`. +- **Redacted errors.** Error messages and `APIError.Detail` have the API key and + every token used in the call removed, including tokens from earlier retry + attempts. The original errors are kept for `errors.Is` and `errors.As`, so + callers should not log unwrapped causes. +- **Validated credentials.** Credentials with control characters or surrounding + whitespace are rejected before any request, so they cannot inject headers. + +## Out of scope + +- OAuth login flows and token storage. +- [x402](https://www.x402.org) payments, which the TypeScript client supports. +- Response size limits by default. Public API clients such as go-github, + stripe-go and slack-go do not impose one, because the server decides how + much a request returns. `WithMaxResponseBytes` is available for callers + that want a hard bound. +- Response caching and client-side rate limiting. Applications that need them + can add them in a custom `http.RoundTripper` or around the client. +- Reading configuration from the environment or files. + +## Compared with the TypeScript client + +| TypeScript client | Go client | | --- | --- | -| Asset metadata and metric list | `ListAssets`, `ListMetrics` | -| Metric metadata and lag stats | `GetMetricMetadata`, `GetMetricStats` | -| Generic metric + schema | `GetMetric` with a caller-owned Go destination; optional custom `UnmarshalJSON` | -| Scalar/object series | `GetTimeSeries`, `GetObjectTimeSeries`, nullable numbers | -| Bulk metric | `GetBulkMetric`, repeated query values and typed entries | -| Metadata tags/categories/blockchains | Named list methods and `ListNames` (from CLI) | -| API usage | `GetAPIUsage` (from CLI) | -| Dates and repeated parameters | `time.Time`, slices and `url.Values` | -| Fetch injection and observability hooks | `WithHTTPClient`, custom `http.RoundTripper` | -| AbortSignal and per-call timeout | `context.Context` deadline/cancellation | -| Error classes | Concrete errors, `errors.As` / `errors.Is` | -| Retries, jitter, Retry-After | Configurable `RetryPolicy` | -| Header/query credentials | Header default, explicit query option | -| Browser bundles, Zod runtime DSL | Not relevant to Go | -| OAuth refresh | `WithTokenSource`, application-owned refresh and storage | -| x402 signed payments | Deferred | - -## CLI migration - -Keep the existing `internal/api` entry point as a small CLI adapter, retaining -command signatures and output types. Remove its HTTP/decode implementation and -delegate to the pinned SDK module. Keep environment/config resolution, OAuth -refresh transport, dry-run URL rendering, asset pruning and usage presentation -inside the CLI. No committed filesystem `replace` directive; use a temporary -workspace for local cross-module testing. - -Because the SDK is private, CLI builds require GitHub read access and -`GOPRIVATE=github.com/glassnode/glassnode-api-go-client`. Document this explicitly. -Public CLI CI cannot fetch a private module without an authorized read token; -configure its workflow to use a repository secret, without exposing it in logs -or to fork PRs. Publication of the Go SDK remains a separate decision. - -## Verification - -Use `httptest` for wire contracts and fixtures inherited from the CLI. Cover -nullable and custom metric data, precision, typed HTTP errors, redaction, -malformed responses, endpoint/query validation, redirects, retry counts and -Retry-After, cancellation in flight and during backoff, timeout behavior and -concurrent use. Run `go test -race ./...` and `go vet ./...` in both modules. -Examples must compile. Verify private visibility and pushed branches remotely. - -## Module packaging - -The library is the root module with one public package, `glassnode`. It has no -third-party runtime dependencies. Root semantic-version tags version the SDK; -commits resolve to pseudo-versions before a release is tagged. - -`examples/go.mod` defines a separate, unpublished module. Go module archives -exclude nested modules, keeping executable examples and their setup out of the -client distributable. A relative replacement targets the local SDK checkout; -CI explicitly vets and builds examples in addition to testing the root module. +| Asset metadata, metric list | `ListAssets`, `ListMetrics` | +| Metric metadata, lag statistics | `GetMetricMetadata`, `GetMetricStats` | +| Generic metric with a Zod schema | `GetMetric` with a Go type and, optionally, `UnmarshalJSON` | +| Scalar and object series | `GetTimeSeries`, `GetObjectTimeSeries` | +| Bulk metrics | `GetBulkMetric` | +| Metadata tags, categories, blockchains | `ListMetricTags`, `ListAssetTags`, `ListAssetCategories`, `ListAssetBlockchains` | +| Custom `fetch`, observability hooks | `WithHTTPClient` with a custom `http.RoundTripper` | +| `AbortSignal`, per-call timeout | `context.Context` | +| Error classes | Error types for `errors.As` | +| Retries with jitter and `Retry-After` | `RetryPolicy` | +| OAuth token refresh | `WithTokenSource` | +| x402 payments | Not supported | + +`GetAPIUsage`, `ListExchanges`, `ListNetworks`, `ListMiners` and the generic +`ListNames` have no TypeScript counterpart. + +## Packaging + +The module root is the library. Releases are Git tags such as `v0.1.0`. + +`examples/` is a separate Go module with its own `go.mod`. Go leaves nested +modules out of the module archive, so users who install the client do not +download the examples. The examples module uses a relative `replace` directive +to build against the local checkout. CI lints and builds it separately. diff --git a/errors.go b/errors.go index a221f34..de5d207 100644 --- a/errors.go +++ b/errors.go @@ -1,12 +1,20 @@ package glassnode -import "fmt" +import ( + "errors" + "fmt" + "time" +) // APIError describes a non-2xx response. Detail is bounded and credential-redacted. +// RetryAfter is how long the server asked the client to wait, taken from the +// Retry-After header or, for 429 responses, from x-rate-limit-reset. It is zero +// when the server gave no such hint. type APIError struct { StatusCode int Endpoint string Detail string + RetryAfter time.Duration } func (e *APIError) Error() string { @@ -22,6 +30,25 @@ func (e *APIError) Retryable() bool { return e.StatusCode == 429 || e.StatusCode >= 500 && e.StatusCode <= 599 } +// ErrResponseTooLarge is the cause of every ResponseTooLargeError, for +// errors.Is. +var ErrResponseTooLarge = errors.New("response exceeds the configured size limit") + +// ResponseTooLargeError reports a successful response whose body exceeds the +// WithMaxResponseBytes limit. It is a decision of the client's configuration, +// not a transport or decoding failure, and the call is not retried. +type ResponseTooLargeError struct { + Endpoint string + Limit int64 +} + +func (e *ResponseTooLargeError) Error() string { + return fmt.Sprintf("glassnode: %s: response exceeds the configured limit of %d bytes", e.Endpoint, e.Limit) +} + +// Unwrap returns ErrResponseTooLarge. +func (e *ResponseTooLargeError) Unwrap() error { return ErrResponseTooLarge } + // InputError describes invalid configuration or arguments, before any request. type InputError struct{ Field, Message string } diff --git a/examples/README.md b/examples/README.md index 35dd927..4ba9bb7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,49 +1,61 @@ -# Runnable Go SDK examples +# Examples -Requires Go 1.24+ and a repository checkout. All programs use the local SDK via -`replace ... => ..`; there is no need to download the private module. +Small programs that call the Glassnode API with this client. They build against +the client in this repository checkout, so you do not need to install the +module first. ```sh cd examples -export GLASSNODE_API_KEY='your-api-key' +export GLASSNODE_API_KEY=your-api-key go run ./price ``` -Set exactly one of `GLASSNODE_API_KEY` or `GLASSNODE_ACCESS_TOKEN`. The latter -accepts an existing OAuth bearer token; these programs do not perform login or -refresh. Credentials are read only by example setup, never implicitly by the SDK. -Requests use your account's API credits and require access to the selected metric. +| Program | What it shows | +| --- | --- | +| `price` | Daily BTC closing prices with `GetTimeSeries`, including gaps | +| `ohlc` | Daily OHLC prices with `GetObjectTimeSeries` | +| `bulk` | Market cap of BTC and ETH in one request with `GetBulkMetric` | +| `metadata` | Metadata of the closing-price metric with `GetMetricMetadata` | +| `usage` | Credits used and the monthly allowance with `GetAPIUsage` | +| `custom` | Decoding into your own types with `GetMetric`, keeping full numeric precision | -| Command | Demonstrates | Output | -| --- | --- | --- | -| `go run ./price` | Nullable scalar BTC closing prices | Date and USD price, including gaps | -| `go run ./ohlc` | Object time series | OHLC points as JSON | -| `go run ./bulk` | Repeated asset selectors for BTC and ETH | Unwrapped bulk market-cap points as JSON | -| `go run ./metadata` | Typed metric metadata | Closing-price metadata as JSON | -| `go run ./usage` | Account credit usage | Used credits and monthly allowance | -| `go run ./custom` | Caller-owned types and `json.Number` | Prices as JSON with numeric precision preserved | +Every request uses API credits from your account, and the metrics must be +included in your plan. + +## Options -The metric examples default to 30 days of daily data. Change the asset, range or -whole-operation deadline with flags: +All programs accept the same flags: + +| Flag | Default | Used by | +| --- | --- | --- | +| `-asset` | `BTC` | `price`, `ohlc`, `metadata`, `custom` | +| `-days` | `30` | `price`, `ohlc`, `bulk`, `custom` | +| `-timeout` | `30s` | all | ```sh -go run ./price -asset ETH -days 7 -timeout 15s -go run ./price -h +go run ./price -asset ETH -days 7 ``` -Bulk always selects BTC and ETH. Metadata uses only the asset flag; usage ignores -asset and days. Shared [setup](internal/example/example.go) handles configuration -and errors so each program focuses on its SDK operation. +Credentials and the API address come from environment variables: + +| Variable | Purpose | +| --- | --- | +| `GLASSNODE_API_KEY` | Your API key | +| `GLASSNODE_BASE_URL` | A different API address, for example a local mock server | -`GLASSNODE_BASE_URL` can point at a local test server. Only use a server you trust: -the example sends your configured credential there. No production requests are -needed to compile these programs: +The API key is sent to `GLASSNODE_BASE_URL` when it is set, so only point it +at a server you trust. + +The shared setup code is in [internal/example](internal/example/example.go). + +## How this directory is built + +The directory is its own Go module. Its `go.mod` uses a `replace` directive to +build against the client in the parent directory, and Go leaves it out of the +client's module archive. `go test ./...` in the repository root does not +include it, so check it separately: ```sh -go vet ./... go build ./... +golangci-lint run ./... ``` - -This directory is a nested Go module, so Go excludes it from the parent SDK's -module archive. Root `go test ./...` does not traverse it; CI vets and builds this -module separately. These programs are repository examples, not a published module. diff --git a/examples/internal/example/example.go b/examples/internal/example/example.go index 2c733d6..7e6a51b 100644 --- a/examples/internal/example/example.go +++ b/examples/internal/example/example.go @@ -22,15 +22,12 @@ func Run(run func(context.Context, *glassnode.Client, *glassnode.MetricParams) e fmt.Fprintln(os.Stderr, "days and timeout must be positive") os.Exit(2) } - key, token := os.Getenv("GLASSNODE_API_KEY"), os.Getenv("GLASSNODE_ACCESS_TOKEN") - if (key == "") == (token == "") { - fmt.Fprintln(os.Stderr, "set exactly one of GLASSNODE_API_KEY or GLASSNODE_ACCESS_TOKEN") + key := os.Getenv("GLASSNODE_API_KEY") + if key == "" { + fmt.Fprintln(os.Stderr, "set GLASSNODE_API_KEY") os.Exit(2) } var options []glassnode.Option - if token != "" { - options = append(options, glassnode.WithBearerToken(token)) - } if baseURL := os.Getenv("GLASSNODE_BASE_URL"); baseURL != "" { options = append(options, glassnode.WithBaseURL(baseURL)) } diff --git a/metadata.go b/metadata.go index 7c43c11..3a76cc4 100644 --- a/metadata.go +++ b/metadata.go @@ -149,3 +149,18 @@ func (c *Client) ListAssetCategories(ctx context.Context, filter string) ([]stri func (c *Client) ListAssetBlockchains(ctx context.Context, filter string) ([]string, error) { return c.ListNames(ctx, "/v1/metadata/assets/blockchains", filter) } + +// ListExchanges lists the supported exchanges. +func (c *Client) ListExchanges(ctx context.Context) ([]string, error) { + return c.ListNames(ctx, "/v1/metadata/exchanges", "") +} + +// ListNetworks lists the supported networks. +func (c *Client) ListNetworks(ctx context.Context) ([]string, error) { + return c.ListNames(ctx, "/v1/metadata/networks", "") +} + +// ListMiners lists the supported miners. +func (c *Client) ListMiners(ctx context.Context) ([]string, error) { + return c.ListNames(ctx, "/v1/metadata/miners", "") +} diff --git a/metadata_test.go b/metadata_test.go index 1451953..074e715 100644 --- a/metadata_test.go +++ b/metadata_test.go @@ -2,11 +2,13 @@ package glassnode import ( "context" + "encoding/json" "fmt" "net/http" "net/url" "os" "testing" + "time" ) func TestExtractedEndpointFixtures(t *testing.T) { @@ -50,8 +52,8 @@ func TestExtractedEndpointFixtures(t *testing.T) { if err != nil || len(series) != 3 { t.Fatalf("series %v %v", series, err) } - bulk, err := client.GetBulkMetric(ctx, "market/marketcap_usd", &MetricParams{Assets: []string{"BTC", "ETH"}}) - if err != nil || len(bulk) != 31 || bulk[0].Bulk[0].Asset != "BTC" { + bulk, err := client.GetBulkMetric(ctx, "market/marketcap_usd", &MetricParams{Assets: []string{"BTC", "ETH"}, Since: time.Unix(1770076800, 0)}) + if err != nil || len(bulk) != 31 || bulk[0].Bulk[0].Asset() != "BTC" { t.Fatalf("bulk %v %v", bulk, err) } usage, err := client.GetAPIUsage(ctx) @@ -69,6 +71,8 @@ func TestExtractedEndpointFixtures(t *testing.T) { for _, call := range []func() ([]string, error){ func() ([]string, error) { return client.ListMetricTags(ctx) }, func() ([]string, error) { return client.ListAssetTags(ctx, "") }, func() ([]string, error) { return client.ListAssetCategories(ctx, "") }, func() ([]string, error) { return client.ListAssetBlockchains(ctx, "") }, + func() ([]string, error) { return client.ListExchanges(ctx) }, func() ([]string, error) { return client.ListNetworks(ctx) }, + func() ([]string, error) { return client.ListMiners(ctx) }, } { names, err := call() if err != nil || len(names) != 2 || names[0] != "one" { @@ -77,6 +81,30 @@ func TestExtractedEndpointFixtures(t *testing.T) { } } +func TestUsageWithoutAddons(t *testing.T) { + for _, body := range []string{`{"creditsUsed":6,"apiAddons":null}`, `{"creditsUsed":6}`} { + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) }) + usage, err := client.GetAPIUsage(context.Background()) + if err != nil || usage.CreditsUsed != 6 || usage.CreditsPerMonth() != 0 || usage.APIAddons == nil { + t.Fatalf("%s: %+v %v", body, usage, err) + } + } +} + +func TestTimeSeriesPointWithoutComputedAt(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[{"t":1,"v":2},{"t":3,"v":4,"computed_at":5}]`) + }) + points, err := client.GetTimeSeries(context.Background(), "market/price", nil) + if err != nil || points[0].ComputedAt != nil || points[1].ComputedAt == nil || *points[1].ComputedAt != 5 { + t.Fatalf("points %+v %v", points, err) + } + encoded, _ := json.Marshal(points[0]) + if string(encoded) != `{"t":1,"v":2}` { + t.Errorf("omitempty lost: %s", encoded) + } +} + func TestObjectAndNullableBulk(t *testing.T) { client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/metrics/market/ohlc" { @@ -89,12 +117,61 @@ func TestObjectAndNullableBulk(t *testing.T) { if err != nil || len(points) != 1 || points[0].Object["c"] != nil || *points[0].Object["new"] != 15 { t.Fatalf("object %v %v", points, err) } - bulk, err := client.GetBulkMetric(context.Background(), "market/price", nil) - if err != nil || bulk[0].Bulk[0].Value != nil || bulk[0].Bulk[1].Network != "eth" { + bulk, err := client.GetBulkMetric(context.Background(), "market/price", &MetricParams{Since: time.Unix(1, 0)}) + if err != nil || bulk[0].Bulk[0].Value != nil || bulk[0].Bulk[1].Network() != "eth" { t.Fatalf("bulk %v %v", bulk, err) } } +func TestBulkEntryKeepsSelectors(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":[{"t":1,"bulk":[ + {"a":"BTC","e":"binance","v":12}, + {"a":"BTC","e":"bitfinex","v":123}, + {"a":"ETH","category":"more_10y","v":null}]}]}`) + }) + bulk, err := client.GetBulkMetric(context.Background(), "distribution/balance_exchanges", &MetricParams{Since: time.Unix(1, 0)}) + if err != nil { + t.Fatal(err) + } + entries := bulk[0].Bulk + if entries[0].Params["e"] != "binance" || entries[1].Params["e"] != "bitfinex" || entries[1].Asset() != "BTC" { + t.Errorf("exchange selectors lost: %+v", entries) + } + if entries[2].Params["category"] != "more_10y" || entries[2].Value != nil { + t.Errorf("category lost: %+v", entries[2]) + } + encoded, err := json.Marshal(entries[0]) + if err != nil || string(encoded) != `{"a":"BTC","e":"binance","v":12}` { + t.Errorf("marshal %s %v", encoded, err) + } + var missingValue BulkEntry + if err := json.Unmarshal([]byte(`{"a":"BTC"}`), &missingValue); err == nil { + t.Error("accepted bulk entry without v") + } + // A non-string selector is kept as its JSON text and written back as a string. + var numeric BulkEntry + if err := json.Unmarshal([]byte(`{"a":"BTC","bps":5,"v":1}`), &numeric); err != nil || numeric.Params["bps"] != "5" { + t.Fatalf("numeric selector: %+v %v", numeric, err) + } + if encoded, _ := json.Marshal(numeric); string(encoded) != `{"a":"BTC","bps":"5","v":1}` { + t.Errorf("numeric selector marshal: %s", encoded) + } +} + +func TestParametersDefaultsAcceptsStrings(t *testing.T) { + var meta MetricMetadata + if err := json.Unmarshal([]byte(`{"path":"/p","parameters":{},"parameters_defaults":{"e":"aggregated","network":["eth"]}}`), &meta); err != nil { + t.Fatal(err) + } + if got := meta.ParametersDefaults; len(got["e"]) != 1 || got["e"][0] != "aggregated" || got["network"][0] != "eth" || meta.Path != "/p" { + t.Fatalf("defaults %v", meta) + } + if err := json.Unmarshal([]byte(`{"parameters":{},"parameters_defaults":{"e":1}}`), &meta); err == nil { + t.Error("accepted numeric default") + } +} + func TestMetadataFilterAndRepeatedValues(t *testing.T) { client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/v1/metadata/assets" { diff --git a/metadata_types.go b/metadata_types.go index 6461082..5c658ac 100644 --- a/metadata_types.go +++ b/metadata_types.go @@ -1,5 +1,10 @@ package glassnode +import ( + "encoding/json" + "fmt" +) + // Asset describes a supported blockchain asset or token. type Asset struct { ID string `json:"id"` @@ -53,6 +58,37 @@ type MetricMetadata struct { Descriptors *MetricDescriptors `json:"descriptors,omitempty"` } +// UnmarshalJSON accepts each parameters_defaults value either as a list, as the +// API currently returns it, or as a single string, as the API documentation +// describes it. +func (m *MetricMetadata) UnmarshalJSON(data []byte) error { + type plain MetricMetadata + var wire struct { + *plain + ParametersDefaults map[string]json.RawMessage `json:"parameters_defaults"` + } + wire.plain = (*plain)(m) + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + m.ParametersDefaults = nil + for key, raw := range wire.ParametersDefaults { + var values []string + if err := json.Unmarshal(raw, &values); err != nil { + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return fmt.Errorf("parameters_defaults %q: expected a string or a list of strings", key) + } + values = []string{value} + } + if m.ParametersDefaults == nil { + m.ParametersDefaults = make(map[string][]string, len(wire.ParametersDefaults)) + } + m.ParametersDefaults[key] = values + } + return nil +} + // MetricVariant links the base, bulk and point-in-time metric paths. type MetricVariant struct { Base *string `json:"base,omitempty"` diff --git a/metrics.go b/metrics.go index dbf6f1b..90f2043 100644 --- a/metrics.go +++ b/metrics.go @@ -4,21 +4,28 @@ import ( "context" "encoding/json" "errors" + "fmt" + "io" + "mime" + "net/http" "strings" ) // TimeSeriesPoint is a scalar metric point. Value is nil for a gap (JSON null). -// Timestamp is Unix seconds, not milliseconds. +// Timestamp is Unix seconds, not milliseconds. ComputedAt is the Unix time at +// which a point-in-time metric computed the value; it is nil for other metrics. type TimeSeriesPoint struct { - Timestamp int64 `json:"t"` - Value *float64 `json:"v"` + Timestamp int64 `json:"t"` + Value *float64 `json:"v"` + ComputedAt *int64 `json:"computed_at,omitempty"` } // UnmarshalJSON requires the timestamp and scalar value, tolerating new fields. func (p *TimeSeriesPoint) UnmarshalJSON(data []byte) error { var wire struct { - Timestamp *int64 `json:"t"` - Value json.RawMessage `json:"v"` + Timestamp *int64 `json:"t"` + Value json.RawMessage `json:"v"` + ComputedAt *int64 `json:"computed_at"` } if err := json.Unmarshal(data, &wire); err != nil { return err @@ -32,20 +39,25 @@ func (p *TimeSeriesPoint) UnmarshalJSON(data []byte) error { } p.Timestamp = *wire.Timestamp p.Value = value + p.ComputedAt = wire.ComputedAt return nil } // ObjectTimeSeriesPoint is an object metric point (OHLC or a breakdown). // Object values are nullable; nested shapes can be decoded with GetMetric. +// ComputedAt is set for point-in-time metrics, as in TimeSeriesPoint. type ObjectTimeSeriesPoint struct { - Timestamp int64 `json:"t"` - Object map[string]*float64 `json:"o"` + Timestamp int64 `json:"t"` + Object map[string]*float64 `json:"o"` + ComputedAt *int64 `json:"computed_at,omitempty"` } +// UnmarshalJSON requires the timestamp and object, tolerating new fields. func (p *ObjectTimeSeriesPoint) UnmarshalJSON(data []byte) error { var wire struct { - Timestamp *int64 `json:"t"` - Object map[string]*float64 `json:"o"` + Timestamp *int64 `json:"t"` + Object map[string]*float64 `json:"o"` + ComputedAt *int64 `json:"computed_at"` } if err := json.Unmarshal(data, &wire); err != nil { return err @@ -55,44 +67,77 @@ func (p *ObjectTimeSeriesPoint) UnmarshalJSON(data []byte) error { } p.Timestamp = *wire.Timestamp p.Object = wire.Object + p.ComputedAt = wire.ComputedAt return nil } -// BulkEntry is one asset's nullable scalar value, optionally scoped to a network. +// BulkEntry is one value of a bulk response. Params holds every selector that +// identifies the entry, such as "a", "e", "network" or, for object metrics, +// "category"; which selectors appear depends on the metric. Asset and Network +// read the common ones. +// +// Selector values are strings in the API. Should a selector arrive as another +// JSON type, Params holds its JSON text (5 becomes "5"); use GetMetric with +// your own type when exact types matter. type BulkEntry struct { - Asset string `json:"a"` - Value *float64 `json:"v"` - Network string `json:"network,omitempty"` + Value *float64 + Params map[string]string } +// Asset returns the "a" selector, or "" for a metric without one. +func (p BulkEntry) Asset() string { return p.Params["a"] } + +// Network returns the "network" selector, or "" when the entry has none. +func (p BulkEntry) Network() string { return p.Params["network"] } + +// UnmarshalJSON requires v and keeps every other field as a selector. func (p *BulkEntry) UnmarshalJSON(data []byte) error { - var wire struct { - Asset string `json:"a"` - Value json.RawMessage `json:"v"` - Network string `json:"network"` - } - if err := json.Unmarshal(data, &wire); err != nil { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { return err } - if wire.Asset == "" || len(wire.Value) == 0 { - return errors.New("bulk entry requires a and v") + raw, ok := fields["v"] + if !ok { + return errors.New("bulk entry requires v") } var value *float64 - if err := json.Unmarshal(wire.Value, &value); err != nil { + if err := json.Unmarshal(raw, &value); err != nil { return err } - p.Asset = wire.Asset - p.Value = value - p.Network = wire.Network + params := make(map[string]string, len(fields)-1) + for key, raw := range fields { + if key == "v" { + continue + } + var param string + if err := json.Unmarshal(raw, ¶m); err != nil { + param = string(raw) + } + params[key] = param + } + *p = BulkEntry{Value: value, Params: params} return nil } +// MarshalJSON encodes the entry with the selectors next to "v", as the API +// does. Every selector is written as a JSON string, since Params cannot tell a +// numeric selector from an asset such as "1". +func (p BulkEntry) MarshalJSON() ([]byte, error) { + fields := make(map[string]any, len(p.Params)+1) + for key, param := range p.Params { + fields[key] = param + } + fields["v"] = p.Value + return json.Marshal(fields) +} + // BulkDataPoint groups asset values at a Unix timestamp in seconds. type BulkDataPoint struct { Timestamp int64 `json:"t"` Bulk []BulkEntry `json:"bulk"` } +// UnmarshalJSON requires the timestamp and bulk entries. func (p *BulkDataPoint) UnmarshalJSON(data []byte) error { var wire struct { Timestamp *int64 `json:"t"` @@ -143,8 +188,11 @@ func (c *Client) GetObjectTimeSeries(ctx context.Context, path string, params *M return points, nil } -// GetBulkMetric fetches values for several assets and unwraps the data envelope. -// Pass the base metric path, without a /bulk suffix. +// GetBulkMetric fetches the bulk variant of a metric and unwraps the data +// envelope. Pass the base metric path, without a /bulk suffix. The API requires +// Since and limits the time range per request (31 days at 24h resolution). +// Each asset and selector combination is billed like a separate request, and +// omitting Assets selects every asset, so set the selectors explicitly. func (c *Client) GetBulkMetric(ctx context.Context, path string, params *MetricParams) ([]BulkDataPoint, error) { path, q, err := metricQuery(path, params, false) if err != nil { @@ -153,6 +201,9 @@ func (c *Client) GetBulkMetric(ctx context.Context, path string, params *MetricP if strings.HasSuffix(path, "/bulk") { return nil, &InputError{"metric path", "pass the base path without /bulk"} } + if !q.Has("s") { + return nil, &InputError{"s", "bulk metrics require Since"} + } endpoint := "/v1/metrics" + path + "/bulk" var wire struct { Data []BulkDataPoint `json:"data"` @@ -165,3 +216,69 @@ func (c *Client) GetBulkMetric(ctx context.Context, path string, params *MetricP } return wire.Data, nil } + +// recordingWriter remembers the first error of the wrapped writer, so a +// failure of the caller's destination can be told from a read failure. +type recordingWriter struct { + w io.Writer + err error +} + +func (r *recordingWriter) Write(p []byte) (int, error) { + n, err := r.w.Write(p) + if err != nil && r.err == nil { + r.err = err + } + return n, err +} + +// GetMetricCSV requests a metric in CSV and copies the response to w as it +// arrives, without decoding it. The columns are the API's: timestamp and +// value, the object keys for object metrics, plus computed_at for +// point-in-time metrics. Bulk metrics are not available in CSV. +// +// Failures before any byte reaches w (transport errors, 429, 5xx, a connection +// lost right after the headers) are retried as usual. A failure after output +// has been written is not, since a retry would duplicate it; neither is a +// body over WithMaxResponseBytes. An error from w itself is returned wrapped, +// so errors.Is against it works, and is never retried. +// +// The per-attempt timeout (one minute by default) covers reading the whole +// body. For a large download over a slow link, raise it with WithTimeout or +// disable it with WithTimeout(0) and bound the call with a context deadline; +// a timeout in mid-body is reported as a TransportError noting that partial +// output was written. +func (c *Client) GetMetricCSV(ctx context.Context, path string, params *MetricParams, w io.Writer) error { + if w == nil { + return &InputError{"writer", "must not be nil"} + } + path, q, err := metricQueryFormat(path, params, false, "csv") + if err != nil { + return err + } + if strings.HasSuffix(path, "/bulk") { + return &InputError{"metric path", "bulk metrics are not available in CSV"} + } + endpoint := "/v1/metrics" + path + _, err = c.doWith(ctx, endpoint, q, func(resp *http.Response) error { + mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if mediaType != "text/csv" { + return &DecodeError{endpoint, fmt.Errorf("expected a text/csv response, got %q", resp.Header.Get("Content-Type"))} + } + dst := &recordingWriter{w: w} + written, tooLarge, err := copyLimited(dst, resp.Body, c.maxBytes) + switch { + case dst.err != nil: + // The caller's writer failed: their error, not a transport one. + return fmt.Errorf("glassnode: writing %s response: %w", endpoint, dst.err) + case err != nil && written > 0: + return &TransportError{endpoint, &deliveredError{err}} + case err != nil: + return &TransportError{endpoint, err} // nothing was written; retried like any read failure + case tooLarge: + return &ResponseTooLargeError{endpoint, c.maxBytes} + } + return nil + }) + return err +} diff --git a/options.go b/options.go index 94cf7ef..a0c2dcf 100644 --- a/options.go +++ b/options.go @@ -6,6 +6,7 @@ import ( "reflect" "strings" "time" + "unicode" ) // DefaultBaseURL is the standard Glassnode API endpoint. @@ -80,7 +81,8 @@ func WithBearerToken(token string) Option { } // WithRetryPolicy replaces the default two retries with full-jitter backoff -// from one second to thirty seconds. Delays must be positive when enabled. +// from one second to 65 seconds, which covers the API's one-minute rate-limit +// window. Delays must be positive when enabled. func WithRetryPolicy(policy RetryPolicy) Option { return func(c *Client) error { if policy.MaxRetries < 0 || policy.MaxRetries > 20 || policy.MaxRetries > 0 && (policy.BaseDelay <= 0 || policy.MaxDelay < policy.BaseDelay) { @@ -92,17 +94,32 @@ func WithRetryPolicy(policy RetryPolicy) Option { } // WithUserAgent identifies the calling application. The SDK's default is -// glassnode-api-go-client. An empty or multiline value is rejected. +// glassnode-api-go-client. A blank value or one containing control characters +// is rejected. func WithUserAgent(agent string) Option { return func(c *Client) error { - if strings.TrimSpace(agent) == "" || strings.ContainsAny(agent, "\r\n") { - return &InputError{"user agent", "must be non-empty and contain no whitespace or control characters"} + if strings.TrimSpace(agent) == "" || strings.IndexFunc(agent, unicode.IsControl) >= 0 { + return &InputError{"user agent", "must be non-blank and contain no control characters"} } c.userAgent = agent return nil } } +// WithMaxResponseBytes rejects response bodies larger than limit with a +// ResponseTooLargeError instead of decoding them. The default, zero, is no +// limit, as the API's full-history responses can legitimately be large. Use it +// to bound memory where a failed call is preferable to an unbounded one. +func WithMaxResponseBytes(limit int64) Option { + return func(c *Client) error { + if limit < 0 { + return &InputError{"max response bytes", "must not be negative"} + } + c.maxBytes = limit + return nil + } +} + // WithTokenSource uses OAuth tokens supplied at request time. Pass an empty API // key to NewClient. Each token is redacted from that call's error messages. func WithTokenSource(source TokenSource) Option { diff --git a/params.go b/params.go index 12aed2d..255197b 100644 --- a/params.go +++ b/params.go @@ -99,6 +99,12 @@ func (p *MetricParams) Values() (url.Values, error) { } func metricQuery(path string, params *MetricParams, metadata bool) (string, url.Values, error) { + return metricQueryFormat(path, params, metadata, "json") +} + +// metricQueryFormat is metricQuery with an explicit response format. Only the +// CSV download sets anything other than json; decoded responses must be JSON. +func metricQueryFormat(path string, params *MetricParams, metadata bool, format string) (string, url.Values, error) { path = "/" + strings.TrimPrefix(path, "/") if err := validateEndpoint(path); err != nil { return "", nil, &InputError{"metric path", "expected a relative metric path without URL, query or traversal"} @@ -113,6 +119,9 @@ func metricQuery(path string, params *MetricParams, metadata bool) (string, url. } q.Set("path", path) } - q.Set("f", "json") + if existing := q.Get("f"); existing != "" && !strings.EqualFold(existing, format) { + return "", nil, &InputError{"f", "the response format is set by the method; use GetMetricCSV for CSV"} + } + q.Set("f", format) return path, q, nil } diff --git a/resilience_test.go b/resilience_test.go index 0d37a67..8d4e0f6 100644 --- a/resilience_test.go +++ b/resilience_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "sync/atomic" "testing" @@ -74,7 +75,7 @@ func TestExplicitTimeoutIndependentOfOptionOrder(t *testing.T) { func TestBaseURLPrefixAndUserAgent(t *testing.T) { for _, agent := range []string{"glassnode-api-go-client", "my-application/1.0"} { - client, server := testClient(t, func(w http.ResponseWriter, r *http.Request) { + _, server := testClient(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/proxy/v1/test" || r.Header.Get("User-Agent") != agent { t.Errorf("path=%s agent=%s", r.URL.Path, r.Header.Get("User-Agent")) } @@ -90,6 +91,16 @@ func TestBaseURLPrefixAndUserAgent(t *testing.T) { } } +func TestInvalidUserAgent(t *testing.T) { + for _, agent := range []string{"", " ", "app\x00", "app\tname", "app\r\nX-Injected: 1", "app\x7f"} { + _, err := NewClient("test-secret-key", WithUserAgent(agent)) + var inputErr *InputError + if !errors.As(err, &inputErr) || inputErr.Field != "user agent" { + t.Errorf("WithUserAgent(%q): got %v, want user agent InputError", agent, err) + } + } +} + type rejectingDecoder struct{ cause error } func (d *rejectingDecoder) UnmarshalJSON([]byte) error { return d.cause } @@ -140,3 +151,260 @@ func TestAPIErrorDetailRedactedBeforeTruncation(t *testing.T) { t.Fatalf("bad bounded detail: %v", err) } } + +func TestInterruptedBodyIsRetried(t *testing.T) { + var attempts atomic.Int32 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if attempts.Add(1) == 1 { + // Announce more bytes than are sent, so the client sees an + // unexpected EOF while decoding. + w.Header().Set("Content-Length", "100") + fmt.Fprint(w, `[{"t":1,"v":1},`) + return + } + fmt.Fprint(w, `[{"t":1,"v":1},{"t":2,"v":2}]`) + }, WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond})) + points, err := client.GetTimeSeries(context.Background(), "market/price", nil) + if err != nil || len(points) != 2 || attempts.Load() != 2 { + t.Fatalf("points=%v err=%v attempts=%d", points, err, attempts.Load()) + } +} + +func TestInvalidJSONIsNotRetried(t *testing.T) { + var attempts atomic.Int32 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + fmt.Fprint(w, `[{"t":1,"v":1} oops`) + }, WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond})) + _, err := client.GetTimeSeries(context.Background(), "market/price", nil) + var decodeErr *DecodeError + if !errors.As(err, &decodeErr) || attempts.Load() != 1 { + t.Fatalf("err=%v attempts=%d", err, attempts.Load()) + } +} + +func TestMaxResponseBytes(t *testing.T) { + body := `[{"t":1,"v":1},{"t":2,"v":2}]` + var attempts atomic.Int32 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + fmt.Fprint(w, body) + }, WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond}), WithMaxResponseBytes(int64(len(body))-1)) + var tooLarge *ResponseTooLargeError + var transportErr *TransportError + _, err := client.GetTimeSeries(context.Background(), "market/price", nil) + if !errors.Is(err, ErrResponseTooLarge) || !errors.As(err, &tooLarge) || tooLarge.Limit != int64(len(body))-1 || errors.As(err, &transportErr) { + t.Fatalf("Get: %v", err) + } + if _, err := client.Raw(context.Background(), "/v1/test", nil); !errors.Is(err, ErrResponseTooLarge) { + t.Fatalf("Raw: %v", err) + } + if attempts.Load() != 2 { + t.Fatalf("retried a too-large response: attempts=%d", attempts.Load()) + } + exact, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) }, WithMaxResponseBytes(int64(len(body)))) + if got, err := exact.Raw(context.Background(), "/v1/test", nil); err != nil || string(got) != body { + t.Fatalf("exact limit: %s %v", got, err) + } + if _, err := NewClient("key", WithMaxResponseBytes(-1)); err == nil { + t.Fatal("accepted negative limit") + } +} + +func TestDefaultRetryCoversRateLimitWindow(t *testing.T) { + client, err := NewClient("key") + if err != nil { + t.Fatal(err) + } + if client.retry.MaxDelay < 60*time.Second { + t.Fatalf("default MaxDelay %v does not cover x-rate-limit-reset of up to 60s", client.retry.MaxDelay) + } +} + +// A complete value followed by a failed read is retried; the retry must not +// merge into what the failed attempt would have produced. +func TestFailedAttemptLeavesNoTraceInDestination(t *testing.T) { + var attempts atomic.Int32 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if attempts.Add(1) == 1 { + w.Header().Set("Content-Length", "30") // longer than the body: the read fails after the value + fmt.Fprint(w, `{"a":1,"b":2}`) + return + } + fmt.Fprint(w, `{"a":1}`) + }, WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond})) + dst := map[string]int{} + if err := client.Get(context.Background(), "/v1/test", nil, &dst); err != nil { + t.Fatal(err) + } + if attempts.Load() != 2 || len(dst) != 1 || dst["a"] != 1 { + t.Fatalf("attempts=%d dst=%v, want map[a:1] from the successful attempt only", attempts.Load(), dst) + } + // Invalid JSON is rejected before anything is written to dst. + failing, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"a":1,`) }) + dst = map[string]int{"kept": 1} + var decodeErr *DecodeError + if err := failing.Get(context.Background(), "/v1/test", nil, &dst); !errors.As(err, &decodeErr) { + t.Fatalf("err=%v", err) + } + if len(dst) != 1 || dst["kept"] != 1 { + t.Fatalf("dst modified on invalid JSON: %v", dst) + } +} + +// With a chunked body the last data and EOF arrive in separate reads; a body of +// exactly the limit must still be accepted. +func TestMaxResponseBytesExactLimitChunked(t *testing.T) { + body := `[{"t":1,"v":1},{"t":2,"v":2}]` + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Transfer-Encoding", "chunked") + fmt.Fprint(w, body) + w.(http.Flusher).Flush() + }, WithMaxResponseBytes(int64(len(body)))) + if points, err := client.GetTimeSeries(context.Background(), "market/price", nil); err != nil || len(points) != 2 { + t.Fatalf("Get: %v %v", points, err) + } + if got, err := client.Raw(context.Background(), "/v1/test", nil); err != nil || string(got) != body { + t.Fatalf("Raw: %s %v", got, err) + } +} + +func TestGetMetricCSV(t *testing.T) { + csvBody := "timestamp,value\n1700000000,42000\n1700086400,43000\n" + var attempts atomic.Int32 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if attempts.Add(1) == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + if r.URL.Query().Get("f") != "csv" || r.Header.Get("Accept") != "text/csv" || r.URL.Query().Get("a") != "BTC" { + t.Errorf("query=%v accept=%s", r.URL.Query(), r.Header.Get("Accept")) + } + w.Header().Set("Content-Type", "text/csv") + fmt.Fprint(w, csvBody) + }, WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond})) + var out strings.Builder + if err := client.GetMetricCSV(context.Background(), "market/price_usd_close", &MetricParams{Asset: "BTC"}, &out); err != nil { + t.Fatal(err) + } + if out.String() != csvBody || attempts.Load() != 2 { + t.Fatalf("out=%q attempts=%d", out.String(), attempts.Load()) + } +} + +func TestGetMetricCSVRejectsBulkAndNonCSV(t *testing.T) { + var attempts atomic.Int32 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `[]`) + }, WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond})) + var out strings.Builder + var inputErr *InputError + if err := client.GetMetricCSV(context.Background(), "market/price/bulk", nil, &out); !errors.As(err, &inputErr) { + t.Fatalf("bulk: %v", err) + } + if err := client.GetMetricCSV(context.Background(), "market/price", nil, nil); !errors.As(err, &inputErr) { + t.Fatalf("nil writer: %v", err) + } + var decodeErr *DecodeError + if err := client.GetMetricCSV(context.Background(), "market/price", nil, &out); !errors.As(err, &decodeErr) { + t.Fatalf("json body: %v", err) + } + if err := client.Get(context.Background(), "/v1/test", url.Values{"f": {"csv"}}, new(any)); !errors.As(err, &inputErr) { + t.Fatalf("Get with f=csv: %v", err) + } + if attempts.Load() != 1 || out.Len() != 0 { + t.Fatalf("attempts=%d out=%q", attempts.Load(), out.String()) + } +} + +type failingWriter struct{ err error } + +func (f failingWriter) Write(p []byte) (int, error) { return 0, f.err } + +func TestGetMetricCSVErrorClassification(t *testing.T) { + t.Run("read failure before any output is retried", func(t *testing.T) { + var attempts atomic.Int32 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/csv") + if attempts.Add(1) == 1 { + w.Header().Set("Content-Length", "50") // headers only, then the connection ends + return + } + fmt.Fprint(w, "timestamp,value\n1,2\n") + }, WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond})) + var out strings.Builder + if err := client.GetMetricCSV(context.Background(), "market/price", nil, &out); err != nil || attempts.Load() != 2 || out.String() != "timestamp,value\n1,2\n" { + t.Fatalf("err=%v attempts=%d out=%q", err, attempts.Load(), out.String()) + } + }) + t.Run("format is matched case-insensitively", func(t *testing.T) { + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Accept") != "text/csv" { + t.Errorf("Accept = %q for f=%s", r.Header.Get("Accept"), r.URL.Query().Get("f")) + } + w.Header().Set("Content-Type", "text/csv") + fmt.Fprint(w, "timestamp,value\n") + }) + var out strings.Builder + if err := client.GetMetricCSV(context.Background(), "market/price", &MetricParams{Extra: url.Values{"f": {"CSV"}}}, &out); err != nil { + t.Fatal(err) + } + }) + t.Run("writer failure is the caller's error", func(t *testing.T) { + var attempts atomic.Int32 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.Header().Set("Content-Type", "text/csv") + fmt.Fprint(w, "timestamp,value\n1,2\n") + }, WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond})) + diskFull := errors.New("no space left on device") + err := client.GetMetricCSV(context.Background(), "market/price", nil, failingWriter{diskFull}) + var transportErr *TransportError + if !errors.Is(err, diskFull) || errors.As(err, &transportErr) || attempts.Load() != 1 || !strings.Contains(err.Error(), "writing /v1/metrics/market/price response") { + t.Fatalf("err=%v attempts=%d", err, attempts.Load()) + } + }) +} + +func TestGetMetricCSVDoesNotRetryAfterPartialOutput(t *testing.T) { + var attempts atomic.Int32 + client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + attempts.Add(1) + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Length", "100") // the body is cut short + fmt.Fprint(w, "timestamp,value\n1700000000,42000\n") + }, WithRetryPolicy(RetryPolicy{2, time.Millisecond, 5 * time.Millisecond})) + var out strings.Builder + err := client.GetMetricCSV(context.Background(), "market/price", nil, &out) + var transportErr *TransportError + if !errors.As(err, &transportErr) || attempts.Load() != 1 || !strings.Contains(err.Error(), "partial output") { + t.Fatalf("err=%v attempts=%d", err, attempts.Load()) + } + if !strings.HasPrefix(out.String(), "timestamp,value") { + t.Fatalf("partial output not delivered: %q", out.String()) + } + csvBody := "timestamp,value\n1700000000,42000\n" + limited, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/csv") + fmt.Fprint(w, csvBody) + }, WithMaxResponseBytes(10)) + out.Reset() + if err := limited.GetMetricCSV(context.Background(), "market/price", nil, &out); !errors.Is(err, ErrResponseTooLarge) { + t.Fatalf("limit: %v", err) + } + if out.Len() != 10 { + t.Fatalf("writer received %d bytes, want exactly the 10-byte limit", out.Len()) + } + exact, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Transfer-Encoding", "chunked") + fmt.Fprint(w, csvBody) + w.(http.Flusher).Flush() + }, WithMaxResponseBytes(int64(len(csvBody)))) + out.Reset() + if err := exact.GetMetricCSV(context.Background(), "market/price", nil, &out); err != nil || out.String() != csvBody { + t.Fatalf("exact limit: %v %q", err, out.String()) + } +} diff --git a/testdata/contract/README.md b/testdata/contract/README.md index d4bb8c5..a454293 100644 --- a/testdata/contract/README.md +++ b/testdata/contract/README.md @@ -1,10 +1,15 @@ -# Recorded API contract fixtures +# Recorded API responses -Copied from `glassnode/glassnode-api-ts-client/test/fixtures/contract` (Apache -License 2.0), captured on 2026-09-23 by its `scripts/record-fixtures.mjs` using -client version 0.27.0 against `https://api.glassnode.com`. +Real responses from `https://api.glassnode.com`, used by `contract_test.go` to +check that the client decodes the production format. Running the tests sends +no requests and needs no credentials. -Response bodies are unchanged except `asset-metadata.json`, reduced to its first -three assets to keep this SDK's source archive small. No requests or credentials -are needed to run the tests. The exchange metadata fixture specifically guards -`parameters_defaults`, which was previously discarded by the Go model. +The files come from the TypeScript client's +[contract fixtures](https://github.com/glassnode/glassnode-api-ts-client/tree/main/test/fixtures/contract) +(Apache License 2.0), recorded on 2026-09-23 with its +`scripts/record-fixtures.mjs` and client version 0.27.0. They are unchanged, +except that `asset-metadata.json` is cut down to its first three assets. + +The two `*-pit-*` files were recorded on 2026-10-05 with this client during a +shape audit of all metrics; they cover the `computed_at` field that +point-in-time metrics add to every point. diff --git a/testdata/contract/timeseries-active-count-pit-btc.json b/testdata/contract/timeseries-active-count-pit-btc.json new file mode 100644 index 0000000..cdd68c1 --- /dev/null +++ b/testdata/contract/timeseries-active-count-pit-btc.json @@ -0,0 +1 @@ +[{"t":1790899200,"v":729981,"computed_at":1790985731},{"t":1790985600,"v":631346,"computed_at":1791072134},{"t":1791072000,"v":555361,"computed_at":1791158538}] \ No newline at end of file diff --git a/testdata/contract/timeseries-ssr-pit-btc.json b/testdata/contract/timeseries-ssr-pit-btc.json new file mode 100644 index 0000000..d1c52b4 --- /dev/null +++ b/testdata/contract/timeseries-ssr-pit-btc.json @@ -0,0 +1 @@ +[{"t":1790899200,"o":{"h":5.648763169746526,"l":3.7533322026042955,"v":5.6209851597624},"computed_at":1790985823},{"t":1790985600,"o":{"h":5.662302931965689,"l":3.7485258706835403,"v":5.638306907071154},"computed_at":1791072229},{"t":1791072000,"o":{"h":5.679136747507495,"l":3.7431904608358133,"v":5.748405550837904},"computed_at":1791158636}] \ No newline at end of file diff --git a/usage.go b/usage.go index 5e19266..0f80162 100644 --- a/usage.go +++ b/usage.go @@ -17,6 +17,7 @@ type APIUsage struct { APIAddons []APIAddon `json:"apiAddons"` } +// UnmarshalJSON requires creditsUsed and tolerates missing add-ons. func (u *APIUsage) UnmarshalJSON(data []byte) error { var wire struct { CreditsUsed *int `json:"creditsUsed"` @@ -25,11 +26,15 @@ func (u *APIUsage) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &wire); err != nil { return err } - if wire.CreditsUsed == nil || wire.APIAddons == nil { - return errors.New("usage requires creditsUsed and apiAddons") + if wire.CreditsUsed == nil { + return errors.New("usage requires creditsUsed") } u.CreditsUsed = *wire.CreditsUsed + // Accounts without add-ons may report null or omit the field entirely. u.APIAddons = wire.APIAddons + if u.APIAddons == nil { + u.APIAddons = []APIAddon{} + } return nil }