Guide contents

Lesson 26 of 73

The Hub HTTP API and Cache Layout in Java

A local model starts as files on someone else's server. Before a program can run a model, it has to find the right files, download them without corruption, and store them where other tools can find them again. This lesson builds that part in Java.

The Hugging Face Hub is the website that hosts model files. Besides its web pages, it offers an HTTP API (application programming interface): a set of URLs that return JSON (a text format for structured data) or the bytes of a file. The previous lesson downloaded files with the hf tool and llama.cpp. This lesson does the same work in Java with java.net.http.HttpClient (the HTTP client built into the JDK) and Jackson (a Java JSON library).

Afterwards you can call the Hub's endpoints, read the headers that come with a download, handle redirects, tokens, and rate limits, and store files in the same folder layout that huggingface_hub (the official Python client) and llama.cpp share. The last example is a HubClient class that the loop pull command reuses in section 4. The table lists the endpoints and ideas in the order they appear.

Endpoint or concept Used for
GET /api/models/{repo} Model metadata and the file list
GET /api/models/{repo}/refs Branches and tags with their commits
GET /api/models/{repo}/tree/{revision} Files with sizes and hashes
GET /api/models?search=... Searching for models
GET/HEAD /{repo}/resolve/{revision}/{path} Downloading a file; its commit, ETag, and size
Git LFS pointer, Xet How large files are stored
models--org--name/{blobs,snapshots,refs} The local cache

The environment that checks this course's examples cannot reach huggingface.co. For that reason, Example 1 is the only example that needs the network, and it is marked java,ignore so the checker skips it.

Every other example works offline in one of two ways. Some parse a JSON document in the shape the huggingface_hub 2.1.1 client reads (the field names come from its source). The others talk to a fake Hub: a small web server that runs inside the same program on 127.0.0.1 (the address of your own machine) with a port chosen by the operating system. Hashes, sizes, and counts in the fake data are invented for the examples.

Example 1: GET /api/models/{repo} from Java

An HTTP request has a method, a URL, and headers (named values that travel with the request). The method GET means "send me this resource". The server answers with a status code (200 means success) and a body. The first thing a download tool needs is the metadata of a model, which the Hub serves at /api/models/{repo}.

The program below sends one GET request and prints the status and the first 300 characters of the JSON body. If the environment variable HF_TOKEN is set, it also sends a token. A token is a secret string that identifies a Hub account. It travels in the Authorization header in the form Bearer <token>.

Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class ModelInfoRequest {
    public static void main(String[] args) throws Exception {
        String repo = "Qwen/Qwen2.5-Coder-0.5B-Instruct";
        HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
        HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("https://huggingface.co/api/models/" + repo))
                .header("Accept", "application/json")
                .timeout(Duration.ofSeconds(30));
        String token = System.getenv("HF_TOKEN");
        if (token != null) request.header("Authorization", "Bearer " + token);
        HttpResponse<String> response = client.send(request.build(), HttpResponse.BodyHandlers.ofString());
        System.out.println("status " + response.statusCode());
        System.out.println(response.body().substring(0, Math.min(300, response.body().length())));
    }
}

The request needs no library beyond the JDK. Three details matter:

  • HttpClient is created once and reused, because it keeps connections open between requests. Opening a new connection for every request would waste time.
  • connectTimeout limits the time to set up the connection (TCP and TLS, the steps that open a network connection and encrypt it). The per-request timeout limits the wait for the response headers. Without timeouts, a stalled server would block the program forever.
  • The token is added only when HF_TOKEN is set. Public models need none.

The response is JSON, described field by field in the next example. The program has no output block because it needs network access to huggingface.co. When run with access, it prints status 200 and the first 300 characters of the JSON object that the next example describes.

Example 2: parsing a saved model-info JSON with Jackson

A model-info response is a JSON object with many fields, and a download tool needs only some of them. JSON is built from objects (named fields in braces), arrays (lists in brackets), strings, numbers, and booleans. Jackson reads it into a tree of JsonNode objects that the program can walk.

The program below parses a saved response that has the fields ModelInfo in huggingface_hub reads. It prints the model id, access flags, library, license, and a count of the files. Look at how the license and the number of weight files are found: neither is a plain field, so the program has to search for them.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

public class ParseModelInfo {
    public static void main(String[] args) throws Exception {
        String json = """
            {
              "id": "example-org/Coder-7B-Instruct",
              "author": "example-org",
              "sha": "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c",
              "lastModified": "2026-05-14T09:12:44.000Z",
              "private": false,
              "gated": false,
              "downloads": 182344,
              "likes": 412,
              "library_name": "transformers",
              "pipeline_tag": "text-generation",
              "tags": ["transformers", "safetensors", "qwen2", "text-generation", "code", "license:apache-2.0"],
              "siblings": [
                {"rfilename": ".gitattributes"}, {"rfilename": "config.json"},
                {"rfilename": "model-00001-of-00002.safetensors"}, {"rfilename": "model-00002-of-00002.safetensors"},
                {"rfilename": "model.safetensors.index.json"}, {"rfilename": "tokenizer.json"}
              ]
            }
            """;
        JsonNode info = new ObjectMapper().readTree(json);
        System.out.println(info.get("id").asText() + " @ " + info.get("sha").asText().substring(0, 12));
        System.out.println("gated: " + info.get("gated").asText() + ", private: " + info.get("private").asBoolean());
        System.out.println("library: " + info.path("library_name").asText("unknown") + ", task: " + info.path("pipeline_tag").asText());
        String license = "unknown";
        for (JsonNode tag : info.get("tags")) if (tag.asText().startsWith("license:")) license = tag.asText().substring(8);
        System.out.println("license: " + license);
        int shards = 0;
        for (JsonNode s : info.get("siblings")) if (s.get("rfilename").asText().endsWith(".safetensors")) shards++;
        System.out.println(info.get("siblings").size() + " files, " + shards + " safetensors shards");
    }
}
Output
example-org/Coder-7B-Instruct @ 4b1c7d2e9f0a
gated: false, private: false
library: transformers, task: text-generation
license: apache-2.0
6 files, 2 safetensors shards

The output walks through the response in five lines:

  1. The model id and the first 12 characters of the commit hash. A commit is one saved state of the repository, named by a long hash.
  2. Both access flags are false, so anyone can download the model.
  3. The library and task come from library_name and pipeline_tag.
  4. The license is found by looping over tags and taking the one that starts with license:. The text after the colon is the license name.
  5. The program counts 6 files and, among them, 2 whose names end in .safetensors. These are the weight shards (pieces of the weights), explained in The safetensors Format.

The fields used most often:

Field Meaning
id org/name of the repository
sha Commit hash of the revision that was queried (main by default)
private, gated Access control; gated is false, or a string such as "auto" or "manual" for gated repositories
downloads, likes Popularity counters
library_name, pipeline_tag, tags What the model is for and how it is stored; the license appears as a tag license:...
siblings The files at that revision, each with rfilename

The program reads gated with asText(). The field is a boolean in some responses and a string in others, and Jackson's asText() turns both into a string, so the code does not break on either shape. Fields that may be missing are read with path(...).asText(default). get returns null for a missing field and would cause a NullPointerException, but path returns a placeholder node and asText(default) returns the default value. The huggingface_hub source notes that most ModelInfo attributes are optional because the data the Hub returns depends on the query.

siblings contains only file names unless the request asks for more (files_metadata=True in huggingface_hub adds sizes and LFS data). A downloader wants sizes and checksums before it starts, so the next example uses the tree endpoint, which returns them directly.

Example 3: file listing with sizes and LFS hashes

