Help us shape the future of Go! Take the 2026 Go Developer Survey

api

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 16, 2026 License: GPL-3.0 Imports: 24 Imported by: 0

Documentation

Overview

Package api is version 1 of the sim telemetry API: the contract a telemetry client talks to, exactly as docs/API-V1.md defines it.

The shapes on the wire are github.com/pacenote-sim/protocol/wire's and the trace codec is github.com/pacenote-sim/protocol/trace's. Nothing is redefined here — a type that crosses between the client and this server lives in one module that both are built against, which is what keeps two programs written in separate repositories interoperable.

The rules that hold on every route

  • Everything except discovery and pairing carries a bearer device token. The lookup is by the token's clear-text prefix and the decision is a constant-time comparison of digests.
  • Every non-2xx response is the wire.ErrorEnvelope, with one of the eight stable codes. The message inside is shown to the driver exactly as written, so it is a complete sentence with no jargon and no identifier.
  • Every authenticated POST and PUT carries an Idempotency-Key. Three of them are backed by the idempotency table and replay their stored answer; see API.Routes for which, and why the others are not.
  • Rate limiting is a token bucket per device token under a global ceiling, built from the same wire.Limits that discovery serves, so the client's intervals and the server's limiter cannot drift apart (D-7).
  • Live samples and field reports are held in memory and never written to the database (D-5). A sample two seconds old is worthless, and persisting it would cost a write per second per driver for nothing.

Degrade, do not fail

An optional feature that this installation does not have is named in neither wire.Discovery.Features nor wire.Me.Features, and its endpoint answers wire.CodeForbidden rather than 404. The difference matters to a client: a 404 is a server it does not understand, and a forbidden feature is a button it turns off. The features are the core's own and follow nothing an operator configures; what is installed beside the core is not a feature at all but a list of plugins and their routes on GET /me, and a client decides what it knows how to talk to.

Golden fixtures

testdata/golden holds one request and one response document for every endpoint and every error code. They are committed so that the capture client, which is built in another repository by people who cannot see this code, can assert against the same bytes this server produces. Regenerate them with

go test ./internal/api -update

and read what that means before doing it: a fixture that changes is a change to the contract.

Index

Constants

View Source
const (
	ReadsPerSecond  = 5.0
	ReadBurst       = 30.0
	WritesPerSecond = 5.0
	WriteBurst      = 60.0
)

The rates this server sets for itself, for the classes the contract does not give an interval to. They are per device and per second.

Writes are generous on purpose: the intervals in discovery govern a client that is connected, and a client that has been offline for a session drains its whole queue the moment it reconnects. Refusing that drain would be refusing the case the offline queue exists for.

View Source
const (
	GlobalReadsPerSecond  = 500.0
	GlobalWritesPerSecond = 200.0
	GlobalLivePerSecond   = 500.0
	GlobalFieldPerSecond  = 100.0
	GlobalOtherPerSecond  = 50.0
)

The ceilings over every device at once, per second and in burst. They are what stops a flood spread across many tokens, which a per-device bucket cannot see.

View Source
const (
	MaxNameLen   = 200
	MaxShortLen  = 64
	MaxSectors   = 64
	MaxLapNumber = 100_000
	MaxLapMs     = 3_600_000 // one hour, the same bound the trace codec puts on t
	MaxSpeedKmh  = 1_000
	MaxIncidents = 100_000
	// MaxCornersPerLap bounds a lap's corner analysis. No circuit has sixty-four
	// corners, and the client's own widest setting reports ninety-nine, so this
	// is wider than any real lap and narrow enough that the column cannot be
	// used as storage.
	MaxCornersPerLap = 64
	// MaxSetupValues bounds the named values of a setup sheet, and
	// MaxSetupTextLen the length of one setting's group, name, text or unit. A
	// real iRacing sheet is sixty to ninety lines of a few words each.
	MaxSetupValues  = 256
	MaxSetupTextLen = 200
	// MaxSetupUpdateCount bounds the simulator's own revision counter for the
	// sheet. A driver who changed their setup a million times in one sitting
	// did not.
	MaxSetupUpdateCount = 1_000_000
	// MaxSetupKpa, MaxSetupTempC and the tread percentage bound the normalised
	// core. Each is far outside what a tyre does and inside what the column can
	// mean.
	MaxSetupKpa   = 1_000
	MaxSetupTempC = 1_000
)

