polyzon

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 20 Imported by: 0

README

Polyzon Go SDK

The official Go client for the Polyzon public API: search 3D models across 22 catalogues (Thingiverse, Printables, MakerWorld, Cults3D and more), fetch full model cards, and check your account and quota. The library uses only the Go standard library, is safe for concurrent use, retries transient failures, and keeps your token out of logs and error messages.

Русская версия

Contents

Installation

go get github.com/PolyzonOrg/polyzon-go

Quick start

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/PolyzonOrg/polyzon-go"
)

func main() {
	client, err := polyzon.NewClient() // reads POLYZON_API_KEY
	if err != nil {
		log.Fatal(err)
	}

	page, err := client.Search(context.Background(), polyzon.SearchParams{Query: "дракон"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(page.Total, "models")
	for _, item := range page.Items {
		fmt.Println(item.Source, item.ID, item.Title)
	}
}

More runnable programs are in examples/: quickstart, pagination, batch and proxy (go run ./examples/quickstart дракон).

Authentication

Every request needs a personal API token (pzk_…). Issue one at https://polyzon.org/account/ in the «API» section. The client reads it from the POLYZON_API_KEY environment variable, or you pass it explicitly:

client, err := polyzon.NewClient(polyzon.WithAPIKey(os.Getenv("MY_POLYZON_TOKEN")))

NewClient returns an error when the token is missing, longer than 512 characters, or contains anything but printable ASCII (spaces, line breaks and control characters are rejected so they can never inject headers). The token is masked whenever the client is printed (polyzon.Client{baseURL: https://api.polyzon.org, apiKey: pzk_Q7xR…}) and never appears in errors or logs.

Methods

All methods take a context.Context first and are safe to call from many goroutines at once.

page, err := client.Search(ctx, polyzon.SearchParams{
	Query:    "кашпо",                 // required, up to 200 characters, Russian or English
	Page:     1,                       // 1–100, default 1
	Size:     50,                      // 1–50, default 20; Page × Size ≤ 2400
	Sort:     polyzon.SortPopular,     // relevant (default), popular, newest, likes, makes, downloads
	Free:     true,                    // only free models
	Sources:  []string{polyzon.SourceThingiverse, polyzon.SourcePrintables},
	Formats:  []string{"stl", "3mf"},
	Licenses: []string{polyzon.LicenseCC0, polyzon.LicenseCCBY},
	Lang:     polyzon.LangEN,          // ru (default) or en; other texts are machine-translated
})
fmt.Println(page.Total, page.TotalRelation, page.HasMore, page.Partial)
for _, item := range page.Items {
	fmt.Println(item.Source, item.ID, item.Title, item.URL, item.License)
	if item.Price != nil {
		fmt.Println(item.Price.Type, item.Price.Display)
	}
	if item.Stats.Likes != nil {
		fmt.Println(*item.Stats.Likes, "likes")
	}
}

Zero values are not sent, so the server defaults apply. Lists are sent as one comma-separated parameter. Partial reports that part of the catalogue did not answer in time. CorrectedQuery and DidYouMean carry spelling suggestions.

Sources, licenses, sort orders and languages have constants (polyzon.Source…, polyzon.License…, polyzon.Sort…, polyzon.Lang…), but every such field is a plain string: new sources and licenses appear without a new SDK release.

GetModel
model, err := client.GetModel(ctx, polyzon.SourceThingiverse, "tv-1234567", polyzon.WithLang(polyzon.LangEN))
fmt.Println(model.Title, model.Description, model.Category)
fmt.Println(model.Images)         // URLs on the source's CDN
if model.License != nil {
	fmt.Println(model.License.ID, model.License.Raw)
}
for _, other := range model.Copies { // the same model on other sources
	fmt.Println(other.Source, other.ID)
}

source and id are the pair from search results and from polyzon.org/model/{source}/{id}. They are validated before any request (source ^[a-z0-9]{1,32}$, id ^[A-Za-z0-9._-]{1,255}$, not . or ..); an invalid value returns an error without spending quota. A model removed on the right holder's request answers polyzon.ErrGone: delete your copy too.

Me
me, err := client.Me(ctx)
fmt.Println(me.Account.ID, me.Account.DisplayName, me.Token.Name, me.Token.Prefix)
Limits
limits, err := client.Limits(ctx)
fmt.Printf("%d/%d this minute, %d/%d today, resets at %v\n",
	limits.PerMinute.Used, limits.PerMinute.Limit,
	limits.Daily.Used, limits.Daily.Limit, time.Unix(limits.Daily.ResetAt, 0))

Me and Limits do not use the daily quota.

Counters (Stats.Likes, Stats.Downloads, …) are *int64: nil means the source does not report the number. Optional texts (Thumbnail, Creator, Description, …) are "" when unknown. Dates are ISO-8601 strings exactly as the source reported them. Unknown JSON fields are ignored and malformed values read as empty, so new API fields never break your build or your parsing.

Pagination

SearchAll returns an iter.Seq2[polyzon.SearchItem, error] that fetches pages lazily, starting at params.Page (default 1), until the last page:

for item, err := range client.SearchAll(ctx, polyzon.SearchParams{Query: "робот", Size: 50}) {
	if err != nil {
		return err // yielded once; the iteration then stops
	}
	fmt.Println(item.Title)
	if enough() {
		break // no further pages are requested
	}
}

Every page costs one request of the daily quota, so use Size: 50 and break when you have enough. The API serves at most 2400 results per query (Page × Size) and no page past 100, so SearchAll stops after page 100 even when HasMore is still true there (sizes under 24).

Batch requests and concurrency

A *polyzon.Client is immutable and safe for concurrent use: create one per process and share it between goroutines. Requests reuse a pool of keep-alive connections (10 per host by default, see WithMaxConnections). Go has no separate async API: run calls in goroutines and control them with contexts.

GetModels fetches many model cards with bounded parallelism and returns one result per reference, in input order:

refs := []polyzon.ModelRef{{Source: "thingiverse", ID: "tv-1234567"}, {Source: "printables", ID: "pr-123456"}}
results, err := client.GetModels(ctx, refs, polyzon.WithConcurrency(4), polyzon.WithLang(polyzon.LangRU))
if err != nil {
	log.Fatal(err) // only for invalid options, e.g. concurrency < 1
}
for _, res := range results {
	if res.Err != nil {
		fmt.Println(res.Ref, res.Err) // per-item failure
		continue
	}
	fmt.Println(res.Model.Title)
}

Concurrency defaults to 4. Once a request fails with ErrAuthentication, ErrPermissionDenied or ErrQuotaExceeded, the references not started yet get that same error without sending requests. Each fetched model costs one request of the daily quota, and the per-minute limit (20 by default) applies to the batch.

Response metadata

Every result carries a Meta field (excluded from JSON encoding):

page, _ := client.Search(ctx, params)
fmt.Println(page.Meta.RequestID) // X-Request-ID, quote it in support requests
if rl := page.Meta.RateLimit; rl != nil { // the DAILY window from x-ratelimit-*
	fmt.Println(rl.Limit, rl.Remaining, time.Unix(rl.ResetAt, 0))
}

Errors

Error answers of the API are *polyzon.APIError values. Classify them with errors.Is and the sentinel errors, and read details with errors.As:

model, err := client.GetModel(ctx, "thingiverse", "tv-1234567")
switch {
case errors.Is(err, polyzon.ErrNotFound):
	// no such model
case errors.Is(err, polyzon.ErrGone):
	// removed on the right holder's request: delete it on your side too
case errors.Is(err, polyzon.ErrQuotaExceeded):
	// the daily quota is spent until 00:00 UTC
case errors.Is(err, polyzon.ErrRateLimited):
	// per-minute limit, still hit after the automatic retries
case err != nil:
	var apiErr *polyzon.APIError
	if errors.As(err, &apiErr) {
		log.Printf("status %d, code %s, request %s: %s", apiErr.StatusCode, apiErr.Code, apiErr.RequestID, apiErr.Detail)
	}
}
Sentinel Matches Codes
ErrBadRequest HTTP 400 bad_request (APIError.Param names the parameter), page_too_deep
ErrAuthentication HTTP 401 unauthorized, invalid_token
ErrPermissionDenied HTTP 403 account_action_required: accept the current terms at polyzon.org/account
ErrNotFound HTTP 404 not_found
ErrGone HTTP 410 gone
ErrRateLimited every HTTP 429 rate_limited, quota_exceeded
ErrQuotaExceeded HTTP 429 quota_exceeded (also matches ErrRateLimited) quota_exceeded
ErrServer HTTP 5xx unavailable, internal, any
ErrConnection network failure: DNS, TCP, TLS, proxy, reset (also timeouts) —
ErrTimeout an attempt ran out of time (also matches ErrConnection) —
ErrInvalidResponse a 2xx answer that is not a JSON object or exceeds 8 MiB —

Other statuses (another 4xx, or a 3xx, since redirects are never followed) are an *APIError matching no sentinel. APIError fields: StatusCode, Code, Title, Detail, Param, RequestID, RetryAfter (time.Duration, 0 when absent) and Header. Its message reads polyzon: 429 rate_limited: Не больше 20 запросов в минуту. (request id …). A non-JSON error body (an HTML page from the edge proxy) gives an empty Code and Detail HTTP <status>; the raw body is never put into the message.

The Code* constants name the codes. Errors caused by your context are not wrapped in any sentinel: they match context.Canceled or context.DeadlineExceeded. An invalid argument (a bad model id, invalid options) is a plain error returned before any request.

Retries

Requests are retried up to 2 times (polyzon.WithMaxRetries(n), 0 disables) on connection errors, timeouts, HTTP 500, 502, 503, 504 and 429 rate_limited. They are never retried on quota_exceeded or other 4xx answers. The wait is the server's Retry-After (seconds or HTTP date) when present; a Retry-After longer than 60 seconds is not waited for, and the error is returned at once. Without it the wait is min(8 s, 0.5 s × 2^attempt) with random jitter (×0.5–1.0). Waits end early when the context is cancelled.

Timeouts and cancellation

Each attempt has a total time limit of 30 seconds, including connecting (at most 10 seconds) and reading the answer. Change it with polyzon.WithTimeout. A timed-out attempt is retried and finally reported as ErrTimeout. For an overall deadline across retries, use the context:

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
page, err := client.Search(ctx, params) // errors.Is(err, context.DeadlineExceeded) on expiry

A cancelled or expired context is never retried.

Proxies

Without configuration the standard environment variables apply: HTTPS_PROXY/https_proxy, HTTP_PROXY/http_proxy and NO_PROXY/no_proxy (loopback hosts are never proxied through them). An explicit proxy overrides them:

proxyURL, _ := url.Parse("http://user:password@proxy.internal:3128")
client, err := polyzon.NewClient(polyzon.WithProxy(proxyURL))

Supported schemes are http://, https:// (TLS to the proxy), socks5:// and socks5h:// (names resolved by the proxy), all native to Go's net/http. Credentials in the URL are sent as Basic proxy authentication (percent-decoded) and never appear in errors or logs. Requests to the https API go through a CONNECT tunnel with end-to-end TLS, so the proxy never sees your token. polyzon.WithProxy(nil) disables proxies, including the environment ones.

Custom HTTP client, CA certificates

polyzon.WithHTTPClient sends requests through your *http.Client, for example to trust a corporate CA, use mTLS or add tracing:

pool, _ := x509.SystemCertPool()
pool.AppendCertsFromPEM(corporateCAPEM)
client, err := polyzon.NewClient(polyzon.WithHTTPClient(&http.Client{
	Transport: &http.Transport{
		Proxy:           http.ProxyFromEnvironment,
		TLSClientConfig: &tls.Config{RootCAs: pool},
	},
}))

The SDK uses a copy of your client that never follows redirects. Configure proxies and pool size on its transport (WithProxy and WithMaxConnections cannot be combined with it). TLS certificate verification is always on: the SDK has no option to disable it and rejects an *http.Transport with InsecureSkipVerify. client.CloseIdleConnections() closes idle pooled connections, for example before a process sleeps for long.

Logging

Pass a *slog.Logger to log requests at debug level: method, path with query, status, attempt, duration and retry delays. Headers, and so the token, are never logged.

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
client, err := polyzon.NewClient(polyzon.WithLogger(logger))

Rate limits and quota

Quotas are per account, shared by all its tokens:

  • 20 requests per minute for every method;
  • 200 requests per UTC day for Search and GetModel (each page of SearchAll and each model of GetModels counts). Me and Limits do not use it.

The server charges the quota before it validates parameters, so even a rejected request (400) costs one. A request over the per-minute limit fails with rate_limited and is retried automatically after Retry-After; a spent daily quota fails with quota_exceeded (resets at 00:00 UTC) and is not retried. Check the remaining daily quota with Meta.RateLimit on any result or with client.Limits(ctx).

Configuration reference

Option Default Notes
WithAPIKey(key) env POLYZON_API_KEY required
WithBaseURL(url) env POLYZON_BASE_URL, else https://api.polyzon.org https only; http only for localhost, 127.0.0.1, [::1]; no credentials, query or fragment
WithTimeout(d) 30 s per attempt connect at most 10 s
WithMaxRetries(n) 2 0 disables retries
WithProxy(u) environment proxies nil disables proxies
WithHTTPClient(c) built-in transport redirects are never followed
WithMaxConnections(n) 10 connections to the API at once
WithUserAgent(s) — appended to polyzon-go/1.0.0 (go <version>; <os>/<arch>)
WithLogger(l) no logging debug level

Per call: polyzon.WithLang(lang) for GetModel/GetModels, polyzon.WithConcurrency(n) for GetModels.

Security

  • Never ship your token to browsers or mobile apps. Anyone can extract it from there. Keep it on your server and call the API from there.
  • The client refuses non-https base URLs (except loopback for local testing), never follows redirects (a redirect could carry the token to another origin), always verifies TLS certificates, caps response bodies at 8 MiB and validates path segments before building URLs.
  • Revoke a leaked token at https://polyzon.org/account/ at once. Report vulnerabilities to security@polyzon.org, see SECURITY.md.

Versioning

The SDK follows Semantic Versioning. Releases are git tags (v1.0.0); breaking changes would ship as a new major version with a /v2 module path. See CHANGELOG.md.

Requirements

Go 1.23 or newer (for range-over-func iterators). No dependencies outside the standard library.

License

MIT © 2026 Polyzon

Documentation

Overview

Package polyzon is the official Go client for the Polyzon public API (https://api.polyzon.org): search across 3D-model catalogues and fetch model cards, with the account's quota and token information.

Create one Client and share it: it is safe for concurrent use by many goroutines and keeps a pool of keep-alive connections.

client, err := polyzon.NewClient() // reads POLYZON_API_KEY
if err != nil {
	log.Fatal(err)
}
page, err := client.Search(ctx, polyzon.SearchParams{Query: "дракон"})

Every method takes a context.Context first; cancelling it aborts the request and any retry wait. Errors returned by the API are *APIError values that match the sentinel errors of this package through errors.Is, for example errors.Is(err, polyzon.ErrRateLimited).

Keep the token on the server side: never ship it to browsers or mobile apps.

Index

Examples

Constants

View Source
const (
	SourceThingiverse   = "thingiverse"
	SourceCults3D       = "cults3d"
	SourcePrintables    = "printables"
	SourceMakerWorld    = "makerworld"
	Source3DToday       = "3dtoday"
	SourceCrealityCloud = "crealitycloud"
	SourceMyMiniFactory = "myminifactory"
	SourceThangs        = "thangs"
	SourceYouMagine     = "youmagine"
	SourceGrabCAD       = "grabcad"
	SourceSketchfab     = "sketchfab"
	SourceThreeding     = "threeding"
	SourceNexprint      = "nexprint"
	SourceNASA          = "nasa"
	SourceNIH3D         = "nih3d"
	SourceSmithsonian   = "smithsonian"
	SourcePinshape      = "pinshape"
	SourceCGTrader      = "cgtrader"
	SourceGambody       = "gambody"
	SourceJewelModel    = "jewelmodel"
	SourceMakerOnline   = "makeronline"
	Source3DExport      = "3dexport"
)

Known sources (catalogues) of models. The API may add new sources at any time, so every field and parameter holding a source is a plain string.

View Source
const (
	LicenseCC0        = "cc0"
	LicenseCCBY       = "cc-by"
	LicenseCCBYSA     = "cc-by-sa"
	LicenseCCBYND     = "cc-by-nd"
	LicenseCCBYNC     = "cc-by-nc"
	LicenseCCBYNCSA   = "cc-by-nc-sa"
	LicenseCCBYNCND   = "cc-by-nc-nd"
	LicenseGPL        = "gpl"
	LicenseLGPL       = "lgpl"
	LicenseMIT        = "mit"
	LicenseBSD        = "bsd"
	LicensePrivateUse = "private-use"
	LicenseCommercial = "commercial"
	LicenseCustom     = "custom"
	LicenseUnknown    = "unknown"
)

Known license ids, used by SearchParams.Licenses, SearchItem.License and License.ID. New ids may appear, so the fields are plain strings.

View Source
const (
	SortRelevant  = "relevant" // by relevance (the default)
	SortPopular   = "popular"  // by popularity on the source
	SortNewest    = "newest"
	SortLikes     = "likes"
	SortMakes     = "makes"
	SortDownloads = "downloads"
)

Sort orders for SearchParams.Sort.

View Source
const (
	LangRU = "ru" // the default
	LangEN = "en"
)

Languages for titles, descriptions and tags (SearchParams.Lang, WithLang). Texts originally in another language are machine-translated.

View Source
const (
	PriceFree = "free"
	PricePaid = "paid"
	PriceOpen = "open"
)

Values of Price.Type.

View Source
const (
	TotalEq  = "eq"  // Total is exact
	TotalGte = "gte" // Total is a lower bound
)

Values of SearchPage.TotalRelation.

View Source
const (
	CodeBadRequest            = "bad_request"             // 400, see APIError.Param
	CodePageTooDeep           = "page_too_deep"           // 400, page × size > 2400
	CodeUnauthorized          = "unauthorized"            // 401, no token sent
	CodeInvalidToken          = "invalid_token"           // 401, unknown or revoked token
	CodeAccountActionRequired = "account_action_required" // 403, accept the terms at polyzon.org/account
	CodeNotFound              = "not_found"               // 404
	CodeGone                  = "gone"                    // 410, removed on the right holder's request
	CodeRateLimited           = "rate_limited"            // 429, per-minute limit
	CodeQuotaExceeded         = "quota_exceeded"          // 429, daily quota, resets at 00:00 UTC
	CodeUnavailable           = "unavailable"             // 503
	CodeInternal              = "internal"                // 500
)

Error codes of APIError.Code.

View Source
const DefaultBaseURL = "https://api.polyzon.org"

DefaultBaseURL is the production API endpoint.

View Source
const Version = "1.0.0"

Version is the version of this SDK. It is sent in the User-Agent header.

Variables

View Source
var (
	// ErrBadRequest matches HTTP 400: a parameter is invalid (APIError.Param names
	// it) or the page is too deep (code page_too_deep).
	ErrBadRequest error = &sentinel{msg: "polyzon: bad request"}
	// ErrAuthentication matches HTTP 401: the token is missing, unknown or revoked.
	ErrAuthentication error = &sentinel{msg: "polyzon: authentication failed"}
	// ErrPermissionDenied matches HTTP 403: the token owner must accept the current
	// terms at https://polyzon.org/account/.
	ErrPermissionDenied error = &sentinel{msg: "polyzon: permission denied"}
	// ErrNotFound matches HTTP 404.
	ErrNotFound error = &sentinel{msg: "polyzon: not found"}
	// ErrGone matches HTTP 410: the model was removed on the right holder's
	// request. Delete your copy of it too.
	ErrGone error = &sentinel{msg: "polyzon: gone"}
	// ErrRateLimited matches every HTTP 429, including [ErrQuotaExceeded].
	ErrRateLimited error = &sentinel{msg: "polyzon: rate limited"}
	// ErrQuotaExceeded matches HTTP 429 with code quota_exceeded: the daily quota
	// is spent until 00:00 UTC. It also matches [ErrRateLimited].
	ErrQuotaExceeded error = &sentinel{msg: "polyzon: daily quota exceeded", parent: ErrRateLimited}
	// ErrServer matches HTTP 5xx answers.
	ErrServer error = &sentinel{msg: "polyzon: server error"}
	// ErrConnection matches network failures: DNS, TCP, TLS, proxy, reset
	// connections, and timeouts ([ErrTimeout]).
	ErrConnection error = &sentinel{msg: "polyzon: connection error"}
	// ErrTimeout matches a request attempt that ran out of time (see [WithTimeout]).
	// It also matches [ErrConnection].
	ErrTimeout error = &sentinel{msg: "polyzon: request timed out", parent: ErrConnection}
	// ErrInvalidResponse matches a success answer whose body is not a JSON
	// object or exceeds 8 MiB.
	ErrInvalidResponse error = &sentinel{msg: "polyzon: invalid response"}
)

Sentinel errors for errors.Is. Errors answered by the API are *APIError values matching one of the HTTP-status sentinels; transport problems match ErrConnection (and ErrTimeout when time ran out); malformed success answers match ErrInvalidResponse.

Errors caused by the caller's context (cancellation or its deadline) are not wrapped in any of these: they match context.Canceled or context.DeadlineExceeded instead.

Functions

This section is empty.

Types

type APIError

type APIError struct {
	// StatusCode is the HTTP status code.
	StatusCode int
	// Code is the stable machine-readable error code (the Code* constants), or ""
	// when the body was not a problem document.
	Code string
	// Title is the short HTTP status title.
	Title string
	// Detail is the human-readable explanation (usually in Russian).
	Detail string
	// Param names the invalid query parameter for code bad_request, or "".
	Param string
	// RequestID is the X-Request-ID response header; quote it in support requests.
	RequestID string
	// RetryAfter is the Retry-After header as a duration, or 0 when absent.
	RetryAfter time.Duration
	// Header holds the response headers (for example x-ratelimit-*).
	Header http.Header
}

APIError is an error answer of the API (any non-2xx HTTP status, including a redirect, which the client never follows). The API answers application/problem+json; for other bodies (an HTML page from an edge proxy) Code is empty and Detail is "HTTP <status>".

Use errors.Is with the sentinel errors to classify it and errors.As to read its fields:

var apiErr *polyzon.APIError
if errors.As(err, &apiErr) && apiErr.Code == polyzon.CodePageTooDeep { … }
Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	"github.com/PolyzonOrg/polyzon-go"
)

func main() {
	client, err := polyzon.NewClient()
	if err != nil {
		log.Fatal(err)
	}
	_, err = client.GetModel(context.Background(), polyzon.SourceThingiverse, "tv-404")
	switch {
	case errors.Is(err, polyzon.ErrNotFound):
		fmt.Println("no such model")
	case errors.Is(err, polyzon.ErrGone):
		fmt.Println("removed on the right holder's request: delete it")
	case errors.Is(err, polyzon.ErrQuotaExceeded):
		fmt.Println("daily quota spent until 00:00 UTC")
	case errors.Is(err, polyzon.ErrConnection):
		fmt.Println("network trouble:", err)
	}
	var apiErr *polyzon.APIError
	if errors.As(err, &apiErr) {
		fmt.Println(apiErr.StatusCode, apiErr.Code, apiErr.RequestID)
	}
}
Output:
no such model
404 not_found example

func (*APIError) Error

func (e *APIError) Error() string

Error formats the error as "polyzon: <status> <code>: <detail> (request id <id>)".

func (*APIError) Unwrap

func (e *APIError) Unwrap() error

Unwrap returns the sentinel error matching the status code and error code, or nil for statuses without one (a 3xx, or another 4xx).

type Account

type Account struct {
	ID          string `json:"id"`
	DisplayName string `json:"displayName"` // "" when not set
	CreatedAt   string `json:"createdAt"`   // ISO-8601
}

Account is the Polyzon account owning the token.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client calls the Polyzon API. Create it with NewClient. A Client is immutable and safe for concurrent use by multiple goroutines; share one per process so requests reuse its pool of keep-alive connections.

func NewClient

func NewClient(opts ...Option) (*Client, error)

NewClient creates a client. Without WithAPIKey the token is read from the POLYZON_API_KEY environment variable; a missing or malformed token is an error.

Example
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/PolyzonOrg/polyzon-go"
)

func main() {
	// The token comes from POLYZON_API_KEY; polyzon.WithAPIKey passes it explicitly.
	client, err := polyzon.NewClient(
		polyzon.WithTimeout(10*time.Second),
		polyzon.WithMaxRetries(3),
		polyzon.WithUserAgent("my-bot/1.0"),
	)
	if err != nil {
		log.Fatal(err)
	}
	me, err := client.Me(context.Background())
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(me.Account.DisplayName, me.Token.Prefix)
}
Output:
Алексей pzk_Q7xR2mVb

func (*Client) CloseIdleConnections

func (c *Client) CloseIdleConnections()

CloseIdleConnections closes the idle keep-alive connections of the client's pool (of the custom client's transport with WithHTTPClient). The client stays usable.

func (Client) Format

func (c Client) Format(f fmt.State, _ rune)

Format implements fmt.Formatter so that every verb, including %+v and %#v, prints Client.String and never the token.

func (*Client) GetModel

func (c *Client) GetModel(ctx context.Context, source, id string, opts ...ModelOption) (*Model, error)

GetModel returns the model card of the model id on source, the same pair as in search results and in polyzon.org/model/{source}/{id}. It costs one request of the daily quota. An invalid source or id is reported without a request.

A removed model answers ErrGone: delete your copy of it too.

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/PolyzonOrg/polyzon-go"
)

func main() {
	client, err := polyzon.NewClient()
	if err != nil {
		log.Fatal(err)
	}
	model, err := client.GetModel(context.Background(), polyzon.SourceThingiverse, "tv-1234567",
		polyzon.WithLang(polyzon.LangRU))
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(model.Title)
	fmt.Println("license:", model.License.ID)
	if model.Stats.Downloads != nil {
		fmt.Println("downloads:", *model.Stats.Downloads)
	}
	for _, other := range model.Copies {
		fmt.Println("also on", other.Source, other.ID)
	}
}
Output:
Дракон с подвижными сочленениями (пример)
license: cc-by-nc
downloads: 410000
also on printables pr-123456

func (*Client) GetModels

func (c *Client) GetModels(ctx context.Context, refs []ModelRef, opts ...ModelOption) ([]ModelResult, error)

GetModels fetches many model cards with bounded parallelism (WithConcurrency, default 4) and returns one result per ref in input order. Failures are reported per result, never as the returned error, which is only for invalid options.

Once a request fails with ErrAuthentication, ErrPermissionDenied or ErrQuotaExceeded, the refs not started yet get that same error without requests. Each fetched model costs one request of the daily quota.

Example
package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	"github.com/PolyzonOrg/polyzon-go"
)