A downloader should know how big each file is and what its checksum should be before it downloads anything. A checksum is a short fingerprint computed from a file's bytes. If the fingerprint of the downloaded file differs from the expected one, the file is damaged.

GET /api/models/{repo}/tree/{revision} returns an array with one entry per file or directory. A revision is a branch name, a tag, or a commit hash. Adding ?recursive=true includes files inside subdirectories. The program below prints a parsed listing, one line per entry, and shows for each file which kind of hash it carries.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Locale;

public class TreeListing {
    static final String TREE = """
        [
          {"type": "file", "oid": "a6344aac8c09253b3b630fb776ae94478aa0275b", "size": 1519, "path": ".gitattributes"},
          {"type": "file", "oid": "6f2b4ea2bf7ff0b1a6b8b3ad94e0a3f8ce0e6e1c", "size": 2210, "path": "README.md"},
          {"type": "directory", "oid": "0c5b3c9b8f6d7e1a2b3c4d5e6f708192a3b4c5d6", "size": 0, "path": "extra"},
          {"type": "file", "oid": "3d1f0e6a2b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e", "size": 4683071008, "path": "Coder-7B-Instruct-Q4_K_M.gguf",
           "lfs": {"oid": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "size": 4683071008, "pointerSize": 135}},
          {"type": "file", "oid": "5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f7e6d", "size": 8098525216, "path": "Coder-7B-Instruct-Q8_0.gguf",
           "lfs": {"oid": "60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752", "size": 8098525216, "pointerSize": 135}}
        ]
        """;

    public static void main(String[] args) throws Exception {
        for (JsonNode e : new ObjectMapper().readTree(TREE)) {
            if (!e.get("type").asText().equals("file")) { System.out.println("dir   " + e.get("path").asText() + "/"); continue; }
            JsonNode lfs = e.get("lfs");
            String hash = lfs != null ? "sha256 " + lfs.get("oid").asText().substring(0, 16) : "git    " + e.get("oid").asText().substring(0, 16);
            System.out.println(String.format(Locale.ROOT, "%-5s %-32s %13d  %s", lfs != null ? "lfs" : "git",
                    e.get("path").asText(), e.get("size").asLong(), hash));
        }
    }
}
Output
git   .gitattributes                            1519  git    a6344aac8c09253b
git   README.md                                 2210  git    6f2b4ea2bf7ff0b1
dir   extra/
lfs   Coder-7B-Instruct-Q4_K_M.gguf       4683071008  sha256 9f86d081884c7d65
lfs   Coder-7B-Instruct-Q8_0.gguf         8098525216  sha256 60303ae22b998861

In the output, the first two files are small and live directly in git, so they show a git hash. The directory extra/ has no size or hash to show. The two .gguf files are large. They are marked lfs and show a SHA-256 hash, and their sizes (4,683,071,008 and 8,098,525,216 bytes, about 4.4 GiB and 7.5 GiB) are the amount of disk a user needs. Git LFS (Large File Storage) is the git extension that keeps big files outside the normal repository. Example 10 shows what git stores in their place.

Each entry has type (file or directory), path, size, and oid. Files stored with Git LFS also have an lfs object: oid is the SHA-256 of the file content, size is its size, and pointerSize is the size of the small pointer file that git stores in its place (Example 10). RepoFile in huggingface_hub maps lfs.oid to its sha256 field.

Two different hashes appear in the listing, and they mean different things:

  • The top-level oid is a git object id (40 hex characters, SHA-1 based). For an LFS file it identifies the pointer, not the content.
  • lfs.oid is the SHA-256 of the actual bytes (64 hex characters). A downloader computes SHA-256 while it writes the file and compares it with this value to detect a corrupted or truncated download.

llama.cpp reads the same endpoint with ?recursive=true (get_repo_files in common/hf-cache.cpp) and uses lfs.oid when present, the git oid otherwise, as the name of the cached blob. Small files such as README.md are stored in git directly and have no lfs object. Naming files by hash is the basis of the cache in Example 12.

Example 4: filtering GGUF files and grouping by quant

A GGUF repository usually holds the same model at several quantization levels. Quantization stores each weight with fewer bits, which makes the file smaller and the model slightly less accurate. The name of the level, such as Q4_K_M, is called the quant and is part of the file name. A user choosing a download needs to see each quant with its total size.

Large models are often split into several files (shards), so the size of a quant is the sum over its files. The program below keeps only model files, extracts the quant name from each file name with a regular expression (a pattern that matches text), and adds up sizes per quant. Look at how Q8_0 is made of two files and how the lowercase q5_k_m is still recognized.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class GroupByQuant {
    static final Pattern QUANT = Pattern.compile("(?i)[-_.](IQ\\d_[A-Z]+|Q\\d_K(?:_[SML])?|Q\\d_\\d|BF16|F16|F32|MXFP4)(?=[-.])");

    public static void main(String[] args) throws Exception {
        String tree = """
            [
              {"type": "file", "path": "README.md", "size": 2210},
              {"type": "file", "path": "imatrix.gguf", "size": 5123456},
              {"type": "file", "path": "Coder-32B-IQ4_XS.gguf", "size": 17690000000},
              {"type": "file", "path": "Coder-32B-Q4_K_M.gguf", "size": 19850000000},
              {"type": "file", "path": "Coder-32B-q5_k_m.gguf", "size": 23260000000},
              {"type": "file", "path": "Q8_0/Coder-32B-Q8_0-00001-of-00002.gguf", "size": 19990000000},
              {"type": "file", "path": "Q8_0/Coder-32B-Q8_0-00002-of-00002.gguf", "size": 14820000000}
            ]
            """;
        Map<String, long[]> groups = new TreeMap<>();               // quant -> {bytes, files}
        for (JsonNode f : new ObjectMapper().readTree(tree)) {
            String path = f.get("path").asText();
            if (!path.endsWith(".gguf") || path.contains("imatrix") || path.contains("mmproj")) continue;
            Matcher m = QUANT.matcher(path.substring(path.lastIndexOf('/') + 1));
            String quant = m.find() ? m.group(1).toUpperCase(Locale.ROOT) : "unknown";
            long[] g = groups.computeIfAbsent(quant, k -> new long[2]);
            g[0] += f.get("size").asLong();
            g[1]++;
        }
        groups.forEach((q, g) -> System.out.println(String.format(Locale.ROOT, "%-7s %d file(s) %7.2f GiB", q, g[1], g[0] / Math.pow(2, 30))));
    }
}
Output
IQ4_XS  1 file(s)   16.48 GiB
Q4_K_M  1 file(s)   18.49 GiB
Q5_K_M  1 file(s)   21.66 GiB
Q8_0    2 file(s)   32.42 GiB

The output has one line per quant. Q4_K_M is one 19,850,000,000-byte file, which is 18.49 GiB (a GiB is 2^30 bytes, 1,073,741,824). Q8_0 has two files, 19.99 GB plus 14.82 GB, which add up to 32.42 GiB. The README and the imatrix.gguf file are skipped.

The steps:

  1. Keep files that end in .gguf and skip imatrix and mmproj files, the same rule llama.cpp applies (The Hugging Face Hub, Example 7).
  2. Find the quant name in the file name with a regular expression. The pattern accepts IQ4_XS, Q4_K_M, Q8_0, BF16, and similar names between separators, in any case; q5_k_m becomes Q5_K_M.
  3. Add the sizes of all files with the same quant. The two shards of Q8_0 sum to 32.42 GiB, which is the number that matters for memory.

The regular expression is a heuristic, a rule that works for common names but is not guaranteed. A file named without a quant (model.gguf) lands in unknown, and an unusual name can match the wrong part. A tool that needs certainty reads general.file_type from the GGUF header after downloading the first few kilobytes (The GGUF Format shows how). The TreeMap keeps the output sorted, so it is the same on every run.