The bounds this server puts on the fields the contract leaves open. Each is far wider than any honest value and narrow enough that a column cannot be used as storage: a text field is a name, not a place to keep a document.

View Source
const DefaultScope = wire.ScopeClass

DefaultScope is what GET /reference uses when the client names none. It is the widest, because an unqualified request means "the best lap you can give me" and the fallback below narrows it to whatever this server actually holds.

View Source
const DeviceLabelMax = 80

DeviceLabelMax is how much of a client's User-Agent is kept as the label a driver sees against a paired machine. It is the only thing the pairing flow learns about a machine, and the contract sends nothing else.

View Source
const DiscoveryMaxAge = 5 * 60

DiscoveryMaxAge is how long a client may keep the discovery document. Five minutes is what the contract says, and it is the right answer: the document changes when an operator changes a setting, and a driver who is mid-session when that happens should not be re-fetching it every lap to find out.

View Source
const FieldMaxCars = 200

FieldMaxCars caps one report. A full grid is forty-odd cars; this is well above any of them and is here so that a body inside the size limit cannot still be a hundred thousand entries.

View Source
const FieldMaxSessions = 256

FieldMaxSessions caps how many sessions are relayed at once, for the same reason LiveMaxStints does.

View Source
const FieldQuietPeriod = 3

FieldQuietPeriod is how long an armed relay may go silent before another client may take over. Three times the published interval: long enough that a dropped packet does not change who is authoritative, short enough that a driver who quits mid-session does not hold the relay for the rest of it.

View Source
const IdempotencyHeader = "Idempotency-Key"

IdempotencyHeader is the header every authenticated write carries. It is what makes the client's offline queue safe: because a retry is free, a lap upload that failed for any reason can simply be sent again.

View Source
const IdempotencyKeyMax = 200

IdempotencyKeyMax is the longest key this server stores. A UUID is 36 characters and the contract does not say what a key looks like beyond being unique per request, so the cap is generous and exists only to keep a table row bounded.

View Source
const IntervalBurst = 4.0

IntervalBurst is the slack on the three classes whose rate comes from discovery. A client that sends at exactly the published interval must never be refused, so the bucket has to absorb the jitter of a timer and a network; four calls is that and no more.

View Source
const LiveMaxStints = 1024

LiveMaxStints caps how many stints are held at once, so a flood of identifiers cannot grow the map without bound. It is far above any team's concurrent drivers; the oldest entry is dropped when it is reached.

View Source
const LiveTTL = 30 * time.Second

LiveTTL is how long a live sample is worth anything. Past it the entry is swept: a driver whose client stopped sending is not on track, and showing their last known position for the rest of the afternoon would be a lie.

View Source
const MinClient = "1.0.0"

MinClient is the oldest client version this build accepts, compared against the X-Client-Version header and published as wire.Discovery.MinClient. A client older than it is told wire.CodeClientTooOld and stops uploading.

View Source
const PairBurst = 10.0

PairBurst is how many pairing calls one address may make at once. It covers a client that starts a pairing and polls immediately, several times over, and it is small enough that walking the user-code space is not worth beginning.

View Source
const PairIntervalS = 2

PairIntervalS is the floor a client must not poll faster than, published as wire.PairStart.IntervalS and enforced by the limiter, so the number a client is given and the number it is held to are the same number.

View Source
const Prefix = config.APIBase

Prefix is where version 1 is mounted. It is the value discovery publishes as wire.Discovery.API, taken from one constant so the document and the routes cannot disagree.

View Source
const SweepInterval = time.Hour

SweepInterval is how often the two tables that hold short-lived rows are cleared out. Neither expiry is enforced by the sweep — the lookups check the clock — so this is housekeeping, and an hour is often enough that a week away does not leave a table full of last week.

Variables

