limits

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Jun 4, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package limits enforces per-user resource caps at the agent-create, domain-register, and message-send paths.

The OSS server is intentionally agnostic about what those caps mean. A row in account_limits is the contract; whatever populates it (the hosted-service sidecar, an admin tool, a self-host operator with SQL access) owns the semantics of plan_code and upgrade_url. The OSS server only enforces the integer caps and echoes the opaque label.

When no row exists for a user, the enforcer falls back to the operator-configured Defaults (config.yaml `limits:` block). Self-host operators who do not want any cap can leave Defaults at MaxInt32.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func WriteLimitError

func WriteLimitError(w http.ResponseWriter, err error) bool

WriteLimitError writes a 402 Payment Required response with the LimitErrorBody payload. Centralized here so every Check* call site in HTTP handlers can convert the typed error uniformly:

if err := enforcer.CheckAgentCreate(ctx, userID); err != nil {
    if limits.WriteLimitError(w, err) { return }
    // some other DB error — propagate as 5xx
}

Returns true when err was a *LimitExceededError and a 402 was written; false when the error was something else (caller must handle it themselves, typically as 5xx).

Types

type Counter

type Counter interface {
	CountAgentsByUser(ctx context.Context, userID string) (int, error)
	CountDomainsByUser(ctx context.Context, userID string) (int, error)
	MessagesThisMonth(ctx context.Context, userID string) (int, error)
	GetStorageBytes(ctx context.Context, userID string) (int64, error)
}

Counter is the subset of *usage.Store the enforcer needs. Declared here as an interface so tests can supply a fake without standing up the full usage store.

type DBEnforcer

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

DBEnforcer is the production Enforcer: reads account_limits + falls back to operator Defaults, counts current resources via the usage store, and caches the resolved Limits in-process for cacheTTL to keep hot paths off the DB on every send.

The cache only stores the *Limits* (caps), not the *counts*. Counts must always reflect the live database — caching them would mean a just-created agent or just-sent message wouldn't show up until TTL expiry, which would either let users exceed caps or let the dashboard lie about current usage. Reading the count is one bounded query per check; the win from caching limits is avoiding the join into account_limits, which is the costlier read.

func NewEnforcer

func NewEnforcer(store *Store, counter Counter, defaults Defaults, cacheTTL time.Duration) *DBEnforcer

NewEnforcer constructs the production enforcer. cacheTTL of 0 disables the cache (every Get hits the DB) — useful for tests that mutate account_limits and want immediate visibility.

func (*DBEnforcer) CheckAgentCreate

func (e *DBEnforcer) CheckAgentCreate(ctx context.Context, userID string) error

func (*DBEnforcer) CheckDomainCreate

func (e *DBEnforcer) CheckDomainCreate(ctx context.Context, userID string) error

func (*DBEnforcer) CheckMessageSend

func (e *DBEnforcer) CheckMessageSend(ctx context.Context, userID string) error

CheckMessageSend enforces both the month-flow cap and the storage stock cap. Either being exceeded blocks the operation. The flow cap is checked first because it's the cheaper read; if both would fail, the user sees "messages" as the reason, which is the easier one to explain ("you sent N this month").

func (*DBEnforcer) Get

func (e *DBEnforcer) Get(ctx context.Context, userID string) (Limits, error)

func (*DBEnforcer) Invalidate

func (e *DBEnforcer) Invalidate(userID string)

type Defaults

type Defaults struct {
	PlanCode         string
	MaxAgents        int
	MaxDomains       int
	MaxMessagesMonth int
	MaxStorageBytes  int64
}

Defaults is the operator-configured fallback applied when a user has no account_limits row. It is also returned verbatim from Get for brand-new users so the dashboard has something to render.

type Enforcer