Example 5: building resolve URLs with correct encoding

The download URL of a file is {endpoint}/{repo}/resolve/{revision}/{path}. The endpoint is the Hub's base address, and the repo is org/name. Building this URL by joining strings is risky when the revision or the path contains characters that have a meaning inside URLs, such as /, ?, #, a space, or non-English letters.

The fix is percent-encoding: each unsafe byte is written as % followed by two hex digits. A space (byte 0x20) becomes %20. The program below encodes the parts the way hf_hub_url in huggingface_hub does, builds four URLs, and shows what happens when the repo id is malicious.

Java
import java.nio.charset.StandardCharsets;

public class ResolveUrls {
    // Percent-encodes everything except RFC 3986 unreserved characters (and '/' when keepSlash).
    static String encode(String s, boolean keepSlash) {
        StringBuilder out = new StringBuilder();
        for (byte b : s.getBytes(StandardCharsets.UTF_8)) {
            char c = (char) (b & 0xFF);
            boolean unreserved = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
                    || c == '-' || c == '.' || c == '_' || c == '~' || (keepSlash && c == '/');
            out.append(unreserved ? String.valueOf(c) : String.format("%%%02X", b & 0xFF));
        }
        return out.toString();
    }

    static String resolveUrl(String endpoint, String repo, String revision, String file) {
        if (!repo.matches("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+")) throw new IllegalArgumentException("bad repo id: " + repo);
        return endpoint + "/" + repo + "/resolve/" + encode(revision, false) + "/" + encode(file, true);
    }

    public static void main(String[] args) {
        String hub = "https://huggingface.co";
        System.out.println(resolveUrl(hub, "example-org/Coder-7B-Instruct-GGUF", "main", "Coder-7B-Instruct-Q4_K_M.gguf"));
        System.out.println(resolveUrl(hub, "example-org/Coder-7B-Instruct-GGUF", "refs/pr/3", "Q8_0/part 1.gguf"));
        System.out.println(resolveUrl(hub, "example-org/multilingual", "v1.0", "données/vocab+merges.txt"));
        try {
            resolveUrl(hub, "../../etc", "main", "passwd");
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}
Output
https://huggingface.co/example-org/Coder-7B-Instruct-GGUF/resolve/main/Coder-7B-Instruct-Q4_K_M.gguf
https://huggingface.co/example-org/Coder-7B-Instruct-GGUF/resolve/refs%2Fpr%2F3/Q8_0/part%201.gguf
https://huggingface.co/example-org/multilingual/resolve/v1.0/donn%C3%A9es/vocab%2Bmerges.txt
bad repo id: ../../etc

Read the output line by line. The first URL needs no encoding. The second URL shows refs/pr/3 turned into refs%2Fpr%2F3 and the space in part 1.gguf turned into %20, while the slash in Q8_0/part 1.gguf stays. The third URL shows the accented letter and the plus sign encoded. The last line is the message of the exception that rejects ../../etc.

hf_hub_url calls Python's quote on the revision with no safe characters and on the file name with / kept. The Java version matches that: every byte that is not an unreserved character (A-Z a-z 0-9 - . _ ~) becomes %XX, the file path keeps its slashes, and the revision does not.

  • refs/pr/3, the revision of a pull request, becomes refs%2Fpr%2F3. Unencoded, its slashes would be read as path separators.
  • A space becomes %20. URLEncoder.encode would produce +, which is the form encoding for query strings, not for paths.
  • é is two UTF-8 bytes and becomes %C3%A9; + becomes %2B.

The repository id is not encoded but validated, because a repo id has a strict shape: two names of letters, digits, _, ., and - with one slash, close to is_valid_repo_id in llama.cpp. A repo id such as ../../etc is rejected before it can become part of a URL or, later, a cache path. This matters because .. in a path can point to a parent directory. Without the check, a hostile repo id could make the program write files outside the cache.

Example 6: HEAD on a resolve URL: commit, ETag, size, and redirect

A HEAD request is a GET that returns only the status and headers, not the body. A client can send HEAD to a resolve URL to learn about a file (its commit, checksum, and size) without downloading its bytes.

The program below starts a fake Hub that answers HEAD with the headers get_hf_file_metadata in huggingface_hub reads. It does not follow the redirect that the fake Hub sends, so that the headers of the redirect response itself are visible. Look at the status code and at which header supplies each value.

Java
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class HeadResolve {
    public static void main(String[] args) throws Exception {
        HttpServer hub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        hub.createContext("/", ex -> {                            // a fake Hub answering HEAD on a resolve URL
            var h = ex.getResponseHeaders();
            h.add("X-Repo-Commit", "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c");
            h.add("X-Linked-Etag", "\"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08\"");
            h.add("X-Linked-Size", "4683071008");
            h.add("ETag", "W/\"3d1f0e6a2b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e\"");
            h.add("Location", "https://cdn.example.net/blobs/9f86d081?signature=abc");
            ex.sendResponseHeaders(302, -1);
            ex.close();
        });
        hub.start();
        String url = "http://127.0.0.1:" + hub.getAddress().getPort() + "/example-org/Coder-7B-Instruct-GGUF/resolve/main/Coder-7B-Instruct-Q4_K_M.gguf";
        HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();
        HttpResponse<Void> r = client.send(HttpRequest.newBuilder(URI.create(url)).method("HEAD", HttpRequest.BodyPublishers.noBody()).build(),
                HttpResponse.BodyHandlers.discarding());
        String etag = r.headers().firstValue("X-Linked-Etag").or(() -> r.headers().firstValue("ETag")).orElseThrow();
        etag = etag.replaceFirst("^W/", "").replace("\"", "");      // _normalize_etag
        System.out.println("status   " + r.statusCode());
        System.out.println("commit   " + r.headers().firstValue("X-Repo-Commit").orElse("?"));
        System.out.println("etag     " + etag);
        System.out.println("size     " + r.headers().firstValue("X-Linked-Size").orElse("?"));
        System.out.println("location " + r.headers().firstValue("Location").orElse("?").replaceAll("\\?.*", "?..."));
        hub.stop(0);
    }
}
Output
status   302
commit   4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c
etag     9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
size     4683071008
location https://cdn.example.net/blobs/9f86d081?...

In the output, the status is 302, which means "the content is at another address, given in Location". The commit is the full hash the revision main pointed to. The ETag has had its quotes removed. The size is 4,683,071,008 bytes. The location is a CDN address, shortened by the program so the output does not contain the signature.

The headers and what huggingface_hub does with them:

Header Meaning
X-Repo-Commit The commit the revision resolved to; the client stores it in refs/{revision}
X-Linked-Etag The ETag of the file content; for LFS files, its SHA-256. Preferred over ETag
ETag Entity tag of the response; used when X-Linked-Etag is missing
X-Linked-Size Size of the file content
Location Where the bytes are served from, often a CDN with a signed URL

A large file is not served by the Hub itself. The resolve URL answers 302 with a Location pointing to a content delivery network (CDN, a network of servers that serve files from many places), and the metadata comes on that redirect response. The client sets Redirect.NEVER to see the 302 instead of following it.

An ETag (entity tag) is an identifier the server gives to a version of a resource. The value needs cleaning. The HTTP standard puts ETags in quotes and allows a weak form W/"..."; _normalize_etag removes both, and the result names the blob in the cache. The huggingface_hub docstring states the rule for its value: a file's ETag is its git SHA-1 if it is stored in git, or its SHA-256 if it is stored in Git LFS.

Example 7: redirects to the CDN and where the token is sent

Example 6 showed that a download starts with a redirect to a CDN. The token that proves who you are belongs to the Hub. The CDN is a different service, and sending the token there would leak it to a party that does not need it.

The program below answers one question: what does Java's HttpClient do with an Authorization header when it follows a redirect? It runs three servers. A fake CDN echoes the Authorization header it receives. One fake Hub redirects to the CDN on another port. A second fake Hub redirects to a path on itself. The client always sends the token hf_secret and follows redirects.

Java
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class RedirectToken {
    static HttpServer server(String location) throws Exception {
        HttpServer s = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        s.createContext("/", ex -> {
            if (location != null && ex.getRequestURI().getPath().startsWith("/repo")) {
                ex.getResponseHeaders().add("Location", location.isEmpty() ? "/blob" : location);
                ex.sendResponseHeaders(302, -1);
            } else {
                byte[] body = ("Authorization=" + ex.getRequestHeaders().getFirst("Authorization")).getBytes();
                ex.sendResponseHeaders(200, body.length);
                ex.getResponseBody().write(body);
            }
            ex.close();
        });
        s.start();
        return s;
    }

    static String get(HttpClient c, String url) throws Exception {
        HttpRequest req = HttpRequest.newBuilder(URI.create(url)).header("Authorization", "Bearer hf_secret").build();
        return c.send(req, HttpResponse.BodyHandlers.ofString()).body();
    }

    public static void main(String[] args) throws Exception {
        HttpServer cdn = server(null);
        HttpServer hubToCdn = server("http://127.0.0.1:" + cdn.getAddress().getPort() + "/blob");
        HttpServer hubToSelf = server("");
        HttpClient follow = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
        System.out.println("redirect to other origin: " + get(follow, "http://127.0.0.1:" + hubToCdn.getAddress().getPort() + "/repo"));
        System.out.println("redirect to same origin:  " + get(follow, "http://127.0.0.1:" + hubToSelf.getAddress().getPort() + "/repo"));
        cdn.stop(0); hubToCdn.stop(0); hubToSelf.stop(0);
    }
}
Output
redirect to other origin: Authorization=null
redirect to same origin:  Authorization=Bearer hf_secret

Each output line shows the header value the final server received. null means no header arrived. Through the redirect to the other server, the token was dropped. Through the redirect to the same server, it was kept.

The output shows the behavior of the JDK 21 client used here: with Redirect.NORMAL it followed both redirects, but it sent Authorization only on the same-origin one. An origin is the combination of scheme (http or https), host, and port, so 127.0.0.1 on two ports counts as two origins.

That is the safe behavior, and other clients need the same rule:

  • huggingface_hub follows redirects itself for HEAD requests and stops at the first redirect to a host that is not the Hub ("the auth header must not leave the Hub", in _httpx2_follow_hub_redirects_with_backoff).
  • A client that follows redirects manually must drop the token when the target origin differs.

The CDN does not need the token: the Location URL is signed (it contains a value computed by the Hub that proves the Hub allowed this request), so the URL itself grants access for a limited time. Redirect.NORMAL also refuses to follow a redirect from HTTPS to HTTP, which prevents a downgrade to an unencrypted connection.

Example 8: the Authorization: Bearer header and 401 and 403 on gated repositories

Some repositories are gated: the owner requires each user to accept a license or request access before downloading. A request without permission fails with an HTTP error status. 401 means "not authenticated" and 403 means "authenticated but not allowed". A bare status code is a poor message for a user, so a client should turn it into an instruction.

The program below sends HEAD requests to a fake gated repository three times: without a token, with a token that has no access, and with a token that has access. It classifies each response the way hf_raise_for_status in huggingface_hub does, by reading a header before looking at the status. Look at how two different status codes lead to the same message.

Java
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class GatedAccess {
    public static void main(String[] args) throws Exception {
        HttpServer hub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        hub.createContext("/", ex -> {                            // fake gated repo: only hf_granted has access
            String auth = ex.getRequestHeaders().getFirst("Authorization");
            int status = auth == null ? 401 : auth.equals("Bearer hf_granted") ? 200 : 403;
            if (status != 200) ex.getResponseHeaders().add("X-Error-Code", "GatedRepo");
            ex.sendResponseHeaders(status, -1);
            ex.close();
        });
        hub.start();
        HttpClient client = HttpClient.newHttpClient();
        String url = "http://127.0.0.1:" + hub.getAddress().getPort() + "/example-org/Gated-8B/resolve/main/config.json";
        for (String token : new String[] {null, "hf_other", "hf_granted"}) {
            HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)).method("HEAD", HttpRequest.BodyPublishers.noBody());
            if (token != null) b.header("Authorization", "Bearer " + token);
            HttpResponse<Void> r = client.send(b.build(), HttpResponse.BodyHandlers.discarding());
            String code = r.headers().firstValue("X-Error-Code").orElse("");
            String meaning = r.statusCode() == 200 ? "ok"
                    : code.equals("GatedRepo") ? "gated: accept the conditions on the model page with this account"
                    : r.statusCode() == 401 ? "not found, or private/gated without a token" : "forbidden";
            System.out.printf("token %-10s -> %d %s%n", token, r.statusCode(), meaning);
        }
        hub.stop(0);
    }
}
Output
token null       -> 401 gated: accept the conditions on the model page with this account
token hf_other   -> 403 gated: accept the conditions on the model page with this account
token hf_granted -> 200 ok