View Source
var (
	// ErrNoToken is a request with no Bearer credential at all. On the API it
	// is a refusal; on a plugin route it is the common case — a browser — and
	// not a failure.
	ErrNoToken = errors.New("api: no device token")
	// ErrBadToken is a token this server did not issue: not shaped like one,
	// or shaped like one and held by nobody. The two are one answer on
	// purpose, so the shape of the refusal tells a guesser nothing.
	ErrBadToken = errors.New("api: that token is not valid on this server")
	// ErrRevokedToken is a token the operator took back.
	ErrRevokedToken = errors.New("api: that device has been removed")
)

The ways a token fails, for a caller that has to say which.

EnterpriseFeatures are the ones this build does not have at all. They are named rather than merely absent so that the line is visible in the code: community is about one driver getting faster, and both of these are about a group being organised.

  • field: the whole-session relay feeding a broadcast timing view.
  • competition: championships, qualifying sessions, results.

Their endpoints are built and gated rather than missing, so a client meets wire.CodeForbidden and turns a button off instead of meeting a 404 and deciding it is talking to a server it does not understand.

View Source
var ErrNoLiveSample = errors.New("api: no live sample for that stint")

ErrNoLiveSample reports that nothing recent is held for a stint.

Functions

func Discovery

func Discovery(settings config.Settings, features []wire.Feature) wire.Discovery

Discovery renders the document without serving it, for the admin panel and for the tests that assert the contract against a golden fixture.

func Entitled

func Entitled(server []wire.Feature, _ int64) []wire.Feature

Entitled narrows a server's features to what one driver may use. The community edition has one organisation and no paid tiers, so every driver is entitled to everything the server has and the intersection is the whole list.

It exists as a seam rather than as an if: GET /me publishes the intersection and not the server's list, and a build that grows entitlements changes this function and nothing else.

func Features

func Features() []wire.Feature

Features is what this installation has, in the order a client reads them. It is the community edition's answer and the default for Deps.Features.

All three are unconditional, because recording one driver's laps and comparing them against a reference is what this server is for and needs nothing configured. Nothing here follows a plugin: a plugin is not a feature, and what is installed is a different question with a different answer — wire.Me.Plugins, built by [API.installed]. The enterprise features are absent from this build rather than off.

Types

type API

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

API serves version 1.

func New

func New(ctx context.Context, d Deps) (*API, error)

New builds the API. It reads the settings once so that the first request does not pay for them, and it is not an error if that read fails: the settings are re-read on the next request that needs them.

func (*API) DriverByBearer added in v0.2.0

func (a *API) DriverByBearer(ctx context.Context, r *http.Request) (db.Driver, error)

DriverByBearer is the driver whose device token the request carries. It is for the plugin routes: a client that talks to a plugin's own address authenticates the way it does to this API, the plugin is told who, and the token itself never reaches the plugin. The device is recorded as used, as it is for an upload.

ErrNoToken is a request with no Bearer credential — a browser, usually — and the caller treats it as nobody rather than as an error.

func (*API) Field

func (a *API) Field(trackID, sessionType string) (wire.FieldReport, time.Time, bool)

Field returns the latest whole-field report for one session, and when it arrived, for a timing view to read. Like the live samples it is held in memory only, so it is this instance's answer and nothing else's.

func (*API) Invalidate

func (a *API) Invalidate()

Invalidate drops the held settings so that the next request reads them again. It is what makes a change in the admin panel take effect without a restart: the panel writes, calls this, and the following request rebuilds the features and the discovery document from what is now stored.

It deliberately does not touch the limiter. Emptying the buckets on every settings change would forgive a client that was sending too fast each time an operator saved a form, so the limiter is rebuilt in [API.current] and only when the published limits themselves have changed.

func (*API) Live

func (a *API) Live(id db.UUID) (LiveSample, error)

Live returns the latest sample for a stint, or ErrNoLiveSample.

func (*API) Routes

func (a *API) Routes(mux *http.ServeMux)

Routes registers version 1 and the discovery document on a mux. It does not own the mux: the same server carries the admin panel.

