What this post covers
This is the twelfth post in the "Digging into Apache httpd's Source" series. In part 10 we watched a request pass through the processing phases until httpd knew "which file on disk is this?" and "who will build the response?". In part 11 we watched a response travel, packed into buckets, down the filter chain to the socket. This time we read the place where the two meet: where a handler actually builds the response. In particular we follow the thing a web server does most often — sending a file as it is.
Four questions:
- What is the "handler" that
SetHandlerandAddHandlertalk about, and how is it different from theContent-Type? - When no module claims a request, who answers it with a file, and how? And what exactly do
EnableSendfileandEnableMMAPchange in that process? - When a browser says "don't send it if it's the same as what I have" (
304) or "just the first 10 bytes, please" (206), which part of the source decides? - Why does a directory URL without a trailing
/, like/docs, become a301, and how does/docs/turn intoindex.html?
| Item | Value |
|---|---|
| Target | Apache httpd |
| Version | 2.5.1-dev (trunk) |
| Commit | e4af30fe6d (2026-10-24) |
| Also read | APR 1.7.2 (e905526629), APR-util 1.6.3 (28327eeca2) |
| Main files | server/core.c, modules/http/mod_mime.c, server/util_etag.c, modules/http/http_protocol.c, modules/http/byterange_filter.c, modules/mappers/mod_dir.c, server/core_filters.c |
All line numbers in this post refer to this commit. Part 1 shows how to get the same source and build the Docker image used for the examples.
Even sending one static file involves several modules and filters taking turns. This post follows only the main path, so that someone opening the source for the first time can keep up. What I left out is listed by name and location under "What this post leaves out" at the end.
Terms to know first
A handler is a function that builds the response body. In httpd, modules hang functions on the handler hook, and for every request those functions answer in turn either "I'll take this one" (OK) or "not my job" (DECLINED). We covered hooks and the RUN_FIRST style in part 6.
A handler name is the string in the r->handler field of the request_rec. Handler functions look at this name to decide whether it is their turn. It is what config lines like SetHandler server-status or AddHandler cgi-script .cgi set. The fact that it is a string, not a function, is where this post starts.
The Content-Type (MIME type) is the value in a response header that tells the browser "this is HTML (text/html)" or "this is a PNG image (image/png)". In the source it is the r->content_type field. The module that picks it from the file extension is mod_mime, and the table of extensions and types is the conf/mime.types file.
A static file is a file sent exactly as it sits on disk. The term contrasts with dynamic responses such as CGI or PHP, where a program runs to produce the content.
A conditional request is a request that says "send it only if a condition holds". If the browser puts the tag (ETag) or modification time (Last-Modified) of a file it received earlier into an If-None-Match or If-Modified-Since header, the server answers with just 304 Not Modified and no body when the file hasn't changed.
Buckets and brigades — the pieces that hold response data (buckets) and the ring that strings them together (a brigade). In part 11 we saw there is a file bucket, which doesn't read the file into memory but only records "these bytes of this file". It is one of the stars of this post.
1. How the handler name is decided
mod_mime: looking up every extension in a table
At startup, mod_mime reads conf/mime.types and builds a hash table from extension to Content-Type (mime_type_extensions) (modules/http/mod_mime.c lines 487–533). When a request comes in, find_ct(), hung on the type_checker phase (line 1088), splits the file name at the dots and looks up each extension in two tables.
while (*fn && (ext = ap_getword(r->pool, &fn, '.'))) {
/* ... */
if (conf->extension_mappings != NULL) {
exinfo = (extension_info*)apr_hash_get(conf->extension_mappings,
ext, APR_HASH_KEY_STRING);
}
if ((exinfo == NULL || !exinfo->forced_type) && !skipct) {
if ((type = apr_hash_get(mime_type_extensions, ext,
APR_HASH_KEY_STRING)) != NULL) {
ap_set_content_type_ex(r, (char*) type, 1);
found = 1;
}
}
if (exinfo != NULL) {
/* ... AddType, AddCharset, AddLanguage, AddEncoding ... */
if (exinfo->handler && r->proxyreq == PROXYREQ_NONE) {
r->handler = exinfo->handler;
if (conf->multimatch & MULTIMATCH_HANDLERS) {
found = 1;
}
}
/* ... AddInputFilter, AddOutputFilter ... */
modules/http/mod_mime.c lines 891–974
There are two tables. The global one built from mime.types (mime_type_extensions) only gives a Content-Type. The one from the directory configuration (conf->extension_mappings) holds whatever directives like AddType, AddHandler and AddEncoding filled in. AddHandler is the directive that fills the handler slot of that table (lines 429–431). So for hello.txt, only r->content_type becomes text/plain; r->handler stays empty.
If r->finfo.filetype is a directory, it skips the extensions, sets the Content-Type to a special value, httpd/unix-directory, and returns (lines 819–822; the value is at include/httpd.h line 729). This value comes back in section 5.
SetHandler: overwritten in the fixups phase
SetHandler and ForceType are directives of the core, not of mod_mime (server/core.c lines 4960–4964). What copies their value into r->handler is core_override_type(), hung at the very front of the fixups phase (APR_HOOK_REALLY_FIRST, line 6174).
static int core_override_type(request_rec *r)
{
core_dir_config *conf =
(core_dir_config *)ap_get_core_module_config(r->per_dir_config);
/* Check for overrides with ForceType / SetHandler
*/
if (conf->mime_type && strcmp(conf->mime_type, "none"))
ap_set_content_type_ex(r, (char*) conf->mime_type, 1);
if (conf->expr_handler) {
/* ... evaluate SetHandler's value (an expression) into val, lowercased ... */
if (strcmp(val, "none")) {
r->handler = val;
}
}
In the phase order from part 10, type_checker (mod_mime) runs before fixups (server/request.c lines 431 and 435). So if both AddHandler and SetHandler apply to the same file, SetHandler, which runs later, wins. SetHandler's own description string says it is "a handler name that overrides any other configured handler" (line 4964). The value is parsed as an expression (lines 2167–2181), so it can even pick a different name per request.
If the name is still empty, the Content-Type becomes the name
Just before calling the handlers, ap_invoke_handler() looks at r->handler once more. We saw the code in part 10, section 5; in one line: if it is empty, the Content-Type is used as the name (with any ; charset=… cut off), and if there is no Content-Type either, an empty string (server/config.c lines 420–441).
The handler name is decided in three places. Thanks to the last default, a Content-Type like text/html also becomes a "handler name".
| What | When | What it sets |
|---|---|---|
mime.types |
type_checker (mod_mime) |
r->content_type |
AddType, AddHandler |
type_checker (mod_mime) |
r->content_type, r->handler |
ForceType, SetHandler |
front of fixups (core) |
overwrites the two above |
| nothing set | ap_invoke_handler() |
r->handler = the Content-Type, or "" |
The comments on the two fields of the request_rec struct sum this up. Next to content_type it says "Break these out --- we dispatch on 'em", and next to handler, "What we really dispatch on" (include/httpd.h lines 1013–1015). Handlers used to be chosen by Content-Type; today handler has that job, with the Content-Type kept as its default.
2. The handler hook — each one checks the name
The handler hook is RUN_FIRST (server/config.c lines 169–170). It calls the registered functions in order and stops at the first one that returns anything other than DECLINED. The core hangs one function at each end of this hook.
ap_hook_handler(core_upgrade_handler,NULL,NULL,APR_HOOK_REALLY_FIRST);
ap_hook_handler(default_handler,NULL,NULL,APR_HOOK_REALLY_LAST);
core_upgrade_handler() at the front only deals with requests asking to switch protocols with an Upgrade header and declines everything else (from line 5956). default_handler() at the very end is the star of this post. The modules' handlers stand in between. The comment on the hook declaration recommends hooking "non-wildcard handlers" at HOOK_MIDDLE and "wildcard" handlers (ones that accept any name) at HOOK_LAST (include/http_config.h lines 1393–1399).
The red box is the "easy to get wrong" item at the end of section 2.
Example 1: a handler of our own, and a "spy" handler
Writing a handler yourself makes the structure easy to see. I made a module with two functions. One, hooked at APR_HOOK_FIRST, is a "spy" that logs r->handler for every request and steps aside; the other is a real handler that answers only when the name is u12-hello.
/* Example written for this post (not httpd source) */
/* ... the #includes and APLOG_USE_MODULE(u12); ... */
/* 1) Spy: when the handler hook starts, log r->handler for every request and step aside */
static int spy_handler(request_rec *r)
{
ap_log_rerror(APLOG_MARK, APLOG_NOTICE, 0, r,
"u12 spy: uri=%s handler=\"%s\" content_type=%s",
r->uri, r->handler, r->content_type ? r->content_type : "(null)");
return DECLINED; /* do nothing — hand over to the next handler */
}
/* 2) The real handler: builds a response only when r->handler is "u12-hello" */
static int hello_handler(request_rec *r)
{
if (strcmp(r->handler, "u12-hello")) {
return DECLINED; /* not my name — step aside */
}
ap_set_content_type(r, "text/plain");
ap_rprintf(r, "hello from u12-hello\n");
ap_rprintf(r, "handler=%s\n", r->handler);
ap_rprintf(r, "filename=%s\n", r->filename ? r->filename : "(null)");
return OK; /* I answered — handlers after me are not called */
}
static void register_hooks(apr_pool_t *p)
{
ap_hook_handler(spy_handler, NULL, NULL, APR_HOOK_FIRST);
ap_hook_handler(hello_handler, NULL, NULL, APR_HOOK_MIDDLE);
}
/* ... the module struct declaration (u12_module) ... */
Excerpted from ex/u12-static/mod_u12.c.
The first line of hello_handler() is the classic httpd handler. Handlers in modules like mod_autoindex and mod_status almost all start by comparing the name like this. strcmp() returns 0 when two strings are equal, so if (strcmp(...)) means "if they differ".
Here is the configuration (an excerpt from ex/u12-static/run.sh). Every other example in this post ran with the same configuration on the same server.
LoadModule mime_module modules/mod_mime.so
LoadModule dir_module modules/mod_dir.so
LoadModule autoindex_module modules/mod_autoindex.so
LoadModule u12_module /work/.libs/mod_u12.so
DocumentRoot /tmp/htdocs
<Directory /tmp/htdocs>
Options Indexes
Require all granted
DirectoryIndex index.html
</Directory>
AddHandler u12-hello .u12
<Files typo.txt>
SetHandler no-such-handler
</Files>
<Location /hello>
SetHandler u12-hello
</Location>
I ran this example in a Docker container (Ubuntu 24.04) on httpd 2.5.1-dev built as is from the reference commit (e4af30fe6d).
### 10. GET /hello (SetHandler u12-hello)
HTTP/1.1 200 OK
Content-Length: 66
Content-Type: text/plain
hello from u12-hello
handler=u12-hello
filename=/tmp/htdocs/hello
### 11. GET /page.u12 (AddHandler u12-hello .u12)
hello from u12-hello
handler=u12-hello
filename=/tmp/htdocs/page.u12
There is no file called /tmp/htdocs/hello. It's still a 200. A handler doesn't care whether a file exists. <Location /hello> set the name, and the handler that owns that name built the response — that's all. It's the same reason mod_status's /server-status works without being on disk.
The spy handler's log (identical lines grouped with uniq -c) looks like this.
7 u12 spy: uri=/hello.txt handler="text/plain" content_type=text/plain
1 u12 spy: uri=/docs/index.html handler="text/html" content_type=text/html
1 u12 spy: uri=/list/ handler="httpd/unix-directory" content_type=httpd/unix-directory
1 u12 spy: uri=/hello handler="u12-hello" content_type=(null)
1 u12 spy: uri=/page.u12 handler="u12-hello" content_type=(null)
1 u12 spy: uri=/notes.u12.txt handler="u12-hello" content_type=text/plain
1 u12 spy: uri=/typo.txt handler="no-such-handler" content_type=text/plain
2 u12 spy: uri=/fresh.txt handler="text/plain" content_type=text/plain
The table from section 1 shows up exactly. Files with no explicit name use their Content-Type as the handler name (text/plain, text/html), a directory gets httpd/unix-directory, and places with SetHandler or AddHandler get that name. .u12 is not in mime.types, so its Content-Type is (null).
Easy to get wrong
AddHandlerdoesn't look only at the last extension. In the log above,/notes.u12.txtwent to theu12-hellohandler. That's because thewhileloop infind_ct()looks upu12andtxt— every extension, one after another. The Content-Type came fromtxt, but the handler came from theu12in the middle. This behavior is how an uploadedphoto.php.jpgends up executed as PHP. To match only the last extension, useSetHandlerinside<FilesMatch "\.u12$">, or theMimeOptionsdirective in this commit'smod_mime(theskipctandskipallvariables are its trace, lines 895–896). I did not tryMimeOptionsfor this post.- A handler name that doesn't exist causes no error. I wrote
SetHandler no-such-handlerfortypo.txtby "mistake", and the response was a200with the file content sent as is.
### 13. GET /typo.txt (SetHandler no-such-handler)
HTTP/1.1 200 OK
Content-Type: text/plain
typo handler
ap_invoke_handler() does have code that logs a "handler not found" warning (AH00523) (server/config.c lines 447–450), but only when the whole hook ends in DECLINED. default_handler() at the very end never looks at r->handler and sends the file if it exists, so the hook never ends in DECLINED. Indeed, the error log of this example has no AH00523. default_handler() on the 2.4.x branch doesn't check the name either. This is why a typo like SetHandler cgi-scrpit for a CGI script doesn't run the script but sends its source code as is (this follows from the source; I did not try it with CGI).
3. default_handler() — one file into one response
A request nobody claimed arrives at default_handler() (server/core.c lines 5169–5320). It's 150 lines, but what it does fits in one picture.
The yellow box is the whole response body. This function doesn't read a single byte of the file.
- Check the method — it notes in
Allowthat this handler acceptsGET,OPTIONSandPOST(line 5181), and reads and discards any request body (lines 5188–5190).HEADis registered inside httpd under the same number asGET(modules/http/http_protocol.cline 711), so it takes the same path. - Check the file — no file:
404(lines 5193–5198); a directory:404(lines 5203–5207); extra path after the file name (/a.txt/more, called path info):404by default (lines 5209–5217). This is where "a 404 is decided bydefault_handler(), not by the walks" from part 10 happens.POSTis a405unless specially flagged (lines 5227–5237). - Open the file and prepare headers — the code below.
if ((status = apr_file_open(&fd, r->filename, APR_READ | APR_BINARY
#if APR_HAS_SENDFILE
AP_SENDFILE_ENABLED(d->enable_sendfile)
#endif
, 0, r->pool)) != APR_SUCCESS) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, status, r, APLOGNO(00132)
"file permissions deny server access: %s", r->filename);
return HTTP_FORBIDDEN;
}
ap_update_mtime(r, r->finfo.mtime);
ap_set_last_modified(r);
ap_set_etag_fd(r, fd);
ap_set_accept_ranges(r);
ap_set_content_length(r, r->finfo.size);
bb = apr_brigade_create(r->pool, c->bucket_alloc);
if ((errstatus = ap_meets_conditions(r)) != OK) {
apr_file_close(fd);
r->status = errstatus;
}
else {
e = apr_brigade_insert_file(bb, fd, 0, r->finfo.size, r->pool);
#if APR_HAS_MMAP
if (d->enable_mmap == ENABLE_MMAP_OFF) {
(void)apr_bucket_file_enable_mmap(e, 0);
}
#endif
#if APR_MAJOR_VERSION > 1 || (APU_MAJOR_VERSION == 1 && APU_MINOR_VERSION >= 6)
if (d->read_buf_size) {
apr_bucket_file_set_buf_size(e, d->read_buf_size);
}
#endif
}
e = apr_bucket_eos_create(c->bucket_alloc);
APR_BRIGADE_INSERT_TAIL(bb, e);
status = ap_pass_brigade(r->output_filters, bb);
#if APR_HAS_SENDFILE … #endif is C's conditional compilation. If the operating system has sendfile at compile time, the line inside is included; otherwise the line simply doesn't exist (one of the kinds of macro we met in part 2).
Three things to read here:
- The file is handed to a bucket while still open.
apr_brigade_insert_file()puts a file bucket into the brigade that holds only the file handle and "from byte 0, for the file's size" (APR-utilbuckets/apr_brigade.clines 707–736). Append an EOS bucket meaning "end of response", pass it to the output filters, and the handler's job is done. Actually reading the file is left to the core output filter at the end of the chain. Section 4 covers that. - The headers are decided right after opening the file.
Last-Modified,ETag,Accept-Ranges: bytesandContent-Lengthgo intor->headers_outhere. They aren't sent yet; the HTTP header filter in the chain sends them when it receives the first bucket (part 11). - This handler also decides
304. Ifap_meets_conditions()isn'tOK, it passes only the EOS without a file bucket. Section 6 covers that.
Also note that the two directives EnableSendfile and EnableMMAP appear in this code in different ways. Sendfile leaves a flag on the file handle when the file is opened; mmap is switched off on the bucket after the bucket is made. And their defaults differ.
/* macro to implement off by default behaviour */
#define AP_SENDFILE_ENABLED(x) \
((x) == ENABLE_SENDFILE_ON ? APR_SENDFILE_ENABLED : 0)
include/http_core.h lines 689–691
The flag is set only when the config explicitly says On. Saying nothing (ENABLE_SENDFILE_UNSET, line 623) is the same as off. Mmap is the opposite: it's turned off only on Off, so it is on by default. The comment in the default config file also says "Defaults: EnableMMAP On, EnableSendfile Off" (docs/conf/httpd.conf.in lines 356–365).
Example 2: the response headers of one static file
I fetched hello.txt (one line, 0123456789abcdefghij, 21 bytes). I had set the file's modification time to 2026-10-01 12:00:00 UTC beforehand. The Date line differs on every run.
< HTTP/1.1 200 OK
< Date: Sat, 24 Oct 2026 11:56:40 GMT
< Server: Apache/2.5.1-dev (Unix)
< Last-Modified: Thu, 01 Oct 2026 12:00:00 GMT
< ETag: "15-65cc627b95000"
< Accept-Ranges: bytes
< Content-Length: 21
< Content-Type: text/plain
<
0123456789abcdefghij
The four lines from Last-Modified to Content-Length come from the five lines of code above (lines 5251–5255), and Content-Type was decided by mod_mime in section 1.
4. How a file bucket gets to the socket — sendfile, mmap, read
The file bucket travels down the output filter chain and reaches the core output filter at the end. There, this function is called first for each bucket.
static APR_INLINE int can_sendfile_bucket(apr_bucket *b)
{
/* Use sendfile to send the bucket unless:
* - the bucket is not a file bucket, or
* - the file is too small for sendfile to be useful, or
* - sendfile is disabled in the httpd config via "EnableSendfile off".
*/
if (APR_BUCKET_IS_FILE(b) && b->length >= AP_MIN_SENDFILE_BYTES) {
apr_file_t *file = ((apr_bucket_file *)b->data)->fd;
return apr_file_flags_get(file) & APR_SENDFILE_ENABLED;
}
else {
return 0;
}
}
server/core_filters.c lines 467–481
If it is a file bucket, at least 256 bytes (AP_MIN_SENDFILE_BYTES, line 57), and carries the flag set when the file was opened in section 3, it is sent with the sendfile() system call (lines 533–547). sendfile() hands the kernel the whole job of "send this file's content to that socket", so httpd never brings the file content into its own memory.
Otherwise the bucket is read (apr_bucket_read()). The file bucket's read function lives in APR-util, and before reading it first tries mmap with file_make_mmap() (APR-util buckets/apr_buckets_file.c lines 44–72, called at lines 89–93).
mmap (memory map) is a system call that maps a file onto memory addresses. The program can read the file like memory without copying it in with read(). can_mmap is 1 when the bucket is created (line 164), and only the section 3 code sets it to 0 when EnableMMAP Off. When mmap succeeds, the file bucket turns into an mmap bucket on the spot (apr_bucket_mmap_make). If the file is bigger than 4 MB (APR_MMAP_LIMIT, APR include/apr_mmap.h lines 105–112), it maps only the first 4 MB and splits the bucket in two.
When mmap can't be used, it reads with read(). The default amount per read is APR_BUCKET_BUFF_SIZE, which is 8000 bytes (apr_buckets_file.c lines 112 and 166, include/apr_buckets.h line 54). ReadBufferSize (d->read_buf_size in the section 3 code) is the directive that changes this size.
Green is the path where httpd never touches the file content; yellow paths go through memory.
Example 3: watching the system calls with strace
I put the same 100 KiB file in three directories and changed only the settings per directory.
<Directory /tmp/htdocs/sf>
EnableSendfile On
</Directory>
<Directory /tmp/htdocs/nommap>
EnableMMAP Off
</Directory>
I started httpd under strace -f (with -X, so it serves from a single process without children), requested each file once, and extracted the system calls starting from the openat that opened the file. I removed mmap calls that grab anonymous memory and reads on the connection, and shortened writev contents to [...]. Address values differ on every run.
--- GET /big.bin
200 102400
openat(AT_FDCWD, "/tmp/htdocs/big.bin", O_RDONLY|O_CLOEXEC) = 9
mmap(NULL, 102400, PROT_READ, MAP_SHARED, 9, 0) = 0x7f546046a000
writev(8, [...], 2) = 102650
munmap(0x7f546046a000, 102400) = 0
--- GET /nommap/big.bin
200 102400
openat(AT_FDCWD, "/tmp/htdocs/nommap/big.bin", O_RDONLY|O_CLOEXEC) = 9
read(9, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 8000) = 8000
read(9, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 8000) = 8000
writev(8, [...], 10) = 72250
writev(8, [...], 4) = 30400
(... read(9, ...) called 13 times in total)
--- GET /sf/big.bin
200 102400
openat(AT_FDCWD, "/tmp/htdocs/sf/big.bin", O_RDONLY|O_CLOEXEC) = 9
writev(8, [...], 1) = 250
sendfile(8, 9, [0] => [102400], 102400) = 102400
fd 9 is the file and fd 8 is the client socket.
- Default settings (
/big.bin) mapped the whole file withmmapand sent the response headers (250 bytes) and the file content (102400 bytes) in a singlewritev(two pieces).250 + 102400 = 102650. EnableMMAP Off(/nommap/) calledread()13 times, 8000 bytes each (102400 / 8000 = 12.8), and sent the collected pieces with twowritevs.EnableSendfile On(/sf/) sent only the headers withwritev; the body was onesendfile(). The file content never passed through httpd's memory.
Easy to get wrong
- strace printed addresses instead of file names. On my first run, paths came out as numbers, like
openat(AT_FDCWD, 0x7f9ac45079b0, ...). httpd starts asrootand switches toUser daemon(part 5), and as far as I know Linux marks a process that switched users like this as "not dumpable", which stops other processes from reading its memory (that is kernel behavior, so I did not verify it in source for this post). On the httpd side,mod_unixdundoes that mark withprctl(PR_SET_DUMPABLE, 1)only whenCoreDumpDirectoryis configured (modules/arch/unix/mod_unixd.clines 189–199). Once I addedCoreDumpDirectory /tmpto the config, the paths appeared. - Sendfile is off by default for a reason. The comment in the default config says, about both mmap and sendfile, that they "must be turned off when serving from networked-mounted filesystems or if support for these functions is otherwise broken on your system" (lines 356–361). The source doesn't say why only sendfile is off by default. I did not reproduce such a situation for this post.
- The handler code is the same with mmap or sendfile. Which system call is used is decided by the bucket and the core output filter, not by the handler. The handler only passed one bucket meaning "this whole file". Separating "what to send" from "how to send it" like this is the heart of the bucket design (part 11).
5. Directories — 301, DirectoryIndex, listing pages
Now, requesting a directory. In section 1 we saw mod_mime set a directory's Content-Type to httpd/unix-directory. What happens next is done by mod_dir at the end of the fixups phase (APR_HOOK_LAST).
mod_dir's dir_fixups() calls fixup_dir() if the request is a directory (APR_DIR), and calls fixup_dflt(), which implements FallbackResource, if there is no file and no handler (modules/mappers/mod_dir.c lines 418–429).
mod_dir is not a handler. It hangs only on the fixups hook (line 433). Its way of working is to swap the request for something else before the handlers are called, or to return a status code so the request never reaches them.
No trailing / means 301
/* Redirect requests that are not '/' terminated */
if (r->uri[0] == '\0' || r->uri[strlen(r->uri) - 1] != '/')
{
char *ifile;
if (d->do_slash == SLASH_OFF) {
return DECLINED;
}
if (d->do_slash == SLASH_NOTFOUND) {
return HTTP_NOT_FOUND;
}
/* ... DECLINED if not GET and redirect-carefully is set ... */
if (r->args != NULL) {
ifile = apr_pstrcat(r->pool, ap_escape_uri(r->pool, r->uri),
"/?", r->args, NULL);
}
else {
ifile = apr_pstrcat(r->pool, ap_escape_uri(r->pool, r->uri),
"/", NULL);
}
apr_table_setn(r->headers_out, "Location",
ap_construct_url(r->pool, ifile, r));
return HTTP_MOVED_PERMANENTLY;
}
modules/mappers/mod_dir.c lines 264–298
If it's a directory but the URL doesn't end in /, it puts the URL with a / appended (plus the query string ?…, if any) into Location and returns 301. With DirectorySlash Off it just passes; if the directive isn't set at all (SLASH_UNSET), neither if matches, so it redirects. On by default.
Why redirect at all? If the index.html served at /docs contains <img src="logo.png">, the browser resolves relative URLs against "everything up to the last /", so it asks for /logo.png instead of /docs/logo.png. Fixing the URL to /docs/ keeps relative links inside the directory correct (this is a browser-side rule, not something verified in httpd's source).
Ending in / means DirectoryIndex is looked up with subrequests
If it ends in /, it makes a subrequest (ap_sub_req_lookup_uri()) for each name listed in DirectoryIndex, and if a regular file is there, moves that result into the main request with ap_internal_fast_redirect() (lines 346–369). We covered this in detail, with code and phase logs, in part 10, section 6. If no names are configured, it uses a single index.html (include/httpd.h line 224).
After the move, the main request has become a request for index.html, so the handler name is text/html and in the end section 3's default_handler() sends that file.
Nothing found means mod_autoindex
If no index file is found, mod_dir writes the names it tried into r->notes under "dir-index-names" and steps aside (lines 409–415). The handler name then stays the Content-Type, httpd/unix-directory, and the handler that claims that name is mod_autoindex's.
if (strcmp(r->handler,DIR_MAGIC_TYPE) && !AP_IS_DEFAULT_HANDLER_NAME(r->handler)) {
return DECLINED;
}
if (r->finfo.filetype != APR_DIR) {
return DECLINED;
}
modules/generators/mod_autoindex.c lines 2303–2308
It's the same "compare the name first" pattern from section 2. If Options Indexes is on, this handler builds a listing page; if not, it logs "no DirectoryIndex and listings are forbidden" (AH01276) and returns 403 (404 if IndexForbiddenReturn404 is on; lines 2320–2349).
Three modules take turns on a single directory. Only mod_dir works in fixups instead of as a handler.
Example 4: three kinds of directory
/tmp/htdocs/docs/ contains index.html, and /tmp/htdocs/list/ contains only a.txt and b.txt.
### 7. GET /docs (no slash)
HTTP/1.1 301 Moved Permanently
Location: http://127.0.0.1:8080/docs/
### 8. GET /docs/
HTTP/1.1 200 OK
ETag: "14-65cc627b95000"
Content-Type: text/html
<h1>docs index</h1>
### 9. GET /list/ (no index.html -> mod_autoindex)
HTTP/1.1 200 OK
Content-Type: text/html;charset=ISO-8859-1
<li><a href="a.txt"> a.txt</a></li>
<li><a href="b.txt"> b.txt</a></li>
Looking back at the spy log in section 2, /docs (no slash) has no line at all. mod_dir returned 301 in the fixups phase, so the request never reached the handler hook. /docs/ was logged as uri=/docs/index.html handler="text/html" (the result of the move), and /list/ as handler="httpd/unix-directory".
6. Conditional requests — ETag and 304
An ETag is size plus modification time
ap_set_etag_fd() from section 3 builds the tag with ap_make_etag_ex() in server/util_etag.c. Without a FileETag directive, it uses this default.
if (etag_bits == ETAG_UNSET) {
etag_bits = ETAG_BACKWARD;
}
/* ... */
if ((er->request_time - er->finfo->mtime < (1 * APR_USEC_PER_SEC))) {
weak = ETAG_WEAK;
weak_len = sizeof(ETAG_WEAK);
}
/* ... */
if (etag_bits & ETAG_INODE) {
next = etag_uint64_to_hex(next, er->finfo->inode);
bits_added |= ETAG_INODE;
}
if (etag_bits & ETAG_SIZE) {
if (bits_added != 0) {
*next++ = '-';
}
next = etag_uint64_to_hex(next, er->finfo->size);
bits_added |= ETAG_SIZE;
}
if (etag_bits & ETAG_MTIME) {
if (bits_added != 0) {
*next++ = '-';
}
next = etag_uint64_to_hex(next, er->finfo->mtime);
}
server/util_etag.c lines 285–332
ETAG_BACKWARD is ETAG_MTIME | ETAG_SIZE (include/http_core.h lines 493–501). The default ETag doesn't include the inode (the file system's file number); it is the size and the modification time in hexadecimal, joined. Decoding "15-65cc627b95000" from example 2 confirms it.
0x15= 21 — the size ofhello.txt, 21 bytes0x65cc627b95000= 1790856000000000 — APR times (apr_time_t) are microseconds since 1970, so dividing by 1,000,000 gives 1790856000 seconds, which is2026-10-01 12:00:00 UTC.
The "14-…" of index.html is likewise a size of 20 bytes with the same modification time.
Within one second of a change: a weak ETag
The if in the middle of the code says "if the request comes within one second of the file's modification time, put W/ in front". If the file changed once more within that same second, the content could differ while size and time stay the same, so such a tag doesn't promise to be "strongly equal". I requested a file right after creating it, and again two seconds later.
### 14. fresh file -> ETag
ETag: W/"4-65e94ca321003"
ETag: "4-65e94ca321003"
Same file, but only the first response got W/. The numbers in the tag differ on every run (it's the modification time).
ap_meets_conditions(): five headers in a fixed order
ap_meets_conditions(), called by default_handler() (modules/http/http_protocol.c lines 564–666), looks at five request headers in this order.
The red 412 means "your condition is wrong, so I'll do nothing"; the green 304 means "use what you already have".
Here is just the If-None-Match part.
cond = ap_condition_if_none_match(r, r->headers_out);
if (AP_CONDITION_NOMATCH == cond) {
not_modified = 0;
}
else if (cond >= AP_CONDITION_WEAK) {
if (r->method_number == M_GET) {
if (not_modified) {
not_modified = 1;
}
}
else {
return HTTP_PRECONDITION_FAILED;
}
}
modules/http/http_protocol.c lines 619–632
not_modified has three values: -1 means "don't know yet" (its initial value at the top of the function), 0 means "some check said it changed", and 1 means "it's fine to answer 304". if (not_modified) not_modified = 1; means "raise -1 (true) to 1, but leave 0 alone". Once any check says "changed", a later "same" can't produce a 304. Only when not_modified == 1 at the end is it HTTP_NOT_MODIFIED (lines 661–663).
Example 5: 304
I sent the ETag from example 2 back in If-None-Match, and also tried If-Modified-Since.
### 2. If-None-Match: "15-65cc627b95000"
HTTP/1.1 304 Not Modified
Date: Sat, 24 Oct 2026 11:56:40 GMT
Server: Apache/2.5.1-dev (Unix)
Last-Modified: Thu, 01 Oct 2026 12:00:00 GMT
ETag: "15-65cc627b95000"
Accept-Ranges: bytes
### 3. If-Modified-Since: Thu, 01 Oct 2026 12:00:00 GMT
HTTP/1.1 304 Not Modified
A 304 has no body, and no Content-Length or Content-Type either. The handler did set both, but the HTTP header filter removes body-related headers for a 204 or 304 (modules/http/http_filters.c lines 2116–2122; the test macro is at include/httpd.h lines 582–583).
Easy to get wrong
- The handler decides
304too. In the spy log of section 2,/hello.txtappeared 7 times: theGETof example 2, aHEADsent to get the ETag, two304s, and below, two206s and one416. Conditional requests reach the handler hook just like any other, anddefault_handler()inside it callsap_meets_conditions()to decide. So a handler you write yourself will never produce a304unless it callsap_meets_conditions()itself. The core code that processes CGI output calls this function separately as well (server/util_script.cline 577). - There is no guarantee the ETag is the same across servers. The default ETag is just size and modification time, so if you deploy the same file to several servers and the modification times differ, the ETags differ too. That can be why
304s rarely happen behind a load balancer (a conclusion that follows from the source; I did not experiment with multiple servers).
7. Range — 206 and the BYTERANGE filter
Range: bytes=0-9 means "give me only the first 10 bytes". Resumable downloads and video seeking use it. But the word Range doesn't appear in default_handler()'s code, except for the line that adds the Accept-Ranges: bytes header. Cutting the response is done by an output filter. When an HTTP request is created, http_create_request() inserts four filters: BYTERANGE, CONTENT_LENGTH, HTTP_HEADER and HTTP_OUTERROR (modules/http/http_core.c lines 257–269). It inserts them only on an original request, one with no r->main (the parent of a subrequest) and no r->prev (the request before an internal redirect).
The BYTERANGE filter (ap_byterange_filter(), modules/http/byterange_filter.c from line 405) works like this.
for (e = APR_BRIGADE_FIRST(bb);
(e != APR_BRIGADE_SENTINEL(bb) && !APR_BUCKET_IS_EOS(e)
&& e->length != (apr_size_t)-1);
e = APR_BUCKET_NEXT(e)) {
clength += e->length;
}
/*
* Don't attempt to do byte range work if this brigade doesn't
* contain an EOS, or if any of the buckets has an unknown length;
* this avoids the cases where it is expensive to perform
* byteranging (i.e. may require arbitrary amounts of memory).
*/
if (!APR_BUCKET_IS_EOS(e) || clength <= 0) {
ap_remove_output_filter(f);
return ap_pass_brigade(f->next, bb);
}
modules/http/byterange_filter.c lines 441–457
- It adds up the bucket lengths to get the total length. Everything up to the EOS must be in one brigade, with no bucket of unknown length. Otherwise the filter removes itself from the chain and passes everything through unchanged.
default_handler()passes one file bucket plus EOS in one go, so it always meets this condition. ap_set_byterange()parses theRangeheader. If it doesn't start withbytes=or the status isn't200, it does nothing (lines 103–106); ifIf-Rangedoesn't match, it also does nothing (lines 122–124). Once the ranges are sorted out it changes the status to206 Partial Content(line 283). If every range lies outside the file, it's a416(lines 262–265;send_416()at line 391).- For each range,
copy_brigade_range()copies the buckets and splits them (apr_bucket_split, lines 294–389). Splitting a file bucket just yields a file bucket meaning "bytes 0–9 of the file"; the file isn't read. With two or more ranges, it wraps them inmultipart/byteranges.
There is also a cap on the number of ranges. Without MaxRanges, MaxRangeOverlaps and MaxRangeReversals, a request with more than 200 ranges, 20 overlaps or 20 reversed ranges has its ranges ignored and gets the whole file (defaults at lines 59–65, the check at lines 463–470). This guards against attacks that send thousands of ranges to exhaust server memory.
Example 6: 206 and 416
### 4. Range: bytes=0-9
HTTP/1.1 206 Partial Content
Last-Modified: Thu, 01 Oct 2026 12:00:00 GMT
ETag: "15-65cc627b95000"
Accept-Ranges: bytes
Content-Length: 10
Content-Range: bytes 0-9/21
Content-Type: text/plain
0123456789
### 5. Range: bytes=0-1,5-6
HTTP/1.1 206 Partial Content
Last-Modified: Thu, 01 Oct 2026 12:00:00 GMT
ETag: "15-65cc627b95000"
Accept-Ranges: bytes
Content-Length: 186
Content-Type: multipart/byteranges; boundary=3741308f48741222
--3741308f48741222
Content-type: text/plain
Content-range: bytes 0-1/21
01
--3741308f48741222
Content-type: text/plain
Content-range: bytes 5-6/21
56
--3741308f48741222--
### 6. Range: bytes=100-200
HTTP/1.1 416 Requested Range Not Satisfiable
I removed the Date and Server lines; the boundary= value differs on every run. The Content-Length: 21 set by the handler became 10. Since the filter changed the body, the CONTENT_LENGTH filter after it counts the length again (the comment in the BYTERANGE filter assumes exactly this, lines 547–552).
The statuses and lengths of the three responses are exactly the output of example 6 above. Yellow boxes are the shape of the brigade; green and red boxes are responses.
Easy to get wrong: your own handler may not support Range
It's because of condition 1 above. If a handler writes a bit at a time with ap_rprintf(), flushing along the way, so that the response is passed in several brigades, the first brigade has no EOS and the BYTERANGE filter drops out right away. Then sending Range gets you a 200 and the whole body. Static files support Range well because default_handler() passes a file bucket of known length and the EOS together.
What this post leaves out
MultiviewsMatchand content negotiation: thefoundvariable inmod_mime, andmod_negotiationchoosing betweenindex.html.koandindex.html.enbased onAccept-Language(modules/mappers/mod_negotiation.c).AddCharset,AddLanguage,AddEncodingand assembling the Content-Type: the second half offind_ct()(lines 1001–1064).FileETag Digest:make_digest_etag(), which builds a strong ETag from a hash of the content (server/util_etag.cfrom line 94).- The 60-second rule of
If-Modified-Since: the part that treats a time comparison within 60 seconds of a change as a "weak match" (http_protocol.clines 456–504). - How
mod_autoindexbuilds a listing:index_directory(),IndexOptions,HeaderName/ReadmeName(modules/generators/mod_autoindex.c). FallbackResource:fixup_dflt(), which funnels requests for missing files to one URL (mod_dir.clines 190–241).mod_file_cache: a module that opens or mmaps files ahead of time at startup (modules/cache/mod_file_cache.c).- Differences from 2.4.x: on the 2.4.x branch the ETag code lives in
modules/http/http_etag.c; trunk moved it toserver/util_etag.c. I checked thatdefault_handler(),AP_SENDFILE_ENABLED, the handler-name logic inap_invoke_handler(), and the hook position ofmod_dirlook the same on the 2.4.x branch. One difference: 2.4.x'sdefault_handler()also has code that adds aContent-MD5header whenContentDigest Onis set; trunk dropped it.
Summary
| Concept | One-line summary |
|---|---|
Handler name (r->handler) |
A string, not a function. Each handler looks at it and decides whether to take the request |
AddHandler |
Set by mod_mime in type_checker. Matches extensions that aren't the last one, too |
SetHandler |
Overwritten by the core at the front of fixups. Runs after AddHandler, so it wins |
| No name | ap_invoke_handler() uses the Content-Type (text/plain, …) as the name |
default_handler() |
The very end of the handler hook. Ignores the name, opens the file, passes a file bucket + EOS |
| A handler name that doesn't exist | No error; default_handler() sends the file as is |
EnableSendfile |
Off by default. When on, the file is flagged at open and the core output filter uses sendfile() |
EnableMMAP |
On by default. The file bucket becomes an mmap bucket when read. When off, read() 8000 bytes at a time |
| ETag | By default "size-mtime" (hex, microseconds). W/ within one second of a change |
ap_meets_conditions() |
If-Match → If-Unmodified-Since → If-None-Match → If-Modified-Since → If-Range. Decides 304 and 412 |
| Range | Not the handler but the BYTERANGE output filter splits buckets for a 206. The EOS must be in the same brigade |
mod_dir |
Not a handler but fixups. No trailing /: 301; with one: DirectoryIndex via subrequests |
mod_autoindex |
When there's no index file, claims the httpd/unix-directory name and builds a listing |
What's next
We followed one file all the way to a response. But all along, this post's configuration had Require all granted. Without that one line, none of these requests would have reached a handler. How Require, <RequireAll> and AuthType Basic are decided in the access_checker, check_user_id and auth_checker phases, and where 401 and 403 come from, continues in part 13: Authentication and Authorization — How a Require Line Is Decided.
Spring MVC solves "who handles this request?" in the same shape. The HandlerAdapter in Spring part 22 answers for itself, via supports(handler), whether it knows how to call a handler, and DispatcherServlet uses the first adapter that says true. It's the same idea as httpd's RUN_FIRST hook, where each handler looks at the r->handler name and answers OK or DECLINED. The difference: Spring's handler is a typed object, and when none is found noHandlerFound() ends with a 404 (part 21), while httpd's handler is a string name, and when nobody claims it default_handler() just sends the file.