The first two requests fail with 401 and 403, and both print the same advice. The third request, with the right token, succeeds with 200.

hf_raise_for_status checks the response headers before the status code:

Condition Error raised by huggingface_hub
X-Error-Code: RevisionNotFound RevisionNotFoundError
X-Error-Code: EntryNotFound RemoteEntryNotFoundError (no such file)
X-Error-Code: GatedRepo GatedRepoError
X-Error-Code: RepoNotFound, or 401 on a repo URL RepositoryNotFoundError
403 HfHubHTTPError with "Make sure your token has the correct permissions"
429 Rate limit error; see Example 14

The fake server follows that mapping.

Without a token it returns 401, with a token that has no access 403, both with X-Error-Code: GatedRepo, and with the right token 200. The client reads the header first, so both failures produce the same instruction: accept the conditions on the model page with the account the token belongs to. The source comments explain why the header matters. The Hub returns 401 both for missing repositories and for private or gated ones when the client is not authenticated, so the status code alone cannot tell the two cases apart. The exact status codes the real Hub returns in each gated case are not shown here; the fake implements the client's decision rules.

Example 9: search: models filtered by library and sorted by downloads

Before downloading, a user has to find the right repository. The Hub offers search at GET /api/models, controlled by query parameters: name=value pairs after a ? in the URL, joined by &. The list_models function in huggingface_hub sends this kind of request.

The program below builds a search URL for GGUF models whose name contains "coder", sorted by downloads. It then parses a sample response and extracts the address of the next page of results. Search results are split into pages because a full list would be too large for one response.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

