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 ¶
- func WriteLimitError(w http.ResponseWriter, err error) bool
- type Counter
- type DBEnforcer
- func (e *DBEnforcer) CheckAgentCreate(ctx context.Context, userID string) error
- func (e *DBEnforcer) CheckDomainCreate(ctx context.Context, userID string) error
- func (e *DBEnforcer) CheckMessageSend(ctx context.Context, userID string) error
- func (e *DBEnforcer) Get(ctx context.Context, userID string) (Limits, error)
- func (e *DBEnforcer) Invalidate(userID string)
- type Defaults
- type Enforcer
- type LimitErrorBody
- type LimitExceededError
- type Limits
- type Store
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) 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 (*Store) Get ¶
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 ¶
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.