type Enforcer interface {
	// Get returns the resolved Limits for the user. Never returns
	// ErrLimitExceeded; that error is reserved for the Check methods.
	Get(ctx context.Context, userID string) (Limits, error)

	// CheckAgentCreate returns nil if the user may create another agent,
	// or *LimitExceededError if they have already hit the cap.
	CheckAgentCreate(ctx context.Context, userID string) error

	// CheckDomainCreate returns nil if the user may register another
	// domain, or *LimitExceededError if they have already hit the cap.
	CheckDomainCreate(ctx context.Context, userID string) error

	// CheckMessageSend returns nil if the user may send/receive another
	// message this calendar month, or *LimitExceededError if they have
	// already hit the cap. Counts inbound+outbound in the current UTC
	// month against MaxMessagesMonth.
	CheckMessageSend(ctx context.Context, userID string) error

	// Invalidate evicts the user's cached Limits so the next Get/Check
	// re-reads from the database. Called by the limits-invalidate HTTP
	// endpoint when an external writer (e.g. billing sidecar) has just
	// updated account_limits.
	Invalidate(userID string)
}

Enforcer is the interface that handlers call. Implementations must be safe for concurrent use.

type LimitErrorBody

type LimitErrorBody struct {
	Error    string `json:"error"`    // human-readable message
	Resource string `json:"resource"` // "agents" | "domains" | "messages" | "storage"
	// Limit + Current are RAW counts in the resource's natural unit:
	// integer count for agents/domains/messages, **bytes** for storage.
	// SDK consumers should treat them as opaque and format per
	// resource. An earlier revision returned storage values in KB,
	// which is inconsistent with every other resource — fixed in
	// favor of bytes-everywhere.
	Limit      int    `json:"limit"`
	Current    int    `json:"current"`
	PlanCode   string `json:"plan_code"`   // opaque label from account_limits
	UpgradeURL string `json:"upgrade_url"` // optional URL to surface in the dashboard
}

LimitErrorBody is the JSON payload returned with HTTP 402 when a Check* call surfaces a *LimitExceededError. Handlers MUST use this shape so the dashboard and SDK clients can render a uniform "you hit a cap, here's how to upgrade" affordance regardless of which limit fired. plan_code is the opaque label written by the external limits provisioner; upgrade_url is empty when no provisioner has supplied one.

type LimitExceededError

type LimitExceededError struct {
	Resource string // "agents" | "domains" | "messages"
	Limit    int    // the cap that was hit
	Current  int    // the user's current count (counts vary by resource)
	Limits   Limits // full resolved limits for upgrade-URL rendering
}

LimitExceededError is returned by Check* methods when the user has reached a cap. Handlers convert it to HTTP 402 with the Limits payload so the dashboard can show the current cap and any upgrade affordance.

func IsLimitExceeded

func IsLimitExceeded(err error) (*LimitExceededError, bool)

IsLimitExceeded reports whether err is a *LimitExceededError, and returns it if so. Callers convert the typed error to HTTP 402.

func (*LimitExceededError) Error

func (e *LimitExceededError) Error() string

type Limits

type Limits struct {
	PlanCode         string `json:"plan_code"`
	MaxAgents        int    `json:"max_agents"`
	MaxDomains       int    `json:"max_domains"`
	MaxMessagesMonth int    `json:"max_messages_month"`
	MaxStorageBytes  int64  `json:"max_storage_bytes"`
	UpgradeURL       string `json:"upgrade_url"`
}

Limits is the resolved per-user cap set: either read from account_limits or filled from operator Defaults when no row exists.

type Store

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

Store wraps account_limits row access. Writes are intended for external provisioners (the hosted billing sidecar, admin tooling); the OSS server itself only reads.

func NewStore

func NewStore(pool *pgxpool.Pool) *Store

func (*Store) Get

func (s *Store) Get(ctx context.Context, userID string) (Limits, bool, error)

Get returns the user's row from account_limits. Returns (Limits{}, pgx.ErrNoRows, false) when no row exists so callers can apply operator-configured defaults — distinguishing "no row" from "real DB error" is important: a transient DB hiccup must fail closed, while a genuinely-missing row is the normal case for a fresh user.

func (*Store) Upsert

func (s *Store) Upsert(ctx context.Context, userID string, l Limits) error

Upsert writes/overwrites the user's row. Only used by external provisioners; not invoked from any OSS request path. Exposed here so the future internal-limits-invalidate endpoint can also serve as a minimal "set limits" API for the sidecar without that code reaching into the table directly.

Jump to

Keyboard shortcuts

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