public class SearchModels {
    public static void main(String[] args) throws Exception {
        Map<String, String> params = new LinkedHashMap<>();
        params.put("search", "coder");
        params.put("filter", "gguf");
        params.put("sort", "downloads");
        params.put("limit", "3");
        String query = params.entrySet().stream()
                .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
                .collect(Collectors.joining("&"));
        System.out.println("GET https://huggingface.co/api/models?" + query);

        String body = """
            [
              {"id": "example-org/Coder-7B-Instruct-GGUF", "downloads": 951234, "likes": 310, "tags": ["gguf", "code"]},
              {"id": "another/Coder-1.5B-GGUF", "downloads": 402110, "likes": 95, "tags": ["gguf"]},
              {"id": "third/Coder-32B-Instruct-GGUF", "downloads": 220045, "likes": 188, "tags": ["gguf", "code"]}
            ]
            """;
        for (JsonNode m : new ObjectMapper().readTree(body))
            System.out.printf("%-36s downloads %7d likes %4d%n", m.get("id").asText(), m.get("downloads").asLong(), m.get("likes").asInt());

        String link = "<https://huggingface.co/api/models?search=coder&filter=gguf&sort=downloads&limit=3&cursor=eyJ2Ijo>; rel=\"next\"";
        Matcher next = Pattern.compile("<([^>]+)>;\\s*rel=\"next\"").matcher(link);
        System.out.println("next page: " + (next.find() ? next.group(1) : "none"));
    }
}
Output
GET https://huggingface.co/api/models?search=coder&filter=gguf&sort=downloads&limit=3
example-org/Coder-7B-Instruct-GGUF   downloads  951234 likes  310
another/Coder-1.5B-GGUF              downloads  402110 likes   95
third/Coder-32B-Instruct-GGUF        downloads  220045 likes  188
next page: https://huggingface.co/api/models?search=coder&filter=gguf&sort=downloads&limit=3&cursor=eyJ2Ijo

The first output line is the URL the program would request. The next three lines are the sample results, ordered from most to fewest downloads. The last line is the URL of page two, which is the same query plus a cursor value.

The parameters used here are among those list_models sends: search (a substring of the repository id), filter (tags, which include libraries such as gguf), sort (downloads, likes, createdAt, lastModified, or trendingScore), and limit. Others include author and pipeline_tag. Query values are encoded with URLEncoder, which is correct for query strings.

The response is a JSON array of model summaries with fewer fields than the model-info endpoint. Results come in pages. The Link response header carries the URL of the next page with rel="next" (the same format GitHub uses), and huggingface_hub follows it until it is absent (paginate in utils/_pagination.py). The program extracts that URL with a regular expression; a client must treat the URL as opaque and request it as given.

Example 10: parsing an LFS pointer file

Git is designed for text and copies the full history of every file to every clone. Storing a multi-gigabyte weights file that way would make clones huge. Git LFS solves this by committing a tiny text file, a pointer, in place of the big file. The real bytes live on a separate storage server and are fetched by hash.

The program below builds the pointer for a small 33-byte blob, parses it back into fields, and prints its size. Look at the three lines of the pointer and at how its size compares with the blob.

Java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;

public class LfsPointer {
    static String pointerFor(byte[] content) throws Exception {
        String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
        return "version https://git-lfs.github.com/spec/v1\noid sha256:" + sha + "\nsize " + content.length + "\n";
    }

    static Map<String, String> parse(String pointer) {
        Map<String, String> fields = new LinkedHashMap<>();
        for (String line : pointer.split("\n")) {
            int space = line.indexOf(' ');
            if (space > 0) fields.put(line.substring(0, space), line.substring(space + 1));
        }
        if (!"https://git-lfs.github.com/spec/v1".equals(fields.get("version"))) throw new IllegalArgumentException("not an LFS pointer");
        return fields;
    }

    public static void main(String[] args) throws Exception {
        byte[] blob = "GGUF fake weights for the example".getBytes(StandardCharsets.UTF_8);
        String pointer = pointerFor(blob);
        System.out.print(pointer);
        Map<String, String> f = parse(pointer);
        System.out.println("oid  = " + f.get("oid"));
        System.out.println("size = " + Long.parseLong(f.get("size")));
        System.out.println("pointerSize = " + pointer.getBytes(StandardCharsets.UTF_8).length);
    }
}
Output
version https://git-lfs.github.com/spec/v1
oid sha256:eac3b4dfaea89f3a50456a409b36795617f1100ab661fdba826af7f4579208bb
size 33
oid  = sha256:eac3b4dfaea89f3a50456a409b36795617f1100ab661fdba826af7f4579208bb
size = 33
pointerSize = 127

The first three output lines are the pointer itself. The oid line holds the SHA-256 of the 33 bytes, and the size line says 33. The last three lines are what the parser read back, plus the size of the pointer in bytes: 127.

A Git LFS pointer is a short text file that git commits in place of the real content. It has one key value pair per line: version (the spec URL), oid (the hash algorithm and the content hash), and size (the content size in bytes). The real bytes live in LFS storage and are fetched by hash.

The pointer for the 33-byte example blob is 127 bytes. For a multi-gigabyte file the pointer is a little longer only because the size value has more digits, which is why the tree listing showed pointerSize: 135 for a 4.7 GB file. A client that downloads through resolve URLs never sees pointers: the Hub resolves them. Pointers appear when a repository is cloned with plain git without the LFS extension installed, which leaves small text files where the weights should be. Checking the first line of a suspicious file for version https://git-lfs.github.com/spec/v1 detects that case.

Example 11: Xet storage and what changes for a client

The Hub stores newer large files in Xet, a storage system that splits files into content-defined chunks and stores each distinct chunk once, so similar files (two quants of one model, or two versions of a fine-tune) share storage and upload only what changed. A client does not need to know how a file is stored to download it, but it can use faster paths when it knows. The Hub tells the client through response headers. The program below applies the rule of parse_xet_file_data_from_response in huggingface_hub to two sets of response headers: one for a plain LFS file and one for a Xet-backed file. It returns a XetFile record when the headers describe Xet storage and null otherwise.

Java
import java.util.List;
import java.util.Map;

public class XetHeaders {
    record XetFile(String hash, String refreshRoute) {}

    // parse_xet_file_data_from_response: X-Xet-Hash plus a refresh route from Link rel="xet-auth" or a header.
    static XetFile parse(Map<String, List<String>> headers) {
        List<String> hash = headers.get("X-Xet-Hash");
        if (hash == null) return null;
        for (String link : headers.getOrDefault("Link", List.of()))
            for (String part : link.split(","))
                if (part.contains("rel=\"xet-auth\"")) return new XetFile(hash.get(0), part.substring(part.indexOf('<') + 1, part.indexOf('>')));
        List<String> route = headers.get("X-Xet-Refresh-Route");
        return route == null ? null : new XetFile(hash.get(0), route.get(0));
    }