func main() {
	client, err := polyzon.NewClient()
	if err != nil {
		log.Fatal(err)
	}
	refs := []polyzon.ModelRef{
		{Source: polyzon.SourceThingiverse, ID: "tv-1234567"},
		{Source: polyzon.SourceThingiverse, ID: "tv-404"},
	}
	results, err := client.GetModels(context.Background(), refs, polyzon.WithConcurrency(4))
	if err != nil {
		log.Fatal(err)
	}
	for _, res := range results { // in the order of refs
		if res.Err != nil {
			fmt.Println(res.Ref.ID, "not found:", errors.Is(res.Err, polyzon.ErrNotFound))
			continue
		}
		fmt.Println(res.Ref.ID, res.Model.Title)
	}
}
Output:
tv-1234567 Дракон с подвижными сочленениями (пример)
tv-404 not found: true

func (*Client) Limits

func (c *Client) Limits(ctx context.Context) (*Limits, error)

Limits returns the account's per-minute and daily quota windows. It does not use the daily quota (it counts toward the per-minute limit like every method).

Example
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/PolyzonOrg/polyzon-go"
)

func main() {
	client, err := polyzon.NewClient()
	if err != nil {
		log.Fatal(err)
	}
	limits, err := client.Limits(context.Background())
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%d of %d daily requests left, resets at %s\n", limits.Daily.Remaining, limits.Daily.Limit,
		time.Unix(limits.Daily.ResetAt, 0).UTC().Format(time.RFC3339))
}
Output:
163 of 200 daily requests left, resets at 2026-10-10T00:00:00Z

