namalzi

← All posts

Digging into Apache httpd's Source 12: Handlers and Static Files — How a File Becomes a Response

Apache httpd · Published

How the "handler" set by SetHandler and AddHandler differs from the Content-Type, and how the core's default_handler() answers any request nobody else claimed by sending a single file bucket. We also check ETag and 304, Range and 206, the trailing-slash 301 and DirectoryIndex, and which system calls EnableSendfile and EnableMMAP actually change — with curl and strace.

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:

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;
        }
    }

server/core.c lines 5114–5144

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 order in which the handler name is decided. In the type_checker phase mod_mime sets content_type from the extension and handler from AddHandler; in the fixups phase core_override_type overrides them with ForceType and SetHandler. ap_invoke_handler then uses r->handler if set, otherwise content_type, otherwise an empty string, and runs the handler hook. 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);

server/core.c lines 6170–6171

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).

Order of the handler hook. First, at REALLY_FIRST, core_upgrade_handler. Then, at MIDDLE, module handlers such as mod_autoindex and mod_cgi, each checking the r->handler name. If one returns OK it stops there; if all decline, default_handler at REALLY_LAST sends the file as is without checking the name. 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

### 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.

Flow of default_handler. If the method is OPTIONS it answers with an Allow header; anything other than GET and POST is 405 or 501. For GET or POST it checks that the file exists and isn't a directory, otherwise 404. It opens the file (flagged for sendfile if EnableSendfile On), prepares the Last-Modified, ETag, Accept-Ranges and Content-Length headers, and calls ap_meets_conditions. On 304 or 412 it closes the file and sends only an EOS bucket; on OK it puts a file bucket and an EOS bucket in the brigade and hands it to ap_pass_brigade. The yellow box is the whole response body. This function doesn't read a single byte of the file.

  1. Check the method — it notes in Allow that this handler accepts GET, OPTIONS and POST (line 5181), and reads and discards any request body (lines 5188–5190). HEAD is registered inside httpd under the same number as GET (modules/http/http_protocol.c line 711), so it takes the same path.
  2. 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): 404 by default (lines 5209–5217). This is where "a 404 is decided by default_handler(), not by the walks" from part 10 happens. POST is a 405 unless specially flagged (lines 5227–5237).
  3. 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);

server/core.c lines 5241–5281

#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:

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.

Three ways the core output filter sends a file bucket. If it is at least 256 bytes and the file is flagged for sendfile (EnableSendfile On), sendfile lets the kernel copy it directly. Otherwise the bucket is read: if mmap may be used (EnableMMAP defaults to On, 4 MB pieces for bigger files), the file is mapped into memory and sent with writev; if not, it is read 8000 bytes at a time and sent with writev. 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.

Easy to get wrong

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).

Flow of a directory request. In type_checker mod_mime sets content_type to httpd/unix-directory; in fixups mod_dir's dir_fixups checks whether the URL ends in /. If not, a 301 to /docs/. If it does, a subrequest for each DirectoryIndex name; if a regular file is there, ap_internal_fast_redirect turns the main request into index.html and default_handler sends it. Otherwise the mod_autoindex handler returns a listing page with Options Indexes, or 403 without it. 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.

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 order of decisions in ap_meets_conditions. If-Match present but the ETag differs: 412. If-Unmodified-Since present but the file changed after it: 412. If-None-Match equals the current ETag: a 304 candidate for GET, 412 otherwise; different: not a candidate. Unchanged since If-Modified-Since: a 304 candidate for GET; changed: not a candidate. If If-Range and Range came together, return OK and let the Range filter decide 200 or 206. Otherwise 304 if still a candidate, else OK. 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

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

  1. 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.
  2. ap_set_byterange() parses the Range header. If it doesn't start with bytes= or the status isn't 200, it does nothing (lines 103–106); if If-Range doesn't match, it also does nothing (lines 122–124). Once the ranges are sorted out it changes the status to 206 Partial Content (line 283). If every range lies outside the file, it's a 416 (lines 262–265; send_416() at line 391).
  3. 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 in multipart/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).

What the BYTERANGE filter does. default_handler() hands over the 21-byte file in one go as a FILE(21) bucket and EOS. The filter adds up bucket lengths and checks that EOS is in the same brigade and that no bucket has an unknown length. If not, it removes itself from the chain and a 200 with the whole body goes out, a path hand-written handlers often take. If so, ap_set_byterange() parses Range. bytes=0-9 splits FILE(21) into FILE(0~9) without reading the file, giving 206 Partial Content, Content-Range: bytes 0-9/21 and Content-Length: 10, with the length recounted by the CONTENT_LENGTH filter. bytes=0-1,5-6 is wrapped as boundary, FILE(0~1), boundary, FILE(5~6), final boundary, giving 206 multipart/byteranges with Content-Length: 186. bytes=100-200 lies entirely outside the file, giving 416 Requested Range Not Satisfiable 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

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.

More posts