    public static void main(String[] args) {
        Map<String, List<String>> lfsOnly = Map.of("X-Linked-Etag", List.of("\"9f86d081...\""), "X-Linked-Size", List.of("4683071008"));
        Map<String, List<String>> xet = Map.of(
                "X-Linked-Etag", List.of("\"9f86d081...\""),
                "X-Xet-Hash", List.of("c3a7f0e5d1b2a49687e5f4d3c2b1a09f8e7d6c5b4a3928170f6e5d4c3b2a1908"),
                "Link", List.of("<https://huggingface.co/api/models/example-org/Coder-7B-Instruct-GGUF/xet-read-token/4b1c7d2e>; rel=\"xet-auth\""));
        System.out.println("plain LFS file: " + parse(lfsOnly));
        System.out.println("Xet-backed file: " + parse(xet));
    }
}
Output
plain LFS file: null
Xet-backed file: XetFile[hash=c3a7f0e5d1b2a49687e5f4d3c2b1a09f8e7d6c5b4a3928170f6e5d4c3b2a1908, refreshRoute=https://huggingface.co/api/models/example-org/Coder-7B-Instruct-GGUF/xet-read-token/4b1c7d2e]

The plain LFS headers have no X-Xet-Hash, so the result is null. The second set has the hash and a Link header with rel="xet-auth", so the program returns the hash and the refresh route.

A file is Xet-backed when the HEAD response has an X-Xet-Hash header and a refresh route, given either as a Link header with rel="xet-auth" or as an X-Xet-Refresh-Route header. The route is an API endpoint where the client obtains a short-lived token for the Xet content service. Tree entries can also carry a xetHash field.

What changes for a client depends on which client it is:

Client Behavior
huggingface_hub with the hf_xet package Downloads chunks from the Xet service and reassembles the file
huggingface_hub without hf_xet Uses the plain HTTP download through the resolve URL
A plain HTTP client (this course's HubClient, llama.cpp) Uses resolve URLs as before

The resolve URL keeps working for Xet-backed files, so a Java client needs no Xet code. One limit appears in the source: huggingface_hub refuses plain HTTP downloads of files over 50 GB (MAX_HTTP_DOWNLOAD_SIZE = 50 * 1000 * 1000 * 1000) and asks for hf_xet instead. The details of the Xet protocol are outside this course.

Downloads are large, so a tool should never fetch the same bytes twice. Two revisions of a repository often share most files, and several tools (Python, llama.cpp, a Java program) may want the same model. A shared cache folder with a fixed layout lets all of them reuse one copy.

The layout has three folders: blobs holds the content, snapshots shows the content under its normal file names for each commit, and refs maps branch names to commits. A symbolic link (symlink) is a tiny file that points to another file. The snapshot entries are symlinks to blobs, so a file shared by ten commits is stored once and linked ten times.

The program below builds the layout for one file by hand in a temporary directory and lists everything it created. Look at the last line, where the arrow shows a link and its target.

Java
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.stream.Stream;

public class BuildCache {
    public static void main(String[] args) throws Exception {
        Path cache = Files.createTempDirectory("hub-cache");
        String repo = "example-org/Coder-7B-Instruct-GGUF";
        String commit = "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c";
        String etag = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
        String file = "Coder-7B-Instruct-Q4_K_M.gguf";

        Path repoDir = cache.resolve("models--" + repo.replace("/", "--"));
        Path blob = repoDir.resolve("blobs").resolve(etag);
        Files.createDirectories(blob.getParent());
        Files.writeString(blob, "fake GGUF bytes");                           // content stored once, named by hash
        Path snapshot = repoDir.resolve("snapshots").resolve(commit);
        Files.createDirectories(snapshot);
        Files.createSymbolicLink(snapshot.resolve(file), snapshot.relativize(blob));  // ../../blobs/<etag>
        Files.createDirectories(repoDir.resolve("refs"));
        Files.writeString(repoDir.resolve("refs/main"), commit);              // branch name -> commit

        try (Stream<Path> paths = Files.walk(cache)) {
            paths.sorted(Comparator.comparing(Path::toString)).forEach(p -> {
                String rel = cache.relativize(p).toString();
                if (rel.isEmpty()) return;
                String extra = Files.isSymbolicLink(p) ? " -> " + readLink(p) : "";
                System.out.println(rel + extra);
            });
        }
    }

    static String readLink(Path p) {
        try { return Files.readSymbolicLink(p).toString(); } catch (Exception e) { return "?"; }
    }
}
Output
models--example-org--Coder-7B-Instruct-GGUF
models--example-org--Coder-7B-Instruct-GGUF/blobs
models--example-org--Coder-7B-Instruct-GGUF/blobs/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
models--example-org--Coder-7B-Instruct-GGUF/refs
models--example-org--Coder-7B-Instruct-GGUF/refs/main
models--example-org--Coder-7B-Instruct-GGUF/snapshots
models--example-org--Coder-7B-Instruct-GGUF/snapshots/4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c
models--example-org--Coder-7B-Instruct-GGUF/snapshots/4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c/Coder-7B-Instruct-Q4_K_M.gguf -> ../../blobs/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

The listing shows the repository folder, then its three subfolders, the blob named by its hash, the refs/main file, a snapshot folder named by the commit, and the symlink with its relative target ../../blobs/<hash>. ../.. means "up two folders", which from the snapshot folder leads to the repository folder, where blobs/ lives.

The layout, used by huggingface_hub and by llama.cpp's hf-cache.cpp:

  • models--example-org--Coder-7B-Instruct-GGUF: one directory per repository. repo_folder_name joins the repository type (models), the organization, and the name with --.
  • blobs/<etag>: the file content, named by its ETag (the SHA-256 for LFS files). A file that appears in ten commits unchanged is stored once.
  • snapshots/<commit>/<path>: one directory per commit, holding symbolic links with the file names users expect. The link is relative (../../blobs/<etag>), so the whole cache can be moved.
  • refs/main: a text file containing the commit that main pointed to at the last download.

Path.relativize builds the relative link target, and Files.createSymbolicLink creates it. huggingface_hub also uses two names not shown here: blobs/<etag>.incomplete for a download in progress, and .no_exist/<commit>/<path> to remember files that do not exist in a revision. On systems without symlink support, huggingface_hub copies files into the snapshot instead (HF_HUB_DISABLE_SYMLINKS forces that).

Example 13: resolving a cached file through refs, snapshot, and blob

When a tool is offline, or wants to avoid a download, it must find a cached file using only the folder layout. The user usually names a revision such as main, but the snapshot folders are named by commit, so the tool first turns the branch name into a commit through refs/.

The program below builds the same layout as Example 12 and runs four lookups: by branch name, by full commit hash, by an unknown branch, and by a file name that was never downloaded. For each hit it prints the real blob path and size.

Java
import java.nio.file.Files;
import java.nio.file.Path;

public class ResolveCached {
    static Path cachedFile(Path cache, String repo, String revision, String file) throws Exception {
        Path repoDir = cache.resolve("models--" + repo.replace("/", "--"));
        String commit = revision.matches("[0-9a-f]{40}") ? revision
                : Files.exists(repoDir.resolve("refs").resolve(revision)) ? Files.readString(repoDir.resolve("refs").resolve(revision)).trim() : null;
        if (commit == null) return null;
        Path candidate = repoDir.resolve("snapshots").resolve(commit).resolve(file);
        return Files.exists(candidate) ? candidate : null;                    // exists() follows the symlink
    }

    public static void main(String[] args) throws Exception {
        Path cache = Files.createTempDirectory("hub-cache");                   // same layout as Example 12
        String repo = "example-org/Coder-7B-Instruct-GGUF", commit = "4b1c7d2e9f0a8b6c5d3e2f1a0b9c8d7e6f5a4b3c";
        Path repoDir = cache.resolve("models--example-org--Coder-7B-Instruct-GGUF");
        Path blob = repoDir.resolve("blobs/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08");
        Files.createDirectories(blob.getParent());
        Files.writeString(blob, "fake GGUF bytes");
        Path snap = repoDir.resolve("snapshots").resolve(commit);
        Files.createDirectories(snap);
        Files.createSymbolicLink(snap.resolve("model-Q4_K_M.gguf"), snap.relativize(blob));
        Files.createDirectories(repoDir.resolve("refs"));
        Files.writeString(repoDir.resolve("refs/main"), commit);

        for (String[] q : new String[][] {{"main", "model-Q4_K_M.gguf"}, {commit, "model-Q4_K_M.gguf"}, {"dev", "model-Q4_K_M.gguf"}, {"main", "model-Q8_0.gguf"}}) {
            Path p = cachedFile(cache, repo, q[0], q[1]);
            String shown = p == null ? "not cached" : cache.relativize(p.toRealPath()) + " (" + Files.size(p) + " bytes)";
            System.out.printf("%-8s %-18s -> %s%n", q[0].length() > 8 ? q[0].substring(0, 7) + "~" : q[0], q[1], shown);
        }
    }
}
Output
main     model-Q4_K_M.gguf  -> models--example-org--Coder-7B-Instruct-GGUF/blobs/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 (15 bytes)
4b1c7d2~ model-Q4_K_M.gguf  -> models--example-org--Coder-7B-Instruct-GGUF/blobs/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 (15 bytes)
dev      model-Q4_K_M.gguf  -> not cached
main     model-Q8_0.gguf    -> not cached

The output has four lines. The first two lookups (main and the commit hash, shortened to 4b1c7d2~ for display) succeed and lead to the same blob. The last two print not cached.

The lookup follows the layout in reverse, the way try_to_load_from_cache in huggingface_hub does:

  1. A 40-hex revision is a commit. Any other revision is a ref name, so read refs/<revision> to get the commit.
  2. Look for snapshots/<commit>/<path>.
  3. Following the symbolic link gives the blob. Files.exists follows links by default, so a link to a missing blob counts as not cached. toRealPath resolves the link to the blob for display.

The output shows the two hits (main and the full commit) resolving to the same 15-byte blob, and two misses: dev has no ref file, and model-Q8_0.gguf has no link. Offline mode in both huggingface_hub and llama.cpp is this lookup with no fallback to the network. A cache lookup by branch name returns the commit from the last download, which can be older than the branch on the Hub; that is the reason tools check the Hub when they are online.

Example 14: 429 and Retry-After handling against an in-process HttpServer

The Hub limits how many requests a client may send in a period (a rate limit). A client over the limit gets status 429 Too Many Requests. A well-behaved client waits and tries again. The server can say how long to wait in a Retry-After header. If it does not, the client uses exponential backoff: a wait that doubles after each failure (1 s, 2 s, 4 s, up to 8 s), so a busy server is not hit repeatedly.

The program below talks to a fake server that returns 429 twice and then succeeds. It follows the retry rules of http_backoff in huggingface_hub. Look at how each wait is computed from the Retry-After value.

Java
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.atomic.AtomicInteger;

public class RetryAfter {
    public static void main(String[] args) throws Exception {
        AtomicInteger calls = new AtomicInteger();
        HttpServer hub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        hub.createContext("/", ex -> {                                         // two 429s, then success
            int n = calls.incrementAndGet();
            if (n <= 2) { ex.getResponseHeaders().add("Retry-After", n == 1 ? "1" : "0"); ex.sendResponseHeaders(429, -1); }
            else { byte[] b = "{\"ok\":true}".getBytes(); ex.sendResponseHeaders(200, b.length); ex.getResponseBody().write(b); }
            ex.close();
        });
        hub.start();
        HttpClient client = HttpClient.newHttpClient();
        URI uri = URI.create("http://127.0.0.1:" + hub.getAddress().getPort() + "/api/models/example-org/Coder-7B-Instruct");
        double backoff = 1.0;                                                  // seconds, doubled up to 8
        for (int attempt = 1; attempt <= 5; attempt++) {
            HttpResponse<String> r = client.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString());
            if (r.statusCode() != 429 && r.statusCode() < 500) { System.out.println("attempt " + attempt + ": " + r.statusCode() + " " + r.body()); break; }
            String retryAfter = r.headers().firstValue("Retry-After").orElse("");
            double wait = retryAfter.matches("\\d+") ? Integer.parseInt(retryAfter) + 1 : backoff;   // +1 s as huggingface_hub does
            System.out.println("attempt " + attempt + ": " + r.statusCode() + ", Retry-After=" + retryAfter + ", waiting " + wait + " s");
            Thread.sleep((long) (wait * 100));                                 // scaled down 10x for the example
            backoff = Math.min(8, backoff * 2);
        }
        hub.stop(0);
    }
}
Output
attempt 1: 429, Retry-After=1, waiting 2.0 s
attempt 2: 429, Retry-After=0, waiting 1.0 s
attempt 3: 200 {"ok":true}

The output shows three attempts. The first got Retry-After=1, and the program waited 1 + 1 = 2 seconds. The second got Retry-After=0, so it waited 0 + 1 = 1 second. The third attempt received 200 and stopped.

The rules in huggingface_hub (_http_backoff_base and _parse_retry_after):

  • Retry on 429 and on 500, 502, 503, and 504, up to 5 retries by default.
  • On a 429, if the RateLimit headers say the remaining quota is 0, wait until their reset time; otherwise, if Retry-After holds a number of seconds, wait that long. In both cases add 1 second "to avoid rounding issues".
  • Otherwise wait with exponential backoff: 1 second, then doubled each time, at most 8 seconds.
  • Retry-After in the HTTP-date form is not parsed; only whole seconds are.

The output shows the first 429 with Retry-After: 1 leading to a 2-second wait and the second with Retry-After: 0 to 1 second; the third attempt succeeds. The program scales the sleeps down by 10 so that it runs quickly; a real client sleeps the full time. Retry-After is the server's statement of when to retry, so a client that ignores it and retries sooner receives more 429 responses.

Example 15: a HubClient tested against a fake Hub on 127.0.0.1:0

The earlier examples each showed one piece. This example puts them together in one class. HubClient does four jobs. It resolves a branch to a commit. It lists the LFS files of that commit. It downloads a file through its resolve URL into the cache and checks the SHA-256. It skips the download when the blob is already in the cache.

The test runs HubClient against a fake Hub that serves the refs, tree, resolve, and CDN paths. It downloads the same file twice and counts how many times the CDN path was requested. If the cache works, the count stays at 1. Section 4 extends this class into loop pull.

Java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class HubClientDemo {
    record RepoFile(String path, long size, String sha256) {}

    static final class HubClient {
        private final HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
        private final ObjectMapper json = new ObjectMapper();
        private final String endpoint, token;
        private final Path cache;

        HubClient(String endpoint, String token, Path cache) { this.endpoint = endpoint; this.token = token; this.cache = cache; }

        private HttpRequest.Builder request(String url) {
            HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url));
            return token == null ? b : b.header("Authorization", "Bearer " + token);
        }

        private JsonNode getJson(String path) throws Exception {
            HttpResponse<String> r = http.send(request(endpoint + path).build(), HttpResponse.BodyHandlers.ofString());
            if (r.statusCode() != 200) throw new IllegalStateException("GET " + path + " -> " + r.statusCode());
            return json.readTree(r.body());
        }

        String commit(String repo, String revision) throws Exception {
            if (revision.matches("[0-9a-f]{40}")) return revision;
            for (JsonNode b : getJson("/api/models/" + repo + "/refs").get("branches"))
                if (b.get("name").asText().equals(revision)) return b.get("targetCommit").asText();
            throw new IllegalArgumentException("no branch " + revision);
        }

        List<RepoFile> files(String repo, String commit) throws Exception {
            List<RepoFile> out = new ArrayList<>();
            for (JsonNode e : getJson("/api/models/" + repo + "/tree/" + commit + "?recursive=true"))
                if (e.get("type").asText().equals("file") && e.has("lfs"))
                    out.add(new RepoFile(e.get("path").asText(), e.get("size").asLong(), e.get("lfs").get("oid").asText()));
            return out;
        }

        Path download(String repo, String commit, RepoFile f) throws Exception {
            Path repoDir = cache.resolve("models--" + repo.replace("/", "--"));
            Path blob = repoDir.resolve("blobs").resolve(f.sha256());
            Path link = repoDir.resolve("snapshots").resolve(commit).resolve(f.path());
            if (!Files.exists(blob)) {
                Files.createDirectories(blob.getParent());
                Path part = blob.resolveSibling(f.sha256() + ".incomplete");
                HttpResponse<InputStream> r = http.send(request(endpoint + "/" + repo + "/resolve/" + commit + "/" + f.path()).build(),
                        HttpResponse.BodyHandlers.ofInputStream());
                if (r.statusCode() != 200) throw new IllegalStateException("download -> " + r.statusCode());
                MessageDigest sha = MessageDigest.getInstance("SHA-256");
                try (InputStream in = new DigestInputStream(r.body(), sha)) { Files.copy(in, part, StandardCopyOption.REPLACE_EXISTING); }
                String actual = HexFormat.of().formatHex(sha.digest());
                if (!actual.equals(f.sha256()) || Files.size(part) != f.size()) { Files.delete(part); throw new IllegalStateException("checksum mismatch"); }
                Files.move(part, blob, StandardCopyOption.ATOMIC_MOVE);
            }
            Files.createDirectories(link.getParent());
            if (!Files.exists(link, java.nio.file.LinkOption.NOFOLLOW_LINKS)) Files.createSymbolicLink(link, link.getParent().relativize(blob));
            Files.createDirectories(repoDir.resolve("refs"));
            Files.writeString(repoDir.resolve("refs/main"), commit);
            return link;
        }
    }

    public static void main(String[] args) throws Exception {
        byte[] gguf = new byte[4096];
        for (int i = 0; i < gguf.length; i++) gguf[i] = (byte) (i * 31);
        String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(gguf));
        String commit = "0123456789abcdef0123456789abcdef01234567";
        AtomicInteger downloads = new AtomicInteger();
        HttpServer hub = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        hub.createContext("/", ex -> {
            String p = ex.getRequestURI().toString();
            byte[] body;
            if (p.endsWith("/refs")) body = ("{\"branches\":[{\"name\":\"main\",\"ref\":\"refs/heads/main\",\"targetCommit\":\"" + commit + "\"}],\"converts\":[],\"tags\":[]}").getBytes();
            else if (p.contains("/tree/")) body = ("[{\"type\":\"file\",\"path\":\"README.md\",\"size\":20,\"oid\":\"aa\"},"
                    + "{\"type\":\"file\",\"path\":\"tiny-Q8_0.gguf\",\"size\":4096,\"oid\":\"bb\",\"lfs\":{\"oid\":\"" + sha + "\",\"size\":4096,\"pointerSize\":132}}]").getBytes();
            else if (p.contains("/resolve/")) { ex.getResponseHeaders().add("Location", "/cdn/" + sha); ex.sendResponseHeaders(302, -1); ex.close(); return; }
            else { downloads.incrementAndGet(); body = gguf; }
            ex.sendResponseHeaders(200, body.length);
            ex.getResponseBody().write(body);
            ex.close();
        });
        hub.start();
        Path cache = Files.createTempDirectory("hub-cache");
        HubClient client = new HubClient("http://127.0.0.1:" + hub.getAddress().getPort(), "hf_example", cache);
        String c = client.commit("demo/tiny-GGUF", "main");
        List<RepoFile> files = client.files("demo/tiny-GGUF", c);
        System.out.println("commit " + c + ", LFS files " + files);
        for (int round = 1; round <= 2; round++) {
            Path p = client.download("demo/tiny-GGUF", c, files.get(0));
            System.out.println("round " + round + ": " + cache.relativize(p) + " (" + Files.size(p) + " bytes), CDN downloads so far: " + downloads.get());
        }
        hub.stop(0);
    }
}
Output
commit 0123456789abcdef0123456789abcdef01234567, LFS files [RepoFile[path=tiny-Q8_0.gguf, size=4096, sha256=695fb69684bbdf7f0df73d0cbcf84b46104edfe6504fd3a1dcbd340f628f8d8a]]
round 1: models--demo--tiny-GGUF/snapshots/0123456789abcdef0123456789abcdef01234567/tiny-Q8_0.gguf (4096 bytes), CDN downloads so far: 1
round 2: models--demo--tiny-GGUF/snapshots/0123456789abcdef0123456789abcdef01234567/tiny-Q8_0.gguf (4096 bytes), CDN downloads so far: 1

