Skip to content

Diagnostic LLM APIs

An expired file still answers metadata and fails every use

The document pipeline has been fine for months and this morning a batch of requests started failing on a handful of customers. Not all of them, and not consistently. The error is a 404, which sends everybody looking for a typo, and the id is right — you can paste it into a metadata call and get a filename, a size and a created date back. The file exists. It answers. And every request that tries to actually use it fails before inference, because what came back was the label and the thing behind it went away eleven days ago.

Read-only key Python and Node.js Tests included
Grayscale photography of rectangular gray case on wire rack
Photo by imgix on Unsplash
The short answer

Read the date, not the status code. Anthropic's expires_in_seconds is set once at upload — between 3,600 and 7,776,000 seconds, one hour to ninety days — and cannot be changed afterwards. After the moment it names, the content stops being retrievable, the bytes are released from your storage quota, and the metadata remains readable for up to thirty days while the file keeps appearing in list responses. So an existence check against the listing or the metadata endpoint returns a confident yes for a file that will fail every real use.

Ask about the ids you actually hold. GET /v1/files accepts up to 100 ids[] values in one request, after de-duplication, and that form is mutually exclusive with page and limit and always returns a single page. Ids that do not resolve to a visible file — including deleted ones — are silently omitted from data, so comparing what came back against what you asked for is itself a finding rather than an error path.

Then grade each returned object on expires_at, which is an RFC 3339 string or null. In the past is a dead reference. Inside your warning window is a deadline you cannot move, because there is nothing to extend. Null means the file is permanent, which is fine here and is somebody else's problem in the note about the storage total.

One header decides whether any of this works. Send anthropic-beta: files-api-2025-04-14 and the response reverts to the older shape, in which expires_at is not returned at all. The script sends no beta header and, if a response comes back without the field, it reports that the check could not run rather than reporting that nothing is expiring.

The repair for an expired file is not a repair. The content is gone. What the script prints is the reference to remove, and the deletion that clears the metadata immediately instead of waiting out the thirty-day window.

The problem in plain words

Expiry on this API is a decision made once, at upload, by whichever piece of code happened to do the upload. It is an integer in a request body. There is no policy object to inspect later, no way to extend it, and nothing that reminds anybody it was set — and if the uploading service and the consuming service are different teams, the consuming side may not know an expiry exists at all.

What turns that into an outage is the gap between two lifetimes. The content and the metadata do not expire together. At expires_at the bytes stop being retrievable and leave your storage quota; the metadata carries on answering for up to thirty days after that, and the file carries on appearing in list responses for the same window. Every cheap way of asking "does this file still exist" therefore says yes during precisely the period when the answer that matters is no.

The failure lands in the worst place, too. It is not a download that fails — it is the inference request. A Messages call referencing an expired file fails before the model sees anything, so what a user experiences is not a missing attachment but a request that did not happen. And because expiry is per file and set per upload, the failures arrive spread out and uncorrelated, one customer at a time, which reads as flakiness rather than as a clock.

The quiet accomplice is a beta header. The Files API left beta, and requests still sending files-api-2025-04-14 get the older response shape, which does not include expires_at at all. Code written against that shape cannot see the field it would need to prevent any of this, and it does not error — it simply never has the date. SDK versions dropped the header independently of each other, so which shape you are on can change under a routine dependency bump.

Expiry set atuploadand neverchangeable againId kept in yourown tablewith no copy ofthe dateexpires_atpassescontent gone,quota releasedMetadata stillanswersso the id looksaliveThe requestfailsbefore inference,on the id
Only the last step failed. The step before it is the one that did the damage, by answering a question with a stale yes.

Why it happens

Existence is the wrong question and the API will answer it wrongly on purpose. Metadata outliving content is a deliberate, documented design: it gives you a window in which you can still find out what a file was. But it means a truthiness check on the metadata endpoint is actively misleading for up to thirty days per file. The only reliable test is the date, which is why every state this script emits is a statement about expires_at and none of them is a statement about whether the object came back.

The ids you hold are the whole input, and the ids form has its own rules. Up to 100 per request after de-duplication, mutually exclusive with page and limit, always a single page. A script that sends 140 ids, or sends a limit alongside them, is not making a slightly worse request — it is making a request with different semantics. So the chunking is a pure function with a test on it, and the fetch sends nothing but the ids.

Silent omission is a finding, not an error. An id that has passed even the metadata window, or that was deleted, is simply absent from data with no error and no marker. That absence is the strongest signal in the whole run: the file is gone past recovery and your record of it is stale. Reading it requires the script to keep the request list and diff it against the response, which is a line of code and the difference between a report and a shrug.

There is nothing to extend, so the repair cannot be the obvious one. Expiry is fixed at upload and immutable, which rules out the fix everybody reaches for first. What is left is re-upload and swap the id, or upload without an expiry and accept that the file then stays on the storage total forever. The script prints both, and it never suggests changing a value that cannot be changed.

A missing field has to be its own outcome, because otherwise the run looks clean. If a beta header is in play the objects come back with no expires_at at all, and a naive script reads that as "nothing is expiring" and prints a clean bill of health on an account full of dying references. So a response with no such field produces expiry-not-reported, which is a finding, and the repair names the header. There is also a third shape to know about: with managed-agents-2026-04-01 and no files beta header, the old cursors and the new ones coexist.

