20 · The configurable-limits pattern¶
Objectives: Understand how a
Configstruct with named preset constructors gives callers safe defaults while still allowing tuning without recompiling. See howvalidateInputand sentinel errors turn silent failures into explicit, testable contracts. Learn when to reach forStrictConfigvsUnlimitedConfig. Estimated time: 15 minutes.
What this actually means (plain English)¶
No jargon — here's what the ideas in this lesson actually mean, and why they matter.
Configstruct = "a thermostat panel installed before the boiler ever turns on." It is a plain Go struct whose exportedintfields declare exactly how much work the parser is allowed to do — set once, enforced on every call.DefaultConfig= "the factory setting that ships with the appliance." It works for most callers (100 MB, 1 M tokens) without requiring them to think about limits at all; they just call the function and get safe behaviour.StrictConfig= "the tamper-proof lock a landlord installs on the thermostat." Lower caps (10 MB, 100 K tokens) in the same shape — drop it in whenever input arrives from the internet or an untrusted file and you don't want a caller accidentally raising the ceiling.UnlimitedConfig= "the master-key override that lives in the facilities closet." It sets every limit to0so theMaxInputSize > 0guard is skipped entirely; opting out of limits is a conscious, named act in source code, never an accident.Validate()vsvalidateInput()= "the pre-flight checklist vs the boarding-gate scanner."Validate()runs at construction and rejects bad Config values (negative limits are programming errors);validateInput()runs per call and rejects oversized payloads at runtime.- Sentinel errors (
ErrInputTooLarge,ErrTooManyTokens) = "a labelled circuit breaker instead of a melted fuse." Because they are package-levelerrors.Newvalues, callers useerrors.Is()to match the exact failure reason and map it to an HTTP status — no string parsing required.
Why it matters: A library that is unsafe by default will eventually be called with default settings on untrusted data — and that is how OOM crashes and denial-of-service bugs happen in production.
See it — the three preset constructors and the two-stage validation gate.
The Config struct¶
Both jsmn-go and tinyxml2-go follow the same pattern. Here is the JSON
tokenizer's version, from jsmn-go/config.go (a
"package" here just means a named, reusable folder of Go source files — think
of it as one labelled toolbox that other code can borrow from; a "module" is
the whole project the packages live in). The Config struct you're about to
read is a labeled bundle of related values — like a form with named blanks —
grouped together under one name so code can pass them around as a single unit
instead of as separate loose variables; each named blank inside it (like
MaxInputSize below) is called a "field," and int just means that field's
blank only ever holds a whole number:
// Config holds parsing configuration and limits.
type Config struct {
// MaxInputSize limits the maximum JSON input size in bytes.
// Default: 100MB. Set to 0 for unlimited (not recommended).
MaxInputSize int
// MaxTokens limits the maximum number of tokens that can be parsed.
// Default: 1,000,000. Set to 0 for unlimited (not recommended).
MaxTokens int
// InitialTokenCapacity is the initial capacity for the token slice.
// Default: inputSize / 4. The slice will grow automatically if needed.
InitialTokenCapacity int
// ParallelThreshold is the minimum input size (in bytes) to enable parallel parsing.
// Default: 4KB.
ParallelThreshold int
}
In plain terms: this is just a labeled form with four numeric blanks — the maximum input size in bytes (a "byte" is one unit of stored data, roughly one character of text; kilo/mega-byte prefixes mean a thousand/million of those), the maximum number of tokens allowed, how much space to set aside up front for tokens, and the size threshold above which the parser splits the work across multiple workers running at the same time.
And the XML parser's version, from tinyxml2-go/config.go:
type Config struct {
MaxInputSize int // bytes
MaxNodeCount int // total element nodes
MaxNestingDepth int // how many levels deep elements may nest
}
Notice the shape is the same — a plain struct, all exported fields (in Go,
capitalizing a field's name, as every field here is, is what makes it visible
and usable to code outside this package — an "exported" field, as opposed to
one only this package's own code could see), all int.
No interface magic (an "interface" is a way of saying "anything that can do
these actions qualifies," without pinning down exactly what it is — not needed
here), no build tags (special markers that would compile different versions
of the code for different situations). A caller (the code elsewhere that runs
these functions) can read the field names and
understand the contract without looking at documentation.
MaxNestingDepth is the field that prevents billion-laughs / stack-exhaustion
attacks: deeply nested XML causes the parser to recurse — that is, to call
itself again on each inner layer it finds, layer after layer. Without a ceiling the
runtime panics (crashes with an error partway through, instead of finishing normally) with a stack overflow (it runs out of the reserved space set aside for tracking
all those nested calls) that recover (a Go mechanism for catching a panic
before it takes down the whole program) cannot catch (see
Lesson 15). The tinyxml2-go parser enforces a hard
ceiling of 10 000 regardless of this field — Config just sets the
caller-visible soft limit.
Three preset constructors¶
Each module ships three constructor functions — a "function" is a named,
reusable block of instructions (like a recipe you can run whenever you need
its result); a "constructor" is simply a function whose job is to build and
hand back a ready-to-use value of some type, here a *Config. From jsmn-go/config.go:
func DefaultConfig() *Config {
return &Config{
MaxInputSize: 100 * 1024 * 1024, // 100MB
MaxTokens: 1_000_000,
InitialTokenCapacity: 0, // auto-calculated
ParallelThreshold: 4 * 1024, // 4KB
}
}
func StrictConfig() *Config {
return &Config{
MaxInputSize: 10 * 1024 * 1024, // 10MB
MaxTokens: 100_000,
InitialTokenCapacity: 0,
ParallelThreshold: 4 * 1024,
}
}
func UnlimitedConfig() *Config {
return &Config{
MaxInputSize: 0, // unlimited
MaxTokens: 0, // unlimited
InitialTokenCapacity: 0,
ParallelThreshold: 4 * 1024,
}
}
In plain terms: each of these three functions fills out one of those
labeled forms with a different set of numbers and hands the filled-out form
back — "return" (also called "invoke" or "call" when you run a function to
get that result) means the function finishes its work and passes its result
back to whichever piece of code asked for it. DefaultConfig hands back
generous everyday numbers, StrictConfig hands back tight, cautious numbers,
and UnlimitedConfig hands back all zeros, meaning "no limit at all."
The same trio appears in tinyxml2-go/config.go with matching field names and
comments. Consistent naming across modules matters: once a developer learns this
pattern in jsmn-go, they can navigate tinyxml2-go without reading a new
README.
Pick the right preset
- Internal tooling, trusted files →
DefaultConfig()(100 MB, 1M tokens). - API endpoint, user-uploaded data →
StrictConfig()(10 MB, 100K tokens/nodes). - Offline batch pipeline where you own the data →
UnlimitedConfig()— but name it explicitly so the next reader knows the choice was deliberate.
Validate: catching bad Config at construction time¶
Config.Validate() runs before any parsing work. The (c *Config) right
before the function name below makes this a "method" — a function that is
attached to a particular struct, so it can reach into that struct's own
fields (here, checking c's fields directly) instead of being handed them
separately. From jsmn-go/config.go:
func (c *Config) Validate() error {
if c.MaxInputSize < 0 {
return errors.New("MaxInputSize cannot be negative")
}
if c.MaxTokens < 0 {
return errors.New("MaxTokens cannot be negative")
}
if c.InitialTokenCapacity < 0 {
return errors.New("InitialTokenCapacity cannot be negative")
}
if c.ParallelThreshold < 0 {
return errors.New("ParallelThreshold cannot be negative")
}
return nil
}
In plain terms: this method walks through each numeric field in the
Config form and, if any of them is a negative number (which makes no sense
for a size limit), it hands back a description of what's wrong instead of
letting the program continue. If every field looks sane, it hands back
nil — Go's way of saying "nothing went wrong."
tinyxml2-go/config.go mirrors this for its three fields. The rule is: a 0
means "unlimited" (the caller opted out intentionally), but a negative number
is a programming error — fail loudly at construction, not silently at parse
time.
validateInput: enforcing limits per call¶
Validate() checks the Config itself. validateInput() checks the actual bytes
handed in. From jsmn-go/config.go:
func (c *Config) validateInput(data []byte) error {
if len(data) == 0 {
return ErrEmptyInput
}
if c.MaxInputSize > 0 && len(data) > c.MaxInputSize {
return ErrInputTooLarge
}
return nil
}
In plain terms: data []byte means the input handed to this method is a
"slice" — an ordered, resizable list — of raw bytes, i.e. the actual file
content to be checked. len(data) counts how many bytes are in it. The
method rejects an empty input outright, then rejects an input bigger than
the configured ceiling — unless that ceiling is 0, which (as the next
paragraph explains) means "no limit."
tinyxml2-go/config.go is identical in structure:
func (c *Config) validateInput(data []byte) error {
if len(data) == 0 {
return ErrEmptyInput
}
if c.MaxInputSize > 0 && len(data) > c.MaxInputSize {
return ErrInputTooLarge
}
return nil
}
The c.MaxInputSize > 0 guard is the "unlimited" opt-out path: when the caller
passes UnlimitedConfig(), MaxInputSize is 0, so the size check is skipped
entirely. This avoids a footgun where 0 accidentally means "reject everything".
Sentinel errors¶
Both modules declare their errors as package-level variables — named values
created once, up front, that any function in the package can refer to,
rather than being built fresh each time they're needed. "Sentinel" here just
means each one acts as a recognizable marker for one specific failure
reason. From
jsmn-go/config.go:
var (
ErrInputTooLarge = errors.New("input size exceeds maximum allowed")
ErrTooManyTokens = errors.New("token count exceeds maximum allowed")
ErrEmptyInput = errors.New("empty input")
)
In plain terms: errors.New(...) builds one reusable error value
carrying that text message. Because each of these three is created once and
given its own name, other code (as shown just below) can check "is this the
exact same error I'm thinking of?" instead of having to read and compare the
message text.
From tinyxml2-go/config.go:
var (
ErrInputTooLarge = errors.New("input size exceeds maximum allowed")
ErrTooManyNodes = errors.New("node count exceeds maximum allowed")
ErrNestingTooDeep = errors.New("nesting depth exceeds maximum allowed")
ErrEmptyInput = errors.New("empty input")
)
Because these are plain errors.New values (not custom types), callers check
them with errors.Is:
tokens, err := jsmngo.ParseWithConfig(ctx, data, jsmngo.StrictConfig())
if errors.Is(err, jsmngo.ErrInputTooLarge) {
http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
return
}
In plain terms: this runs ParseWithConfig (in plain terms, "runs" here
means the same thing as "calls" or "invokes" — you ask a function to do its
work right now) and gets back two results — the parsed tokens and, possibly,
an error. errors.Is then asks "is this specific error the same one as
ErrInputTooLarge?" so the code can react precisely — here, telling the
caller (via a web response) that the payload was too large.
This lets you write tests that pin the exact error path, not just "an error occurred" (a "test" is a small piece of code written purely to check that another piece of code behaves correctly, run automatically rather than by a person clicking around).
Token count checked after parsing
In jsmn-go, MaxTokens is enforced after Parse runs — the tokenizer
does its work and then the count is compared. This means the allocator (the
part of the program that reserves chunks of the computer's memory to hold
data) still
touches memory up to MaxTokens. If you need a hard pre-parse cap, set
MaxInputSize tightly; the token count is a second line of defense against
inputs that are small in bytes but generate many tokens (e.g. [1,2,3,...,999999]).
The ParseWithConfig entry point¶
ParseWithConfig is the public function (one whose capitalized name, like the
exported fields earlier, makes it callable from outside this package) that
wires everything together. From
jsmn-go/config.go (condensed):
func ParseWithConfig(ctx context.Context, data []byte, config *Config) ([]Token, error) {
if config == nil {
config = DefaultConfig() // nil-safe: always have a config
}
if err := config.Validate(); err != nil {
return nil, err
}
if err := config.validateInput(data); err != nil {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if config.shouldUseParallel(len(data)) {
return parseParallelWithConfig(ctx, data, config)
}
// ... serial path
}
In plain terms: nil is Go's word for "no value here / empty" — so
if config == nil means "if the caller didn't supply a Config at all," in
which case a sensible default is used instead of leaving it empty. ctx
context.Context (shortened to ctx) is a standard Go value used to signal
"stop early" — for example if the caller gave up waiting or a timer ran out
— and the select { case <-ctx.Done(): ... } block is this function's way of
checking "has someone asked me to stop?" before doing more work. If
shouldUseParallel says the input is big enough to be worth it, the work is
split across multiple workers running at the same time (see the concurrency
lesson); otherwise it runs step by step in the current worker alone (the
"serial path").
The call order is always: validate Config → validate input → check context → do work. This sequence ensures that any limit violation is caught before a single byte of the input is processed, and that context cancellation is respected before any goroutines (Go's lightweight units of concurrent work — think of them as independent workers that can run at the same time as one another) are launched.
Try it
Run the config-related tests for both modules:
cd /path/to/safeheaders-go
go test ./jsmn-go/... -run TestConfig -v
go test ./tinyxml2-go/... -run TestConfig -v
Expected outcome: all TestConfig* cases pass. Look for cases named
TestConfigDefaultConfig, TestConfigStrictConfig, and
TestConfigUnlimitedConfig — they verify that DefaultConfig() rejects a
200 MB input, StrictConfig() rejects 20 MB, and UnlimitedConfig() accepts
the same payload without error.
To verify the sentinel error surfaces correctly:
Why not just use constants?¶
You might wonder: why a struct and constructors instead of package-level const
values (fixed values written directly into the source code that can never
change while the program runs)? Three reasons:
- Per-call tuning. A single binary (the finished, runnable program produced once the source code is compiled — turned from human-written text into instructions the machine can run) may parse trusted internal data with
DefaultConfigand untrusted user uploads withStrictConfig— in the same running program (a "process"), on the same code path (the same sequence of function calls the program takes to do that work). - Testability. A test can construct a
Config{MaxInputSize: 100}and verify limit enforcement on tiny inputs without manufacturing a 10 MB fixture (a pre-made sample piece of test data set up ahead of time). - Future extensibility. Adding a new field (say,
MaxAttributeCount) is a backwards-compatible change — existing callers usingDefaultConfig()get the new field's default automatically.
Key takeaways¶
- A
Configstruct withDefaultConfig/StrictConfig/UnlimitedConfigconstructors gives callers a safe default, a hardened preset, and an explicit escape hatch — without exposing internal constants. Validate()catches programming errors (negative limits) at construction;validateInput()catches runtime violations (oversized payloads) per call.0means "unlimited" for every limit field; callers must opt in to0explicitly viaUnlimitedConfig()rather than getting it by accident.- Sentinel errors (
ErrInputTooLarge,ErrTooManyTokens,ErrNestingTooDeep) make limit violations testable and HTTP-mappable without parsing error strings. - The
ParseWithConfigentry point follows a strict call order: validate Config, validate input, check context, do work — ensuring no CPU or memory is spent on inputs that violate a limit.