The first output line shows the commit and the one LFS file found (README.md is skipped because it has no lfs object). The next two lines are the two rounds of download. Both return the same snapshot path and 4,096 bytes.

The client puts the lesson's rules together:

  • commit() accepts a 40-hex commit as is and otherwise reads /refs (Example 6 of lesson 25).
  • files() reads the recursive tree and keeps LFS files with their SHA-256 (Example 3).
  • download() writes to blobs/<sha256>.incomplete, computes SHA-256 while copying with a DigestInputStream, compares hash and size with the tree listing, and renames the file with ATOMIC_MOVE only when both match. A crash or a corrupted transfer therefore never leaves a file with a final name. It then creates the snapshot link and refs/main (Example 12).
  • The resolve request is redirected to /cdn/<sha> on the same fake server; Redirect.NORMAL follows it.

Back to the output: it shows the cache doing its job. the first call downloads the 4,096 bytes from the CDN path, and the second finds the blob and makes no CDN request (the counter stays at 1). The fake Hub listens on 127.0.0.1 with port 0, so the operating system picks a free port and the test never collides with another program.

What loop pull adds in section 4: resuming partial downloads with Range requests, progress reporting, parallel ranged downloads, disk space checks, the retry rules of Example 14, and choosing a quant from Example 4's grouping.

