Skip to main content
Version: Next

Headers

A header arrives as text, a name and a value on one line. Relic keeps the text, and parses it into a value with a type when a handler asks. This handler reads the same header both ways:

A header read raw and typed
Response describeAccept(Request req) {
final raw = req.headers['accept'];
final typed = req.headers.accept;
final ranges = typed?.mediaRanges
.map((r) => '${r.type}/${r.subtype} at ${r.quality}')
.join(', ');
return .ok(body: .fromString('raw: $raw\ntyped: $ranges\n'));
}

The program on this page registers it, and the other handlers below, like this:

The routes of the headers example
final app = RelicApp()
..get('/accept', describeAccept)
..get('/file', describeRange)
..get('/compare', compareETags)
..get('/cached', cachedForAnHour)
..get('/echo', echoHeader)
..use('/api', rateLimit(2))
..get('/api/ping', (req) => Response.ok(body: .fromString('pong\n')));

Send it an Accept header with two media ranges and their weights:

curl -H 'Accept: text/html;q=0.9, */*;q=0.1' http://localhost:8080/accept
raw: [text/html;q=0.9, */*;q=0.1]
typed: text/html at 0.9, */* at 0.1

headers['accept'] gives the text, as a list because a header can appear more than once. headers.accept gives an AcceptHeader, whose mediaRanges have a type, a subtype and a quality, so the handler compares weights as numbers and never splits a string. Without the option, curl sends Accept: */*, and the typed read says */* at 1.0, with the default weight filled in.

Every common header has an accessor like that, from userAgent as a String? to cookie as a list of cookies. A header is parsed once per request, and the parsed value is kept for the next read.

Three ways to read​

A typed accessor is a constant on the Headers class, Headers.range for the Range header, and there are three ways to read through it. The getter, headers.range, is the usual one, and it is null when the header is absent:

A header that may be absent
Response describeRange(Request req) {
final range = req.headers.range;
if (range == null) return .ok(body: .fromString('The whole file\n'));
final parts = range.ranges.map((r) => '${r.start ?? ''}-${r.end ?? ''}');
return .ok(body: .fromString('${range.unit} ${parts.join(', ')}\n'));
}
curl -H 'Range: bytes=0-499, 1000-' http://localhost:8080/file
curl http://localhost:8080/file
bytes 0-499, 1000-
The whole file

headers(Headers.range) is the same read, written as a call. The third way, headers.get(Headers.range), returns a plain RangeHeader rather than a RangeHeader?, and throws when the header is absent. That suits a header the handler cannot do without:

A header the handler requires
Response compareETags(Request req) {
final condition = req.headers.get(Headers.ifNoneMatch);
final tags = condition.etags.map(
(e) => e.isWeak ? 'weak ${e.value}' : e.value,
);
return .ok(body: .fromString('Compare against ${tags.join(', ')}\n'));
}
curl -H 'If-None-Match: "v1", W/"v2"' http://localhost:8080/compare
curl -i http://localhost:8080/compare
Compare against v1, weak v2
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
date: Mon, 26 Oct 2026 08:25:59 GMT
content-length: 30

Missing 'if-none-match' header

Each entity tag came back as an ETagHeader with its value and whether it isWeak, the quotes and the W/ already dealt with. Without the header, get threw, and Relic answered 400 with the name of the missing header. Path, query and form parameters read the same three ways, which the Routing and Requests pages show.

A header that does not parse​

The same handler that read the ranges above gets a Range that is not one:

curl -i -H 'Range: bytes=abc' http://localhost:8080/file
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
date: Mon, 26 Oct 2026 08:25:59 GMT
content-length: 37

Invalid 'range' header: Invalid range

The accessor throws an InvalidHeaderException, and Relic turns it into a 400 that names the header. The handler never ran. A handler that would rather answer itself reads with tryGet, which gives null for a value that does not parse as well as for one that is absent.

For a header Relic has no accessor for, index the headers by name. headers['X-Request-Id'] gives its values as text, or null. The name is matched whatever its case.

Writing headers​

A response carries its headers in a Headers, and Headers.build hands you a set to fill in:

A response with typed and named headers
Response cachedForAnHour(Request req) {
return .ok(
headers: Headers.build((h) {
h.cacheControl = CacheControlHeader(publicCache: true, maxAge: 3600);
h.etag = const ETagHeader(value: 'v1');
h['X-Request-Id'] = ['7f3a'];
}),
body: .fromString('Keep me for an hour\n'),
);
}
curl -i http://localhost:8080/cached
HTTP/1.1 200 OK
etag: "v1"
content-type: text/plain; charset=utf-8
cache-control: public, max-age=3600
date: Mon, 26 Oct 2026 08:25:59 GMT
content-length: 20
x-request-id: 7f3a

Keep me for an hour

The typed setters wrote cache-control and etag in the form HTTP expects, the quotes around the tag included. A header without a setter goes in by name, as a list of values. The Body wrote content-type and content-length, and the Responses page shows what happens to those two when a handler sets them itself.

A set of headers cannot be changed once built. transform takes one and returns another with the changes applied, and the original keeps its values, so a middleware that adds a header to a request never changes what another handler sees. The Requests page shows a middleware doing that.

A value that must not reach the wire​

A header ends at the line break. A value with a line break in it would end the header early, and whatever followed would be read as the next header. This handler copies a query parameter into a header:

A header from a query parameter
Response echoHeader(Request req) {
final value = req.url.queryParameters['v'] ?? '';
return .ok(
headers: Headers.build((h) => h['X-Echo'] = [value]),
body: .fromString('Echoed $value\n'),
);
}
curl -i 'http://localhost:8080/echo?v=hello'
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
date: Mon, 26 Oct 2026 08:25:59 GMT
content-length: 13
x-echo: hello

Echoed hello

Now send it a value with a carriage return and a line feed in it, %0D%0A, followed by a header of the client's choosing:

curl -i 'http://localhost:8080/echo?v=a%0D%0AX-Injected:%20yes'
HTTP/1.1 500 Internal Server Error
content-type: text/plain; charset=utf-8
date: Mon, 26 Oct 2026 08:25:59 GMT
content-length: 21

Internal Server Error

The write threw a FormatException, and the log says why:

FormatException: Header "x-echo" value must not contain CR, LF or NUL (at character 2)

No header reached the client, injected or otherwise. Every write checks its value, the typed setters and the named ones alike, and a name that is not an HTTP token is refused the same way. A handler that takes header values from its callers can catch the exception and answer 400.

A header of your own​

Every typed header in Relic is a HeaderAccessor, a HeaderName paired with a HeaderCodec that parses and writes the value. Nothing stops a program from declaring one. This one types an X-RateLimit-Remaining header as an int:

A typed header for X-RateLimit-Remaining
const rateLimitRemainingHeader = HeaderAccessor<int>(
HeaderName.custom('x-ratelimit-remaining'),
HeaderCodec.single(int.parse, encodeRateLimit),
);

Iterable<String> encodeRateLimit(int value) => ['$value'];

extension RateLimitHeaders on HeaderValues {
int? get rateLimitRemaining => this(rateLimitRemainingHeader);
}

extension RateLimitHeadersSetter on MutableHeaders {
set rateLimitRemaining(int? value) => assign(rateLimitRemainingHeader, value);
}

HeaderCodec.single takes the parser and the encoder for a header with one value, and HeaderCodec takes a parser over all the values for a header that may repeat. The two extensions give the header a getter on HeaderValues, which both Headers and MutableHeaders are, and a setter on MutableHeaders, so it reads and writes like the built-in headers. A middleware uses the setter to tell each caller how much of its quota is left:

A middleware that sets the header
Middleware rateLimit(int quota) {
var remaining = quota;
return (next) => (req) async {
if (remaining == 0) return Response(429);
remaining--;
final result = await next(req);
if (result is! Response) return result;
return result.copyWith(
headers: result.headers.transform((h) {
h.rateLimitRemaining = remaining;
}),
);
};
}

The program gives /api a quota of two. Three requests get:

curl -i http://localhost:8080/api/ping
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
date: Mon, 26 Oct 2026 08:25:59 GMT
content-length: 5
x-ratelimit-remaining: 1

pong
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
date: Mon, 26 Oct 2026 08:25:59 GMT
content-length: 5
x-ratelimit-remaining: 0

pong
HTTP/1.1 429 Status 429
date: Mon, 26 Oct 2026 08:25:59 GMT
content-length: 0

The header counted down from the quota, and the third request was refused. assign encoded the int through the codec, and a client built on Relic reads it back with headers.rateLimitRemaining, as an int?, through the same accessor.

Which headers are typed​

The accessors cover the headers a server reads and writes most. These are the ones you will meet first, with the type each reads as:

HeaderAccessorType
AcceptacceptAcceptHeader
AuthorizationauthorizationAuthorizationHeader
Cache-ControlcacheControlCacheControlHeader
Content-LengthcontentLengthint
Content-TypecontentTypeContentTypeHeader
CookiecookieCookieHeader
ETagetagETagHeader
HosthostHostHeader
If-None-MatchifNoneMatchIfNoneMatchHeader
LocationlocationUri
RangerangeRangeHeader
Set-CookiesetCookieSetCookieHeader
User-AgentuserAgentString

The Headers class lists them all, the CORS, security and forwarding headers among them.

Examples & further reading​

Examples​

API documentation​

Further reading​