This is one file's clock, and the note next door is a whole index's. The published vector store expiry note reads a store's expires_after policy and warns that the store will delete every file inside it. Same idea, different scale, different platform, and a different repair: there you clear a policy on an object that still exists, here there is no policy object and no clearing.

The fix, as a flow

An expiry that was set once at upload and cannot be changed afterwards is a deadline your code inherits without being told. The cruel part is what happens after it passes: the content stops being retrievable and the storage is released, but the metadata keeps answering for weeks, so the obvious existence check returns yes for a file that will fail every actual use. The script asks about the ids your application holds, in batches, and reads the date rather than the status code.

Your own ids, asked in batchesthe date, not the statusExpiry already pastevery use of this id will failMissing from the responsegone past metadata as wellExpiring inside the windowand it cannot be extendedNo expiry field returneda header hid the whole checkLive, with runway printedusable until the date shown
Every outcome here is a reading of a date, except the one that is about a date the endpoint declined to return.

How to fix it

Export the file ids your application still references

One per line, out of the table or the object store where you record them. This is the input the note is built on: the question is not what is in your workspace, it is which of the ids you are still handing to the Messages API are already dead. Blanks and # comments are ignored so an export from a query works unedited.

Use a key with access to that workspace, and send no beta header

The Files API is workspace-scoped: any key with access to the workspace can read its files. Send no anthropic-beta value. With files-api-2025-04-14 the response omits expires_at entirely and the whole check silently stops working.

Ask in batches of 100 ids and nothing else

GET /v1/files?ids[]=…, at most 100 per request after de-duplication. Do not add limit or page: they are mutually exclusive with ids, and the ids form always returns one page.

Diff what came back against what you asked for

Unresolvable ids are silently omitted rather than reported. The set difference between the batch you sent and the ids in data is the list of files that are gone past even the metadata window, or deleted.

Grade the date and take a repair that is not an extension

Past is dead, near is a deadline you cannot move, null is permanent. For anything expired, the printed repair removes the reference and offers DELETE /v1/files/{file_id}, which clears the metadata immediately instead of leaving it to answer for another month.

How to check it worked

Fix one expired reference by re-uploading the source and swapping the id, then re-run with the same file. That row should move from expired to live with a printed number of days on it, and the count of ids missing from the response should not change, because those were already unrecoverable before you started. Put it on a schedule at roughly a third of your shortest expiry window: with ninety-day uploads that is monthly, and with one-day uploads a scheduled audit is the wrong tool and a check at the call site is the right one.

python3 anthropic_expired_file_refs.py --ids referenced-files.txt --warn-days 7
# 214 id(s) asked in 3 batch(es) of at most 100, 209 returned
# expired              file_011a: expired 11.3 day(s) ago; the metadata still
#                      answers and every actual use of this id fails
#   repair: the content is gone and cannot be restored. Remove the reference,
#           re-upload the source if you still need it, and DELETE
#           /v1/files/{file_id} to clear the metadata immediately rather than
#           waiting out the 30 day window.
# expiring             file_02b7: expires in 4.1 day(s), and the expiry cannot
#                      be extended
#   repair: expires_in_seconds is set once at upload and cannot be changed, so
#           there is nothing to extend. Re-upload before the date and swap the
#           id, or upload with no expiry and accept that it stays on the quota.
# gone                 file_03c9: not returned by the ids lookup, so it is past
#                      even the metadata window or was deleted
# no-expiry            file_04d2: no expiry was set, so this one is permanent
# live                 file_05e4: live, expires in 61.8 day(s)
# 5 id(s) missing from the response, 7 finding(s)

The full code

One GET per batch of a hundred ids, and seven pure functions. parse_ids, which reads an export nobody tidied; chunks, which de-duplicates and caps at the documented hundred because the ids form has different semantics from the paged one; epoch, which turns the RFC 3339 expires_at into seconds and returns zero rather than a guess for anything it cannot parse; file_row, which records separately whether the field was absent as opposed to null, because those mean two completely different things here; missing_ids, the diff that reads the API's silent omission as a result; classify_id, which grades a date and treats a missing field as a broken check rather than as good news; and repair_lines, which never proposes extending an expiry that cannot be extended.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 97 LLM API fixes, free and open source.
anthropic_expired_file_refs.py
"""Check the file ids an application holds against the expiry on each one.

Read only. GET /v1/files with an ids[] filter and nothing else. No file content
is ever downloaded, nothing is uploaded and nothing is deleted.

expires_in_seconds is set once at upload, between 3600 and 7776000 seconds, and
cannot be changed afterwards. After expires_at the content stops being
retrievable and the bytes leave the storage quota, but the metadata remains
readable for up to 30 days and the file keeps appearing in list responses. So
an existence check answers yes for a file that fails every real use.

The ids form accepts at most 100 values after de-duplication, is mutually
exclusive with page and limit, and always returns a single page. Ids that do
not resolve are silently omitted from data, which is read here as a result
rather than as an error.

No anthropic-beta header is sent. With files-api-2025-04-14 the response omits
expires_at entirely, and a run that cannot see the field says so instead of
reporting that nothing is expiring.
"""
import argparse
import calendar
import logging
import os
import re
import sys
import time

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("anthropic_expired_file_refs")

BASE_URL = "https://api.anthropic.com/v1/files"

# Documented: at most 100 ids per request, after de-duplication.
ID_BATCH = 100
# Documented: metadata stays readable for up to 30 days past expires_at.
METADATA_WINDOW_DAYS = 30

FINDINGS = ("expired", "expiring", "gone", "expiry-not-reported")

_RFC3339 = re.compile(r"^(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2}):(\d{2})"
                      r"(?:\.\d+)?(Z|z|[+-]\d{2}:?\d{2})?$")


def parse_ids(text):
    """File ids from an export nobody tidied. Pure. Blanks and repeats dropped."""
    seen = []
    for line in str(text or "").splitlines():
        item = line.split("#", 1)[0].strip()
        if item and item not in seen:
            seen.append(item)
    return seen


def chunks(ids, size=ID_BATCH):
    """Batches of at most `size` unique ids. Pure.

    The cap is not a performance choice. The ids form is documented at 100
    values after de-duplication and is mutually exclusive with page and limit,
    so a longer list is a different request rather than a slower one.
    """
    try:
        size = max(1, min(int(size), ID_BATCH))
    except (TypeError, ValueError):
        size = ID_BATCH
    unique, out = [], []
    for item in ids or []:
        item = str(item or "").strip()
        if item and item not in unique:
            unique.append(item)
    for i in range(0, len(unique), size):
        out.append(unique[i:i + size])
    return out


def epoch(value):
    """RFC 3339 to seconds. Pure. Zero for anything unparseable, never a guess."""
    if value is None or value == "" or isinstance(value, bool):
        return 0
    if isinstance(value, (int, float)):
        try:
            return max(0, int(value))
        except (TypeError, ValueError, OverflowError):
            return 0
    m = _RFC3339.match(str(value).strip())
    if not m:
        return 0
    try:
        base = calendar.timegm(tuple(int(g) for g in m.groups()[:6]) + (0, 0, 0))
    except (TypeError, ValueError):
        return 0
    off = m.group(7)
    if off and off not in ("Z", "z"):
        digits = off[1:].replace(":", "")
        shift = int(digits[:2]) * 3600 + int(digits[2:4]) * 60
        base -= shift if off[0] == "+" else -shift
    return max(0, base)


def file_row(body):
    """One file object, reduced. Pure.

    `expiry_reported` records whether the key was present at all, which is a
    different fact from the key being null. Absent means the response shape
    does not carry expiry and this check cannot run; null means the file was
    uploaded without one and is permanent.
    """
    body = body if isinstance(body, dict) else {}
    try:
        size = int(body.get("size_bytes"))
    except (TypeError, ValueError):
        size = 0
    return {"id": str(body.get("id") or ""),
            "filename": str(body.get("filename") or ""),
            "size": max(0, size),
            "created_at": epoch(body.get("created_at")),
            "expires_at": epoch(body.get("expires_at")) or None,
            "expiry_reported": "expires_at" in body,
            "downloadable": bool(body.get("downloadable"))}


def missing_ids(requested, returned):
    """Ids asked for and not answered. Pure. Order preserved.

    Unresolvable ids are silently omitted from data with no error and no
    marker, so this diff is the only way the omission becomes a result.
    """
    have = {str(r or "") for r in returned or []}
    return [str(r) for r in requested or [] if str(r) not in have]


def human(size):
    """Binary units, one decimal. Pure."""
    try:
        n = float(size)
    except (TypeError, ValueError):
        return "0 B"
    for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
        if abs(n) < 1024 or unit == "TiB":
            return "%d B" % int(n) if unit == "B" else "%.1f %s" % (n, unit)
        n /= 1024.0
    return "%.1f TiB" % n


def classify_id(row, now, warn_days):
    """Grade one referenced id. Pure. Returns (state, detail).

    `row` is None for an id the API declined to return at all.
    """
    if row is None:
        return ("gone", "not returned by the ids lookup, so it is past even "
                        "the %d day metadata window or was deleted"
                        % METADATA_WINDOW_DAYS)
    if not row.get("expiry_reported"):
        return ("expiry-not-reported",
                "the object came back with no expires_at field, so this check "
                "could not run")
    expires = row.get("expires_at")
    if not expires:
        return ("no-expiry", "no expiry was set, so this one is permanent")
    left = (int(expires) - int(now)) / 86400.0
    if left <= 0:
        return ("expired",
                "expired %.1f day(s) ago; the metadata still answers and every "
                "actual use of this id fails" % abs(left))
    if left <= float(warn_days):
        return ("expiring",
                "expires in %.1f day(s), and the expiry cannot be extended" % left)
    return ("live", "live, expires in %.1f day(s)" % left)


def repair_lines(state):
    """The repair for one verdict. Pure. Printed, never performed."""
    if state == "expired":
        return ["the content is gone and cannot be restored. Remove the "
                "reference, re-upload the source if you still need it, and "
                "DELETE /v1/files/{file_id} to clear the metadata immediately "
                "rather than waiting out the %d day window."
                % METADATA_WINDOW_DAYS]
    if state == "expiring":
        return ["expires_in_seconds is set once at upload and cannot be "
                "changed, so there is nothing to extend. Re-upload before the "
                "date and swap the id, or upload with no expiry and accept "
                "that it stays on the storage quota."]
    if state == "gone":
        return ["this id resolves to nothing at all. Treat the record as stale "
                "and stop passing it, because no read will recover the file."]
    if state == "expiry-not-reported":
        return ["drop the anthropic-beta: files-api-2025-04-14 header. With it "
                "the response omits expires_at entirely and reverts to "
                "before_id and after_id paging, so this check cannot run."]
    if state == "no-expiry":
        return ["nothing to do here, but note that a file with no expiry never "
                "leaves the storage total either."]
    return []


def fetch_batch(batch, key, timeout=30):
    """One GET with an ids[] filter. Returns (rows, ok).

    No limit and no page: both are mutually exclusive with ids, and the ids
    form always returns a single page. No beta header either, because with one
    the response would not carry expires_at at all.
    """
    params = [("ids[]", fid) for fid in batch]
    headers = {"x-api-key": key, "anthropic-version": "2023-06-01"}
    try:
        r = requests.get(BASE_URL, params=params, headers=headers, timeout=timeout)
    except requests.RequestException as exc:
        log.error("ids lookup failed: %s", exc)
        return ([], False)
    if r.status_code != 200:
        log.error("ids lookup returned HTTP %s", r.status_code)
        return ([], False)
    try:
        body = r.json()
    except ValueError:
        return ([], False)
    return ([file_row(item) for item in (body.get("data") or [])], True)


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--ids", required=True,
                    help="file of file ids your application references")
    ap.add_argument("--warn-days", type=float, default=7.0,
                    help="days of remaining life that count as a finding")
    args = ap.parse_args()

    key = os.environ.get("ANTHROPIC_API_KEY")
    if not key:
        log.error("set ANTHROPIC_API_KEY to a key with access to the workspace "
                  "that owns these files. Every call is a GET of /v1/files")
        return 2
    try:
        with open(args.ids, "r", encoding="utf-8") as fh:
            wanted = parse_ids(fh.read())
    except OSError as exc:
        log.error("could not read %s: %s", args.ids, exc)
        return 2
    if not wanted:
        log.error("no file ids in %s. This note is about the ids your "
                  "application holds, not about the workspace listing", args.ids)
        return 2

    now = int(time.time())
    batches = chunks(wanted)
    rows, missing = [], []
    for batch in batches:
        got, ok = fetch_batch(batch, key)
        if not ok:
            log.error("a batch could not be read, so nothing is concluded")
            return 2
        rows.extend(got)
        missing.extend(missing_ids(batch, [r["id"] for r in got]))

    log.info("%d id(s) asked in %d batch(es) of at most %d, %d returned",
             len(wanted), len(batches), ID_BATCH, len(rows))

    findings = 0
    for row in rows:
        state, detail = classify_id(row, now, args.warn_days)
        emit = log.warning if state in FINDINGS else log.info
        emit("%-20s %s: %s", state, row["id"], detail)
        for line in repair_lines(state):
            emit("  repair: %s", line)
        if state in FINDINGS:
            findings += 1
    for fid in missing:
        state, detail = classify_id(None, now, args.warn_days)
        log.warning("%-20s %s: %s", state, fid, detail)
        for line in repair_lines(state):
            log.warning("  repair: %s", line)
        findings += 1

    log.info("%d id(s) missing from the response, %d finding(s)",
             len(missing), findings)
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())
anthropic-expired-file-refs.mjs
/**
 * Check the file ids an application holds against the expiry on each one.
 *
 * Read only. GET /v1/files with an ids[] filter and nothing else. No file
 * content is ever downloaded, nothing is uploaded and nothing is deleted.
 *
 * expires_in_seconds is set once at upload and cannot be changed. After
 * expires_at the content stops being retrievable and the bytes leave the
 * storage quota, while the metadata remains readable for up to 30 days.
 *
 * The ids form accepts at most 100 values after de-duplication, is mutually
 * exclusive with page and limit, and silently omits ids that do not resolve.
 *
 * No anthropic-beta header is sent: with files-api-2025-04-14 the response
 * omits expires_at entirely and this check cannot run.
 */
import { readFile } from 'node:fs/promises';

const BASE_URL = 'https://api.anthropic.com/v1/files';

export const ID_BATCH = 100;
export const METADATA_WINDOW_DAYS = 30;

const FINDINGS = new Set(['expired', 'expiring', 'gone', 'expiry-not-reported']);

/** File ids from an export nobody tidied. Pure. */
export function parseIds(text) {
  const seen = [];
  for (const line of String(text ?? '').split('\n')) {
    const item = line.split('#')[0].trim();
    if (item && !seen.includes(item)) seen.push(item);
  }
  return seen;
}

/** Batches of at most `size` unique ids, capped at the documented 100. Pure. */
export function chunks(ids, size = ID_BATCH) {
  let step = Number.parseInt(size, 10);
  if (!Number.isFinite(step)) step = ID_BATCH;
  step = Math.max(1, Math.min(step, ID_BATCH));
  const unique = [];
  for (const raw of ids ?? []) {
    const item = String(raw ?? '').trim();
    if (item && !unique.includes(item)) unique.push(item);
  }
  const out = [];
  for (let i = 0; i < unique.length; i += step) out.push(unique.slice(i, i + step));
  return out;
}

/** RFC 3339 to seconds. Pure. Zero for anything unparseable, never a guess. */
export function epoch(value) {
  if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
    return 0;
  }
  if (typeof value === 'number') {
    return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
  }
  const ms = Date.parse(String(value).trim());
  return Number.isFinite(ms) ? Math.max(0, Math.trunc(ms / 1000)) : 0;
}

/** One file object, reduced. Pure. Absent expires_at is not null expires_at. */
export function fileRow(body) {
  const row = (body && typeof body === 'object') ? body : {};
  const size = Number(row.size_bytes);
  const expires = epoch(row.expires_at);
  return {
    id: String(row.id ?? ''),
    filename: String(row.filename ?? ''),
    size: Number.isFinite(size) ? Math.max(0, Math.trunc(size)) : 0,
    created_at: epoch(row.created_at),
    expires_at: expires || null,
    expiry_reported: Object.prototype.hasOwnProperty.call(row, 'expires_at'),
    downloadable: Boolean(row.downloadable),
  };
}

/** Ids asked for and not answered. Pure. Order preserved. */
export function missingIds(requested, returned) {
  const have = new Set((returned ?? []).map((r) => String(r ?? '')));
  return (requested ?? []).map(String).filter((r) => !have.has(r));
}

/** Binary units, one decimal. Pure. */
export function human(size) {
  let n = Number(size);
  if (!Number.isFinite(n)) return '0 B';
  for (const unit of ['B', 'KiB', 'MiB', 'GiB', 'TiB']) {
    if (Math.abs(n) < 1024 || unit === 'TiB') {
      return unit === 'B' ? `${Math.trunc(n)} B` : `${n.toFixed(1)} ${unit}`;
    }
    n /= 1024;
  }
  return `${n.toFixed(1)} TiB`;
}

/** Grade one referenced id. Pure. `row` is null for an id never returned. */
export function classifyId(row, now, warnDays) {
  if (row === null || row === undefined) {
    return ['gone', `not returned by the ids lookup, so it is past even the `
      + `${METADATA_WINDOW_DAYS} day metadata window or was deleted`];
  }
  if (!row.expiry_reported) {
    return ['expiry-not-reported',
      'the object came back with no expires_at field, so this check could not run'];
  }
  if (!row.expires_at) return ['no-expiry', 'no expiry was set, so this one is permanent'];
  const left = (Number(row.expires_at) - Number(now)) / 86400;
  if (left <= 0) {
    return ['expired', `expired ${Math.abs(left).toFixed(1)} day(s) ago; the `
      + 'metadata still answers and every actual use of this id fails'];
  }
  if (left <= Number(warnDays)) {
    return ['expiring',
      `expires in ${left.toFixed(1)} day(s), and the expiry cannot be extended`];
  }
  return ['live', `live, expires in ${left.toFixed(1)} day(s)`];
}

/** The repair for one verdict. Pure. Printed, never performed. */
export function repairLines(state) {
  if (state === 'expired') {
    return ['the content is gone and cannot be restored. Remove the reference, '
      + 're-upload the source if you still need it, and DELETE /v1/files/{file_id} '
      + `to clear the metadata immediately rather than waiting out the `
      + `${METADATA_WINDOW_DAYS} day window.`];
  }
  if (state === 'expiring') {
    return ['expires_in_seconds is set once at upload and cannot be changed, so '
      + 'there is nothing to extend. Re-upload before the date and swap the id, '
      + 'or upload with no expiry and accept that it stays on the storage quota.'];
  }
  if (state === 'gone') {
    return ['this id resolves to nothing at all. Treat the record as stale and '
      + 'stop passing it, because no read will recover the file.'];
  }
  if (state === 'expiry-not-reported') {
    return ['drop the anthropic-beta: files-api-2025-04-14 header. With it the '
      + 'response omits expires_at entirely and reverts to before_id and after_id '
      + 'paging, so this check cannot run.'];
  }
  if (state === 'no-expiry') {
    return ['nothing to do here, but note that a file with no expiry never leaves '
      + 'the storage total either.'];
  }
  return [];
}

async function fetchBatch(batch, key) {
  const url = new URL(BASE_URL);
  for (const id of batch) url.searchParams.append('ids[]', id);
  const headers = { 'x-api-key': key, 'anthropic-version': '2023-06-01' };
  try {
    const res = await fetch(url, { headers });
    if (res.status !== 200) {
      console.error(`ids lookup returned HTTP ${res.status}`);
      return [[], false];
    }
    const body = await res.json().catch(() => null);
    if (!body) return [[], false];
    return [(body.data ?? []).map(fileRow), true];
  } catch (err) {
    console.error(`ids lookup failed: ${err.message}`);
    return [[], false];
  }
}

function args(argv) {
  const out = { warnDays: 7 };
  for (let i = 0; i < argv.length; i += 1) {
    if (argv[i] === '--ids') out.ids = argv[i += 1];
    else if (argv[i] === '--warn-days') out.warnDays = Number(argv[i += 1]);
  }
  return out;
}

async function main() {
  const opts = args(process.argv.slice(2));
  const key = process.env.ANTHROPIC_API_KEY;
  if (!key) {
    console.error('set ANTHROPIC_API_KEY to a key with access to the workspace '
      + 'that owns these files. Every call is a GET of /v1/files');
    process.exitCode = 2;
    return;
  }
  if (!opts.ids) {
    console.error('usage: --ids <file> [--warn-days 7]');
    process.exitCode = 2;
    return;
  }
  let wanted;
  try {
    wanted = parseIds(await readFile(opts.ids, 'utf8'));
  } catch (err) {
    console.error(`could not read ${opts.ids}: ${err.message}`);
    process.exitCode = 2;
    return;
  }
  if (!wanted.length) {
    console.error(`no file ids in ${opts.ids}. This note is about the ids your `
      + 'application holds, not about the workspace listing');
    process.exitCode = 2;
    return;
  }

  const now = Math.trunc(Date.now() / 1000);
  const batches = chunks(wanted);
  const rows = [];
  const missing = [];
  for (const batch of batches) {
    const [got, ok] = await fetchBatch(batch, key);
    if (!ok) {
      console.error('a batch could not be read, so nothing is concluded');
      process.exitCode = 2;
      return;
    }
    rows.push(...got);
    missing.push(...missingIds(batch, got.map((r) => r.id)));
  }

  console.log(`${wanted.length} id(s) asked in ${batches.length} batch(es) of at `
    + `most ${ID_BATCH}, ${rows.length} returned`);

  let findings = 0;
  for (const row of rows) {
    const [state, detail] = classifyId(row, now, opts.warnDays);
    console.log(`${state.padEnd(20)} ${row.id}: ${detail}`);
    for (const line of repairLines(state)) console.log(`  repair: ${line}`);
    if (FINDINGS.has(state)) findings += 1;
  }
  for (const id of missing) {
    const [state, detail] = classifyId(null, now, opts.warnDays);
    console.log(`${state.padEnd(20)} ${id}: ${detail}`);
    for (const line of repairLines(state)) console.log(`  repair: ${line}`);
    findings += 1;
  }

  console.log(`${missing.length} id(s) missing from the response, ${findings} finding(s)`);
  process.exitCode = findings ? 1 : 0;
}

if (import.meta.url === `file://${process.argv[1]}`) await main();

Add a test

The first test is the note's whole thesis in four assertions: a file whose date has passed is graded expired, the detail says every actual use fails rather than that a request failed, and the repair offers a deletion that clears the metadata rather than an extension. The second holds the script to the fact that there is nothing to extend, which is the fix everyone reaches for first. The third is the silent omission — an id the API declines to return is gone, and missing_ids has to produce it from the diff because no error is raised. The fourth is the one that stops a clean bill of health: an object with no expires_at key at all must not be graded permanent. Then the batching, which is a contract and not a performance tuning. And last the parsing, where a null expiry and an unparseable one must not collapse into the same answer.

test_anthropic_expired_file_refs.py
from anthropic_expired_file_refs import (ID_BATCH, chunks, classify_id, epoch,
                                        file_row, human, missing_ids, parse_ids,
                                        repair_lines)

NOW = 1_800_000_000
DAY = 86400


def row(fid, expires_in_days=None, has_field=True, size=2048):
    body = {"id": fid, "type": "file", "filename": fid + ".pdf",
            "size_bytes": size, "created_at": "2026-01-01T00:00:00Z",
            "downloadable": False}
    if has_field:
        body["expires_at"] = (None if expires_in_days is None else
                              _stamp(NOW + int(expires_in_days * DAY)))
    return file_row(body)


def _stamp(when):
    import time as _t
    return _t.strftime("%Y-%m-%dT%H:%M:%SZ", _t.gmtime(when))


def test_an_expired_file_still_answers_and_fails_every_use():
    state, detail = classify_id(row("file_011a", -11.3), NOW, 7.0)
    assert state == "expired"
    assert "expired 11.3 day(s) ago" in detail
    assert "the metadata still answers" in detail
    assert "every actual use of this id fails" in detail
    lines = repair_lines(state)
    assert any("cannot be restored" in line for line in lines)
    assert any("DELETE /v1/files/{file_id}" in line for line in lines)
    assert any("30 day window" in line for line in lines)


def test_an_expiry_cannot_be_extended_so_the_repair_never_suggests_it():
    state, detail = classify_id(row("file_02b7", 4.1), NOW, 7.0)
    assert state == "expiring"
    assert "expires in 4.1 day(s)" in detail
    assert "cannot be extended" in detail
    lines = repair_lines(state)
    assert any("set once at upload" in line for line in lines)
    assert any("Re-upload before the date" in line for line in lines)
    assert not any("extend the" in line for line in lines)
    # Outside the window it is simply live, with the runway printed.
    live, live_detail = classify_id(row("file_05e4", 61.8), NOW, 7.0)
    assert live == "live" and "61.8 day(s)" in live_detail
    assert repair_lines(live) == []


def test_an_id_the_api_declines_to_return_is_the_strongest_signal():
    state, detail = classify_id(None, NOW, 7.0)
    assert state == "gone"
    assert "not returned by the ids lookup" in detail
    assert "30 day metadata window" in detail
    assert any("no read will recover" in line for line in repair_lines(state))
    # Nothing is raised for an unresolvable id, so the diff is the only signal.
    asked = ["file_01", "file_02", "file_03"]
    assert missing_ids(asked, ["file_02"]) == ["file_01", "file_03"]
    assert missing_ids(asked, asked) == []
    assert missing_ids([], ["file_09"]) == []


def test_a_missing_expiry_field_disables_the_check_rather_than_passing_it():
    blind = row("file_06f1", has_field=False)
    assert blind["expiry_reported"] is False
    state, detail = classify_id(blind, NOW, 7.0)
    assert state == "expiry-not-reported"
    assert "could not run" in detail
    assert any("files-api-2025-04-14" in line for line in repair_lines(state))
    # A null expiry is a different fact and must not be confused with it.
    perm = row("file_04d2", None)
    assert perm["expiry_reported"] is True and perm["expires_at"] is None
    assert classify_id(perm, NOW, 7.0)[0] == "no-expiry"
    assert any("never leaves the storage total" in line
               for line in repair_lines("no-expiry"))


def test_batching_is_a_contract_and_not_a_performance_setting():
    ids = ["file_%03d" % n for n in range(250)]
    batched = chunks(ids)
    assert [len(b) for b in batched] == [100, 100, 50]
    assert all(len(b) <= ID_BATCH for b in batched)
    # Asking for a bigger batch does not get one: 100 is documented, not tuned.
    assert [len(b) for b in chunks(ids, 500)] == [100, 100, 50]
    # De-duplication happens before the split, as the documentation specifies.
    assert chunks(["a", "a", " a ", "b", ""]) == [["a", "b"]]
    assert chunks([]) == [] and chunks(None) == []


def test_the_dates_and_the_export_survive_what_is_really_in_them():
    ids = parse_ids("file_01\n\n# exported 2026-08-31\nfile_02  # oldest\n"
                    "file_01\n   \nfile_03\n")
    assert ids == ["file_01", "file_02", "file_03"]
    assert parse_ids("") == [] and parse_ids(None) == []
    assert epoch("2023-11-14T22:13:20Z") == 1_700_000_000
    assert epoch("2023-11-14T22:13:20.512Z") == 1_700_000_000
    assert epoch("2023-11-14T23:13:20+01:00") == 1_700_000_000
    assert epoch(None) == 0 and epoch("soon") == 0 and epoch(True) == 0
    junk = file_row({"id": "file_07", "size_bytes": "big", "expires_at": "soon"})
    assert junk["size"] == 0
    # Unparseable is not permanent, but it does read as no usable date, so the
    # verdict is no-expiry rather than a fabricated one.
    assert junk["expires_at"] is None and junk["expiry_reported"] is True
    assert file_row(None)["id"] == "" and human(2048) == "2.0 KiB"
anthropic-expired-file-refs.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ID_BATCH, chunks, classifyId, epoch, fileRow, human, missingIds,
         parseIds, repairLines } from './anthropic-expired-file-refs.mjs';

const NOW = 1_800_000_000;
const DAY = 86400;

const stamp = (when) => new Date(when * 1000).toISOString().replace(/\.\d+Z$/, 'Z');

const row = (id, expiresInDays = null, hasField = true, size = 2048) => {
  const body = { id, type: 'file', filename: `${id}.pdf`, size_bytes: size,
                 created_at: '2026-01-01T00:00:00Z', downloadable: false };
  if (hasField) {
    body.expires_at = expiresInDays === null ? null
      : stamp(NOW + Math.trunc(expiresInDays * DAY));
  }
  return fileRow(body);
};

test('an expired file still answers and fails every use', () => {
  const [state, detail] = classifyId(row('file_011a', -11.3), NOW, 7);
  assert.equal(state, 'expired');
  assert.ok(detail.includes('expired 11.3 day(s) ago'));
  assert.ok(detail.includes('the metadata still answers'));
  assert.ok(detail.includes('every actual use of this id fails'));
  const lines = repairLines(state);
  assert.ok(lines.some((l) => l.includes('cannot be restored')));
  assert.ok(lines.some((l) => l.includes('DELETE /v1/files/{file_id}')));
  assert.ok(lines.some((l) => l.includes('30 day window')));
});

test('an expiry cannot be extended so the repair never suggests it', () => {
  const [state, detail] = classifyId(row('file_02b7', 4.1), NOW, 7);
  assert.equal(state, 'expiring');
  assert.ok(detail.includes('expires in 4.1 day(s)'));
  assert.ok(detail.includes('cannot be extended'));
  const lines = repairLines(state);
  assert.ok(lines.some((l) => l.includes('set once at upload')));
  assert.ok(lines.some((l) => l.includes('Re-upload before the date')));
  assert.ok(!lines.some((l) => l.includes('extend the')));
  const [live, liveDetail] = classifyId(row('file_05e4', 61.8), NOW, 7);
  assert.equal(live, 'live');
  assert.ok(liveDetail.includes('61.8 day(s)'));
  assert.deepEqual(repairLines(live), []);
});

test('an id the api declines to return is the strongest signal', () => {
  const [state, detail] = classifyId(null, NOW, 7);
  assert.equal(state, 'gone');
  assert.ok(detail.includes('not returned by the ids lookup'));
  assert.ok(detail.includes('30 day metadata window'));
  assert.ok(repairLines(state).some((l) => l.includes('no read will recover')));
  const asked = ['file_01', 'file_02', 'file_03'];
  assert.deepEqual(missingIds(asked, ['file_02']), ['file_01', 'file_03']);
  assert.deepEqual(missingIds(asked, asked), []);
  assert.deepEqual(missingIds([], ['file_09']), []);
});

test('a missing expiry field disables the check rather than passing it', () => {
  const blind = row('file_06f1', null, false);
  assert.equal(blind.expiry_reported, false);
  const [state, detail] = classifyId(blind, NOW, 7);
  assert.equal(state, 'expiry-not-reported');
  assert.ok(detail.includes('could not run'));
  assert.ok(repairLines(state).some((l) => l.includes('files-api-2025-04-14')));
  const perm = row('file_04d2', null);
  assert.equal(perm.expiry_reported, true);
  assert.equal(perm.expires_at, null);
  assert.equal(classifyId(perm, NOW, 7)[0], 'no-expiry');
  assert.ok(repairLines('no-expiry')
    .some((l) => l.includes('never leaves the storage total')));
});

test('batching is a contract and not a performance setting', () => {
  const ids = Array.from({ length: 250 }, (_, n) => `file_${String(n).padStart(3, '0')}`);
  const batched = chunks(ids);
  assert.deepEqual(batched.map((b) => b.length), [100, 100, 50]);
  assert.ok(batched.every((b) => b.length <= ID_BATCH));
  assert.deepEqual(chunks(ids, 500).map((b) => b.length), [100, 100, 50]);
  assert.deepEqual(chunks(['a', 'a', ' a ', 'b', '']), [['a', 'b']]);
  assert.deepEqual(chunks([]), []);
  assert.deepEqual(chunks(null), []);
});

test('the dates and the export survive what is really in them', () => {
  const ids = parseIds('file_01\n\n# exported 2026-08-31\nfile_02  # oldest\n'
    + 'file_01\n   \nfile_03\n');
  assert.deepEqual(ids, ['file_01', 'file_02', 'file_03']);
  assert.deepEqual(parseIds(''), []);
  assert.deepEqual(parseIds(null), []);
  assert.equal(epoch('2023-11-14T22:13:20Z'), 1700000000);
  assert.equal(epoch('2023-11-14T22:13:20.512Z'), 1700000000);
  assert.equal(epoch('2023-11-14T23:13:20+01:00'), 1700000000);
  assert.equal(epoch(null), 0);
  assert.equal(epoch('soon'), 0);
  assert.equal(epoch(true), 0);
  const junk = fileRow({ id: 'file_07', size_bytes: 'big', expires_at: 'soon' });
  assert.equal(junk.size, 0);
  assert.equal(junk.expires_at, null);
  assert.equal(junk.expiry_reported, true);
  assert.equal(fileRow(null).id, '');
  assert.equal(human(2048), '2.0 KiB');
});

FAQ

The metadata call works. Why does the Messages request still fail?

Because they are two different lifetimes on the same object. At expires_at the content stops being retrievable and the bytes are released from your storage quota; the metadata remains readable for up to thirty days after that and the file keeps appearing in list responses. A metadata call proves the label exists, not the file. That is the entire failure this note is about, and it is why every state the script prints is a statement about the date rather than about whether the object came back.

Can we extend the expiry on a file we still need?

No. expires_in_seconds is set once, at upload, somewhere between 3,600 and 7,776,000 seconds, and it cannot be changed afterwards. There is no update call and no policy object. The options are to re-upload the source and swap the id in your records before the date, or to upload without an expiry at all and accept that the file then stays on the storage total until somebody deletes it. The script prints both and never suggests the third thing, because the third thing does not exist.

Some ids do not come back at all and there is no error. Is that a bug?

It is documented behaviour and it is the most useful signal in the run. Ids that do not resolve to a visible file, including deleted ones, are silently omitted from data. So an id you sent and did not get back is past even the metadata window, or was deleted outright, and either way no read will recover it. The script keeps the request list and diffs it against the response, which is the only way that omission becomes a result rather than a shrug.

Our run says the check could not run. What does that mean?

It means the objects came back with no expires_at key at all, which happens when the request carries anthropic-beta: files-api-2025-04-14. That header keeps the old response shape, in which the field is not returned and pagination uses before_id and after_id. Drop it. It matters more than it sounds: without that state a script would read the missing field as no expiry and print a clean bill of health across an account of dying references. There is also a hybrid case worth knowing about, where managed-agents-2026-04-01 without the files header returns both the old cursors and the new one.

How often should this run?

About a third of your shortest expiry window. Ninety-day uploads make it a monthly job. One-hour uploads make it the wrong tool entirely: at that cadence the right check is at the call site, verifying the date before you pass the id rather than discovering it in a scheduled report. What a schedule is genuinely good for is the slow half of the population, the files uploaded once with a long expiry and referenced from a record nobody revisits.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.