Interview points

  • A downloader needs four endpoints: model info, refs, the recursive tree, and the resolve URL. Search uses /api/models with search, filter, sort, and limit, and the Link header points to the next page.
  • The tree listing gives each LFS file's SHA-256 (lfs.oid) and size. A downloader checks both before it gives the file its final name.
  • The git oid of an LFS file identifies its small pointer file, not the content.
  • Revisions in resolve URLs are fully percent-encoded (refs/pr/3 becomes refs%2Fpr%2F3), while file paths keep their slashes.
  • HEAD on a resolve URL returns X-Repo-Commit, X-Linked-Etag, and X-Linked-Size, and usually a 302 redirect to a CDN with a signed URL.
  • The Authorization header must not follow a redirect to another origin. The JDK 21 HttpClient drops it on cross-origin redirects, and huggingface_hub stops at redirects that leave the Hub.
  • X-Error-Code tells gated, missing, and revision errors apart. A bare 401 is ambiguous.
  • On a 429, a client waits for Retry-After seconds (or the RateLimit reset time) plus a margin. Without those headers, it backs off exponentially.
  • The cache stores content once in blobs/<etag>, exposes it through snapshots/<commit>/<path> symlinks, and maps branch names to commits in refs/.
  • Xet-backed files still download through resolve URLs. huggingface_hub needs hf_xet only for files over 50 GB.

Summary

  • HttpClient from java.net.http is enough for every Hub request in this lesson.
  • Model-info fields such as gated change type between responses, and Jackson's asText and path handle that safely.
  • ?recursive=true makes the tree endpoint list files in subdirectories.
  • Grouping GGUF files by a quant pattern and adding up shard sizes gives the disk size a user must plan for.
  • hf_hub_url percent-encodes the revision completely and the file name with / kept.
  • _normalize_etag removes the W/ prefix and the quotes from an ETag.
  • An origin is the combination of scheme, host, and port.
  • A gated repository answers with X-Error-Code: GatedRepo.
  • list_models follows Link: <...>; rel="next" headers to reach more pages.
  • An LFS pointer is a text file with version, oid sha256:..., and size lines.
  • repo_folder_name builds the folder name models--org--name.
  • Snapshot entries are relative symlinks to blobs, so the whole cache folder can be moved.
  • http_backoff retries 429 and 5xx responses up to 5 times, with waits from 1 to 8 seconds or the Retry-After value plus 1 second.
  • Writing to .incomplete, verifying SHA-256, and then renaming atomically keeps the cache consistent after a crash.