Which writes are replayed, and which are not:

  • PUT /stints/{id}, POST /stints/{id}/laps and PUT /stints/{id}/summary are backed by the idempotency table. They are what the client's offline queue drains, so a retry has to be safe however it failed the first time.
  • POST /field requires the header and is not replayed. A field report is a snapshot of this instant, and replaying a stale one would be worse than refusing.
  • POST /live carries no key. The contract's own exception: it is never retried, so a key on a call made once a second would be overhead for a guarantee nobody uses.
  • Pairing is unauthenticated, so there is no device to scope a key to. The device code is the idempotency of that flow: polling twice is safe because the grant records the machine it produced.

func (*API) Sweep

func (a *API) Sweep(ctx context.Context, every time.Duration)

Sweep clears out expired idempotency keys and finished pairings until ctx is cancelled. It is a goroutine the caller owns, and it returns when the context does.

Nothing depends on it having run. The idempotency lookup and the pairing poll both check expiry themselves, so a server whose sweep never runs is correct and merely larger than it needs to be.

type Class

type Class int

Class is one kind of call, for rate limiting. A class has its own bucket per device and its own ceiling over every device at once, because the intervals the contract gives them differ by three orders of magnitude: a live sample is a second apart and a summary is half a minute.

const (
	// ClassRead is GET /me and GET /reference.
	ClassRead Class = iota
	// ClassWrite is PUT /stints/{id} and POST /stints/{id}/laps.
	ClassWrite
	// ClassLive is POST /live.
	ClassLive
	// ClassField is POST /field.
	ClassField
	// ClassSummary is PUT /stints/{id}/summary.
	ClassSummary
	// ClassPair is POST /pair/start and POST /pair/poll, which are
	// unauthenticated and so are limited by address instead of by device.
	ClassPair
)

The rate-limited classes.

type Deps

type Deps struct {
	// Log receives the API's own lines. A token is never one of them.
	Log *slog.Logger
	// Store is the database.
	Store *db.Store
	// Now is the clock, injectable for tests.
	Now func() time.Time
	// Features computes what this installation has. nil means [Features], which
	// is the community edition's answer; it is a field so that a build with
	// more in it changes one function rather than every gate.
	Features func() []wire.Feature
	// OnRequest is called when a route finishes, with the route pattern rather
	// than the path so that a metric does not grow a series per driver. nil
	// means nothing is measured.
	OnRequest func(route string, status int, took time.Duration)
	// Plugins receives the events plugins react to — a lap completed, a stint
	// finished — and, when it also implements [Running], says what is running,
	// which is what GET /me tells a client. nil is a server with no plugin
	// host, which is every server built without one and every test that does
	// not care; it has no plugins and says so.
	Plugins EventSink
}

Deps is everything the API needs from the rest of the server.

type EventSink

type EventSink interface {
	// Notify dispatches an event. It must not block: an upload finishing is
	// not allowed to wait on an integration.
	Notify(ctx context.Context, e plugin.Event)
}

EventSink is where the API publishes the things plugins react to. It is an interface rather than the host itself because this package must not depend on how plugins are run, and because a server built without plugins passes nil and nothing else changes.

type Limiter

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

Limiter is the whole server's rate limiting: a token bucket per device per class, each under a ceiling shared by every device.

It is built from the wire.Limits discovery serves, so the intervals a client is told to obey and the intervals this refuses are the same constants. A client that honours the document is never refused.

func NewLimiter

func NewLimiter(l wire.Limits) *Limiter

NewLimiter builds the limiter for one set of published limits.

func (*Limiter) Allow

func (l *Limiter) Allow(c Class, key string) (ok bool, retryAfterS int)

Allow reports whether one call of this class from this key may proceed, and when to try again if it may not. The key is the device's token prefix for an authenticated call and the caller's address for pairing.

type LiveSample

type LiveSample struct {
	DriverID int64
	At       time.Time
	Sample   wire.LiveSample
}

LiveSample is the latest sample held for one stint, with the driver it belongs to and when it arrived. It is what a live view reads.

type Running added in v0.2.0

type Running interface {
	// Running is the manifest of every plugin running right now.
	Running() []plugin.Manifest
}

Running is a plugin host that can say what is running. It is the second thing the API asks of Deps.Plugins, beside receiving events, and it is optional for the same reason EventSink is an interface: this package must not depend on how plugins are run, and a server built without a host has nothing running and says so.

Jump to

Keyboard shortcuts

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