func (*Client) Me

func (c *Client) Me(ctx context.Context) (*Me, error)

Me returns the account and the token used for the request. It does not use the daily quota.

func (*Client) Search

func (c *Client) Search(ctx context.Context, params SearchParams) (*SearchPage, error)

Search returns one page of models matching params. It costs one request of the daily quota (even when the API rejects the parameters).

Like every method, it retries connection errors, timeouts, HTTP 500/502/503/504 and 429 rate_limited up to the configured number of times (see WithMaxRetries), honouring Retry-After up to 60 seconds. A daily quota error (429 quota_exceeded) is returned at once.

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/PolyzonOrg/polyzon-go"
)

func main() {
	client, err := polyzon.NewClient()
	if err != nil {
		log.Fatal(err)
	}
	page, err := client.Search(context.Background(), polyzon.SearchParams{
		Query:   "дракон",
		Sort:    polyzon.SortPopular,
		Free:    true,
		Sources: []string{polyzon.SourceThingiverse, polyzon.SourcePrintables},
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("total:", page.Total)
	for _, item := range page.Items {
		fmt.Printf("%s/%s %s (%s)\n", item.Source, item.ID, item.Title, item.License)
	}
	fmt.Println("daily requests left:", page.Meta.RateLimit.Remaining)
}
Output:
total: 1873
thingiverse/tv-1234567 Дракон с подвижными сочленениями (пример) (cc-by-nc)
daily requests left: 163

func (*Client) SearchAll

func (c *Client) SearchAll(ctx context.Context, params SearchParams) iter.Seq2[SearchItem, error]

SearchAll iterates over all results of params, fetching pages lazily from params.Page (default 1) until a page reports no more results, comes back empty, or is page 100, the deepest the API serves. Every page costs one request of the daily quota; break out of the loop to stop fetching. On an error the iterator yields it once and stops.

for item, err := range client.SearchAll(ctx, polyzon.SearchParams{Query: "дракон", Size: 50}) {
	if err != nil {
		return err
	}
	fmt.Println(item.Title)
}

The API serves at most 2400 results per query (page × size).

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/PolyzonOrg/polyzon-go"
)

func main() {
	client, err := polyzon.NewClient()
	if err != nil {
		log.Fatal(err)
	}
	// Each page costs one request of the daily quota; break to stop early.
	for item, err := range client.SearchAll(context.Background(), polyzon.SearchParams{Query: "дракон", Size: 50}) {
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println(item.Title)
	}
}
Output:
Дракон с подвижными сочленениями (пример)

func (Client) String

func (c Client) String() string

String describes the client with the token masked to its first characters.

type ItemStats

type ItemStats struct {
	Likes    *int64 `json:"likes"`
	Comments *int64 `json:"comments"`
	Makes    *int64 `json:"makes"`
	// Collects counts saves to collections, or downloads where the source does
	// not count saves.
	Collects *int64 `json:"collects"`
}

ItemStats are the counters of a search item; nil means the source does not report the counter.

type License

type License struct {
	ID  string `json:"id"`  // a License* constant or another id
	Raw string `json:"raw"` // the license text as named by the source, or ""
}

License is the license of a model card.

type Limits

type Limits struct {
	PerMinute QuotaWindow `json:"perMinute"` // every method
	Daily     QuotaWindow `json:"daily"`     // search and model cards, UTC day
	Meta      Meta        `json:"-"`
}

Limits are the account's quota windows. Quotas are per account, shared by all its tokens.

type Me

type Me struct {
	Account Account   `json:"account"`
	Token   TokenInfo `json:"token"`
	Meta    Meta      `json:"-"`
}

Me describes the account and the token used for the request.

type Meta

type Meta struct {
	// RequestID is the X-Request-ID response header, or "".
	RequestID string
	// RateLimit is the daily quota window from the x-ratelimit-* headers, or nil
	// when the answer did not carry them.
	RateLimit *RateLimit
}

Meta is the metadata of a successful answer. It is not part of the JSON body, so it is excluded from JSON encoding of the result types.

type Model

type Model struct {
	Source      string     `json:"source"`
	ID          string     `json:"id"`
	Title       string     `json:"title"`
	URL         string     `json:"url"`
	Description string     `json:"description"`
	Thumbnail   string     `json:"thumbnail"`
	Images      []string   `json:"images"` // URLs on the source's CDN
	Creator     string     `json:"creator"`
	CreatorURL  string     `json:"creatorUrl"`
	License     *License   `json:"license"` // nil when unknown
	Price       *Price     `json:"price"`   // nil when unknown
	Formats     []string   `json:"formats"`
	Tags        []string   `json:"tags"`
	Category    string     `json:"category"`
	Stats       ModelStats `json:"stats"`
	PublishedAt string     `json:"publishedAt"` // ISO-8601 or ""
	UpdatedAt   string     `json:"updatedAt"`   // ISO-8601 or ""
	// Copies are the same model on other sources.
	Copies []ModelRef `json:"copies"`
	Meta   Meta       `json:"-"`
}

Model is a model card. Optional text fields are "" when unknown.

type ModelOption

type ModelOption func(*modelOptions)

ModelOption tunes Client.GetModel and Client.GetModels.

func WithConcurrency

func WithConcurrency(n int) ModelOption

WithConcurrency sets how many requests Client.GetModels runs at once (default 4, at least 1). Client.GetModel ignores it.

func WithLang

func WithLang(lang string) ModelOption

WithLang sets the language of the title, description and tags: LangRU (default) or LangEN.

type ModelRef

type ModelRef struct {
	Source string `json:"source"`
	ID     string `json:"id"`
}

ModelRef identifies a model: the source and the model id on it, as in polyzon.org/model/{source}/{id}.

type ModelResult

type ModelResult struct {
	Ref   ModelRef
	Model *Model
	Err   error
}

ModelResult is the outcome of one reference in Client.GetModels: exactly one of Model and Err is set.

type ModelStats

type ModelStats struct {
	Likes     *int64 `json:"likes"`
	Comments  *int64 `json:"comments"`
	Makes     *int64 `json:"makes"`
	Collects  *int64 `json:"collects"`
	Downloads *int64 `json:"downloads"`
	Views     *int64 `json:"views"`
}

ModelStats are the counters of a model card; nil means unknown.

type Option

type Option func(*settings) error

Option configures a Client in NewClient.

func WithAPIKey

func WithAPIKey(key string) Option

WithAPIKey sets the API token, overriding the POLYZON_API_KEY environment variable. Tokens are issued at https://polyzon.org/account/ (section «API»).

func WithBaseURL

func WithBaseURL(baseURL string) Option

WithBaseURL sets the API endpoint, overriding the POLYZON_BASE_URL environment variable and DefaultBaseURL. It must be https, or http on a loopback host (localhost, 127.0.0.1, [::1]) for local testing, without credentials, query or fragment.

func WithHTTPClient

func WithHTTPClient(httpClient *http.Client) Option

WithHTTPClient makes the client send requests through a copy of httpClient, for a custom transport (CA bundle, mTLS, tracing). The copy never follows redirects, whatever httpClient.CheckRedirect says, since following one could leak the token to another origin. It cannot be combined with WithProxy or WithMaxConnections: configure the transport of httpClient instead. An *http.Transport with InsecureSkipVerify is rejected: add a custom CA to RootCAs rather than disabling certificate verification.

Example
package main

import (
	"crypto/tls"
	"crypto/x509"
	"log"
	"net/http"
	"os"

	"github.com/PolyzonOrg/polyzon-go"
)

func main() {
	// Trust a corporate CA in addition to the system roots. TLS verification
	// itself cannot be turned off.
	pool, err := x509.SystemCertPool()
	if err != nil {
		log.Fatal(err)
	}
	pem, err := os.ReadFile("corporate-ca.pem")
	if err != nil {
		log.Fatal(err)
	}
	pool.AppendCertsFromPEM(pem)
	client, err := polyzon.NewClient(polyzon.WithHTTPClient(&http.Client{
		Transport: &http.Transport{
			Proxy:           http.ProxyFromEnvironment,
			TLSClientConfig: &tls.Config{RootCAs: pool},
		},
	}))
	if err != nil {
		log.Fatal(err)
	}
	_ = client
}

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger enables debug logging of requests: method, path with query, status, attempt, duration and retry delays. Headers, and so the token, are never logged. Records are emitted at slog.LevelDebug.

func WithMaxConnections

func WithMaxConnections(n int) Option

WithMaxConnections limits the connections to the API kept open at once (default 10). Requests above the limit wait for a free connection.

func WithMaxRetries

func WithMaxRetries(retries int) Option

WithMaxRetries sets how many times a failed request is retried (default 2); 0 disables retries. Retried are connection errors, timeouts, HTTP 500/502/503/504 and 429 rate_limited, after the server's Retry-After (a wait over 60 seconds is not retried) or an exponential backoff with jitter. A spent daily quota (429 quota_exceeded) is never retried.

func WithProxy

func WithProxy(proxyURL *url.URL) Option

WithProxy routes requests through the proxy at proxyURL: http://, https:// or socks5:// (socks5h:// resolves names on the proxy), with optional user:password for Basic proxy authentication. Requests to https:// endpoints are tunnelled with CONNECT, so the proxy never sees the token.

WithProxy(nil) disables proxies. Without this option the standard environment variables HTTPS_PROXY, HTTP_PROXY and NO_PROXY apply (loopback hosts are never proxied through them).

Example
package main

import (
	"log"
	"net/url"

	"github.com/PolyzonOrg/polyzon-go"
)

func main() {
	// Credentials in the URL become Basic proxy authentication. Requests to the
	// https API are tunnelled with CONNECT, so the proxy never sees the token.
	proxyURL, err := url.Parse("http://user:password@proxy.internal:3128")
	if err != nil {
		log.Fatal(err)
	}
	client, err := polyzon.NewClient(polyzon.WithProxy(proxyURL))
	if err != nil {
		log.Fatal(err)
	}
	_ = client
}

func WithTimeout

func WithTimeout(timeout time.Duration) Option

WithTimeout sets the time limit of one request attempt, including reading the answer. The default is 30 seconds (10 of them at most for connecting). Use the context for an overall deadline across retries.

func WithUserAgent

func WithUserAgent(suffix string) Option

WithUserAgent appends suffix (for example "my-bot/2.1") to the default User-Agent "polyzon-go/<version> (go <version>; <os>/<arch>)". The suffix must be 1–256 printable ASCII characters.

type Price

type Price struct {
	// Type is PriceFree, PricePaid or PriceOpen (pay what you want).
	Type string `json:"type"`
	// Value is the amount, or nil when unknown.
	Value *float64 `json:"value"`
	// Currency is an ISO 4217 code, or "".
	Currency string `json:"currency"`
	// Display is the price formatted for people.
	Display string `json:"display"`
}

Price is the price of a model.

type QuotaWindow

type QuotaWindow struct {
	Limit     int64 `json:"limit"`
	Used      int64 `json:"used"`
	Remaining int64 `json:"remaining"`
	ResetAt   int64 `json:"resetAt"` // start of the next window, Unix seconds
}

QuotaWindow is one quota window.

type RateLimit

type RateLimit struct {
	Limit     int64 // requests per UTC day
	Remaining int64 // requests left today
	ResetAt   int64 // next reset (00:00 UTC), Unix seconds
}

RateLimit is the daily quota window reported in the x-ratelimit-* headers.

type SearchItem

type SearchItem struct {
	Source    string `json:"source"`
	ID        string `json:"id"`
	Title     string `json:"title"`
	URL       string `json:"url"` // the model page on the source site
	Thumbnail string `json:"thumbnail"`
	Creator   string `json:"creator"`
	// License is a license id (the License* constants), or "".
	License string `json:"license"`
	// Price is nil when unknown.
	Price   *Price    `json:"price"`
	Formats []string  `json:"formats"`
	Tags    []string  `json:"tags"`
	Stats   ItemStats `json:"stats"`
	// PublishedAt is an ISO-8601 timestamp as reported by the source, or "".
	PublishedAt string `json:"publishedAt"`
}

SearchItem is a model in search results. Optional text fields are "" when the source does not provide them.

type SearchPage

type SearchPage struct {
	// Query is the query as the server understood it.
	Query string `json:"query"`
	// CorrectedQuery is the spelling-corrected query actually searched, or "".
	CorrectedQuery string `json:"correctedQuery"`
	// DidYouMean is a suggested alternative query, or "".
	DidYouMean string `json:"didYouMean"`
	// Total is the number of matching models (see TotalRelation).
	Total int64 `json:"total"`
	// TotalRelation is TotalEq when Total is exact, TotalGte when it is a lower bound.
	TotalRelation string `json:"totalRelation"`
	Page          int    `json:"page"`
	Size          int    `json:"size"`
	// HasMore reports whether a next page exists within the 2400-result window.
	HasMore bool `json:"hasMore"`
	// Partial reports that part of the catalogue did not answer in time, so the
	// results may be incomplete.
	Partial bool         `json:"partial"`
	Items   []SearchItem `json:"items"`
	Meta    Meta         `json:"-"`
}

SearchPage is one page of search results.

type SearchParams

type SearchParams struct {
	// Query is the search text in Russian or English, up to 200 characters. Required.
	Query string
	// Page is the page number, 1–100 (default 1).
	Page int
	// Size is the number of models per page, 1–50 (default 20). Page × Size must
	// not exceed 2400, else the API answers 400 page_too_deep.
	Size int
	// Sort is one of the Sort* constants (default SortRelevant).
	Sort string
	// Free restricts results to free models.
	Free bool
	// Sources restricts results to these sources (the Source* constants).
	Sources []string
	// Formats restricts results to these file formats, e.g. "stl", "3mf".
	Formats []string
	// Licenses restricts results to these license ids (the License* constants).
	Licenses []string
	// Lang is the language of titles and tags, LangRU (default) or LangEN.
	Lang string
}

SearchParams are the parameters of Client.Search. Zero values are not sent, so the server defaults apply.

type TokenInfo

type TokenInfo struct {
	ID        string `json:"id"`
	Name      string `json:"name"`
	Prefix    string `json:"prefix"`    // the first characters of the token, e.g. "pzk_Q7xR2mVb"
	CreatedAt string `json:"createdAt"` // ISO-8601
}

TokenInfo describes an API token (never the secret itself).

Directories

Path Synopsis
examples
batch command
Command batch fetches the model cards of a search page concurrently with GetModels and reports each result in input order.
Command batch fetches the model cards of a search page concurrently with GetModels and reports each result in input order.
pagination command
Command pagination walks every page of a search with SearchAll.
Command pagination walks every page of a search with SearchAll.
proxy command
Command proxy calls the API through an explicit proxy (http, https or socks5, with optional user:password) and prints the account the token belongs to.
Command proxy calls the API through an explicit proxy (http, https or socks5, with optional user:password) and prints the account the token belongs to.
quickstart command
Command quickstart searches the Polyzon catalogue, opens the first model card and prints the remaining daily quota.
Command quickstart searches the Polyzon catalogue, opens the first model card and prints the remaining daily quota.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL