Diagnostic Slack
The 1 GB file ceiling, and how much headroom is left
The nightly export used to be four megabytes. It is sixty now, because the company grew, and three mornings a week it does not arrive. The log is no help: sometimes file_too_large, sometimes internal_error, most often a read timeout from a host that is not slack.com at all and that nobody on the team has ever heard of.
Nothing changed in the code. The file crossed a line that is not in the code, and the line it crossed is not the 1 GB one in the documentation.
Slack caps a single file at 1 GB. That is the documented ceiling and it is almost never the one you hit. The modern upload sends your bytes to an upload_url on a storage host that is not a Slack API host, with a plain HTTP request that inherits none of your Slack client's timeout, retry or proxy settings, and that request is what fails first. Reports of the SDK helpers failing start well under a hundred megabytes on ordinary connections, which is thirty times below the cap.
So read the distribution rather than the last failure. files.list?count=200&types=all&user=<your bot's user id> returns size in bytes for every file, and the two numbers worth reporting are the biggest file as a percentage of the ceiling and how many files sit in the band where the transfer times out. A size finding is predictive: the upload that breaks next quarter is already in the list today, one growth curve away from the edge.
There is a second ceiling wearing the same error string. Workspaces have an aggregate storage quota, and when it is full, uploads fail with file_too_large regardless of the file. file_too_large on a four-megabyte CSV is not about that CSV, and no amount of compressing it will help.
The problem in plain words
What makes this one drag on is that it is intermittent by construction. The code is the same code, the token is the same token, the channel is the same channel, and the only thing moving is the data. So the failure arrives as sometimes, which is the word that sends a team looking at the network, at Slack's status page, at the scheduler, at everything except the one variable that is quietly increasing every week.
The error strings actively mislead. A file over the per-file cap comes back file_too_large, which is honest. A workspace that has filled its storage quota comes back file_too_large on a small file, which is not. A transfer that ran long comes back as a socket timeout from a storage hostname, with no Slack error at all, because that step is not a Slack API call and there is no ok: false anywhere in it. And a genuinely large upload sometimes comes back internal_error, which reads like Slack having a bad day and is usually the size in a different costume. Four symptoms, three subsystems, one growth curve.
Then the retry makes it worse in a way that is specific to size. Retrying a failed chat.postMessage costs a few hundred bytes. Retrying a failed 600 MB upload sends 600 MB again, three times, and the job that was late is now saturating the link it was already timing out on. An exponential backoff written for API calls is the wrong policy for a byte transfer, and applying it to one turns a slow morning into an outage.
The honest conclusion is usually that the file does not belong in Slack. Slack is a chat product with a file store attached to it, not an object store with a chat interface, and a nightly multi-hundred-megabyte artifact wants S3 or GCS and a message containing a link. That is not a workaround, it is the shape the system wanted from the beginning. One thing this note is deliberately not about: a file that arrives with size: 0 is not a size problem at all, it is the three-step upload sequence stopping in the middle, and that has its own note.
Why it happens
The documented ceiling is not the operative one. 1 GB is where Slack refuses. The band where the plain HTTP transfer starts to lose is far below it and is a property of your link, your client's default timeout and the storage host's patience, not of Slack. The script therefore carries two edges and both of them are arguments, because the useful one is the one you measured on your own network.
A size finding is a distribution, not an event. One failed upload tells you almost nothing. The list of every file the app owns, sorted into bands, tells you whether you are near an edge, how near, and how fast you are approaching it. That is why the report leads with the biggest file as a fraction of the cap rather than with a list of failures.
file_too_large has two meanings and only one of them is about the file. Over the per-file cap it is literal. Well under it, the workspace's aggregate storage quota is full and the error is about the workspace. The repairs have nothing in common: split the file, or go and delete years of old files and ask about the plan.
The byte step is not a Slack API call and does not behave like one. No ok: false, no Retry-After, no rate-limit headers, no Slack SDK retry policy, no Slack SDK timeout. It is a request to a storage URL with whatever defaults your HTTP library ships with, which for several popular ones is no timeout at all in one direction and thirty seconds in the other.
A transfer timeout is arithmetic before it is a mystery. Bytes times eight, divided by the link speed, is the number of seconds the upload needs. If that is larger than the timeout you configured, the upload cannot succeed on a perfectly healthy network, and no retry policy fixes a budget that never balanced.
Zero bytes is a different failure and gets handed off rather than counted. A registered file of length zero means the completion call worked and the transfer did not. It is a broken sequence, not a large file, and folding it into a size report would put a stalled upload and a growing export in the same bucket when they want opposite repairs.
The fix, as a flow
The red box is in the middle of this chain rather than at either end, and that placement is the note. The first step is a Slack API call and the last step is a Slack API call, and the one between them is a plain HTTP request to a storage host that has never heard of your Slack client, your timeouts or your retry policy. The fix branch sorts bytes into bands rather than errors into causes, because nothing here has failed yet: the top rows are a date in the future, and the fourth row is a stalled sequence that belongs to another note and is deliberately kept out of the count.
How to fix it
Page files.list, scoped to the identity that uploads
files.list?count=200&types=all&user=<U...>, following paging.pages. The user filter turns a workspace inventory into an audit of your own output, and the id is the user_id that auth.test returns. Everything after this is arithmetic on the size field.
Put the band edges where your evidence is, not where the docs are
band takes the ceiling, the transfer band and the headroom fraction as arguments, defaulting to 1 GB, 25 MB and three quarters. The default transfer band is a starting point drawn from what people report; the number that belongs there is the size at which your uploads started getting slow, and you already have that number.
Read the biggest file as a fraction of the cap
size_profile returns the counts per band, the biggest file, and what percentage of the ceiling that biggest file is. A workspace at 3% has no size problem and should stop reading. A workspace at 78% has one scheduled for whenever the data grows by a third.
Do the transfer arithmetic before blaming the network
timeout_budget takes a size, a link speed in megabits and the timeout your client is configured with, and returns the seconds the transfer needs. impossible means the upload cannot finish on a healthy link, which is a configuration bug rather than an infrastructure one and is fixed by a larger number in your own code.
Sort a recorded failure into which ceiling it actually hit
refusal_cause takes the error string and the size from your own logs and names the limit: the per-file cap, the workspace storage quota, the transfer, or something that is not about size at all. Four causes, four repairs, and only one of them involves making the file smaller.
Move the big ones out of Slack and post a link instead
The repair the script prints for the top band is an architectural one: upload to object storage, post a message with the link, and let Slack carry the notification rather than the payload. Every repair here is printed rather than performed, because uploading, deleting and revoking are all writes.
How to check it worked
Re-run after the export moves. The number to watch is the percentage of the ceiling, and it should stop climbing.
python3 slack_file_sizes.py --link-mbps 40 --client-timeout 30
# identity U07BOT9QD (reports-bot) in Northwind
# scope files:read granted
# files 312 file(s) owned by U07BOT9QD, over 2 page(s)
# band transfer-band F08K2M4QX warehouse-dump.csv 61.4 MB, above the band where
# the plain byte transfer starts to time out
# band no-headroom F08K5N1QT archive-2026.zip 812.0 MB, 79.3% of the 1 GB
# per-file ceiling. The next growth crosses it
# profile biggest 812.0 MB (79.3% of the ceiling); 14 file(s) in the transfer band
# budget impossible 812.0 MB needs 170.2s at 40 Mbps and the client gives up
# after 30s, so this upload cannot finish on a healthy link
# cause workspace-quota the recorded file_too_large was on a 4.0 MB file, which is
# far under the cap: the workspace storage quota is the limit
# verdict 15 of 312 file(s) are at or near a limit
# repair: raise the byte-step timeout; it is a plain HTTP request, not a Slack call
# repair: put the big artifacts in object storage and post a link into the channel
The full code
One paginated GET and four pure functions, none of which needs a network. band sorts a file by size against edges you supply rather than against the documented cap, because the documented cap is not the one that breaks you. size_profile turns the list into the two numbers that are the actual finding. timeout_budget is the arithmetic that decides whether a timeout was ever survivable. refusal_cause exists because file_too_large means two different things and the repairs have nothing in common.
"""Measure how close your uploads are to the ceiling that will stop them.
Read only. files.list, auth.test and team.info are reads. Nothing here mints an
upload URL, sends a byte anywhere, deletes a file or fetches a url_private: a
bot token with files:read is enough, and every repair is printed.
Slack caps one file at 1 GB. That number is almost never the one you meet. The
byte transfer in the middle of the modern upload goes to a storage host that is
not a Slack API host, over a plain HTTP request that inherits none of your
Slack client's timeout or retry configuration, and it is the step that gives up
first. So the question is not "which upload failed" but "how much room is
left", and that is a distribution rather than an error.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_file_sizes")
API = "https://slack.com/api/"
# The documented per-file ceiling: one gibibyte. Everything below it is a
# property of your link and your HTTP client rather than of Slack, which is why
# the other two edges are arguments with soft defaults.
CEILING = 1024 ** 3
# Where the plain byte transfer starts being the problem instead of the size.
# Drawn from what people report rather than from any published number; replace
# it with the size at which your own uploads got slow.
TRANSFER_BAND = 25 * 1024 * 1024
# What counts as no room left. A file this close to the cap crosses it on the
# day the data grows, which is a date rather than a possibility.
NEAR = 0.75
# The bands that are worth waking somebody for. Kept as a constant so the rows
# printed and the number in the verdict cannot drift apart.
FINDINGS = ("over-ceiling", "no-headroom", "transfer-band")
def human(size):
"""Bytes as something a person can read. Pure."""
try:
n = float(size)
except (TypeError, ValueError):
return "?"
for unit in ("B", "KB", "MB", "GB"):
if abs(n) < 1024 or unit == "GB":
return "%.1f %s" % (n, unit)
n /= 1024.0
return "%.1f GB" % n
def band(f, ceiling=CEILING, transfer=TRANSFER_BAND, near=NEAR):
"""Which size band is one file in? Pure. Returns (band, size, detail).
The bands are deliberately not "ok" and "too big". A file at 79% of the
ceiling is not a failure today and is a scheduled one, and a file in the
transfer band fails on a slow morning and succeeds on a fast one. Those are
the two states this note exists to name.
empty is classified and then handed off: a registered file of length zero
is the upload sequence stopping in the middle, not a size, and counting it
here would put a stalled transfer and a growing export in one bucket.
"""
row = f or {}
if row.get("is_external"):
return ("external", 0, "hosted outside Slack, so the bytes and their limits "
"belong to another system")
raw = row.get("size")
if raw is None:
return ("unknown", 0, "no size field, so this file cannot be measured")
try:
size = int(raw)
except (TypeError, ValueError):
return ("unknown", 0, "size is not a number, so this file cannot be measured")
if size <= 0:
return ("empty", size, "registered with zero bytes. That is the transfer step "
"failing rather than a size limit, and it is a "
"different note")
if size > ceiling:
return ("over-ceiling", size, "%s, past the %s per-file ceiling. Slack refuses "
"this outright" % (human(size), human(ceiling)))
if size >= ceiling * near:
return ("no-headroom", size, "%s, %.1f%% of the %s per-file ceiling. The next "
"growth crosses it"
% (human(size), 100.0 * size / ceiling,
human(ceiling)))
if size >= transfer:
return ("transfer-band", size, "%s, above the band where the plain byte "
"transfer starts to time out" % human(size))
return ("fine", size, "%s, comfortably inside every limit" % human(size))
def size_profile(files, ceiling=CEILING, transfer=TRANSFER_BAND, near=NEAR):
"""The distribution, because one file's size is not a finding. Pure.
Returns (counts, biggest, percent) where counts maps band name to a count,
biggest is (file_id, name, size) or None, and percent is the biggest file
as a percentage of the ceiling. That last number is the whole report: it
says how much of the runway is left, which a list of failures does not.
"""
counts, biggest = {}, None
for f in files or []:
name, size, _detail = band(f, ceiling, transfer, near)
counts[name] = counts.get(name, 0) + 1
if name in ("external", "unknown"):
continue
if biggest is None or size > biggest[2]:
biggest = ((f or {}).get("id") or "?",
(f or {}).get("name") or (f or {}).get("title") or "?", size)
percent = 0.0 if not biggest or not ceiling else round(
100.0 * biggest[2] / ceiling, 1)
return (counts, biggest, percent)
def timeout_budget(size, mbps, timeout_s):
"""How long does the byte step need, against the timeout you gave it? Pure.
Returns (seconds, verdict, detail). impossible is the interesting verdict:
it means the transfer could not have finished on a completely healthy link,
so the failure is a number in your own configuration rather than anything
about Slack, the network or the file.
"""
try:
n = float(size)
rate = float(mbps)
budget = float(timeout_s)
except (TypeError, ValueError):
return (0.0, "unknown", "a size, a link speed and a timeout are all needed")
if n <= 0 or rate <= 0 or budget <= 0:
return (0.0, "unknown", "a size, a link speed and a timeout are all needed")
seconds = round(n * 8.0 / (rate * 1000000.0), 1)
if seconds > budget:
return (seconds, "impossible", "%s needs %.1fs at %g Mbps and the client gives "
"up after %gs, so this upload cannot finish on "
"a healthy link" % (human(n), seconds, rate,
budget))
if seconds > budget / 2.0:
return (seconds, "tight", "%s needs %.1fs at %g Mbps against a %gs timeout. A "
"slow morning is enough to lose it"
% (human(n), seconds, rate, budget))
return (seconds, "comfortable", "%s needs %.1fs at %g Mbps against a %gs timeout"
% (human(n), seconds, rate, budget))
def refusal_cause(error, size, ceiling=CEILING, transfer=TRANSFER_BAND):
"""Which limit did a failure you already recorded actually hit? Pure.
Returns (cause, detail). file_too_large is the reason this function exists:
over the cap it is about the file, and well under it, it is about the
workspace's aggregate storage quota. Same string, unrelated repairs.
"""
err = str(error or "").strip().lower()
try:
n = int(size)
except (TypeError, ValueError):
n = 0
if err in ("file_too_large", "too_large", "request_entity_too_large"):
if n and n > ceiling:
return ("ceiling", "%s is past the %s per-file cap. Split it, compress it, "
"or put it somewhere that is not Slack"
% (human(n), human(ceiling)))
if n:
return ("workspace-quota", "%s is far under the %s cap, so the refusal is "
"about the workspace's aggregate storage rather "
"than this file" % (human(n), human(ceiling)))
return ("ceiling-or-quota", "file_too_large with no size recorded. Record the "
"byte count and this becomes decidable")
if err in ("", "none", "timeout", "timed_out", "read_timeout", "connection_error"):
if n >= transfer:
return ("transfer", "no Slack error at all: the byte step is a plain HTTP "
"request to a storage host and it ran out of time. "
"Raise that client's timeout, not the retry count")
return ("not-a-size-problem", "a transport failure on a small file, which is "
"the network rather than any Slack limit")
if err == "internal_error" and n >= transfer:
return ("large-internal-error", "internal_error on %s. On a large upload this "
"is usually the size wearing a different "
"costume; treat it as the transfer" % human(n))
if err in ("invalid_arguments", "invalid_length"):
return ("declared-length", "the declared length and the bytes disagreed. That "
"is the upload sequence rather than a size limit")
return ("not-a-size-problem", "%s is not one of the size errors; read it on its "
"own terms" % (err or "an empty error string"))
def page_files(session, user, page_size, max_pages):
"""Page files.list. A read, and the only paginated call here."""
out, page, pages = [], 1, 1
while page <= min(pages, max_pages):
params = {"count": str(page_size), "types": "all", "page": str(page)}
if user:
params["user"] = user
body = session.get(API + "files.list", params=params, timeout=30).json()
if body.get("ok") is not True:
log.error("files.list unavailable %s", body.get("error"))
return out, pages
out.extend(body.get("files") or [])
pages = int((body.get("paging") or {}).get("pages") or 1)
page += 1
return out, pages
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
ap.add_argument("--user", default="", help="owner to audit; defaults to this token")
ap.add_argument("--all-users", action="store_true",
help="measure every file in the workspace, not just the app's")
ap.add_argument("--transfer-mb", type=float, default=TRANSFER_BAND / 1048576.0,
help="size above which your own transfers get slow")
ap.add_argument("--near", type=float, default=NEAR,
help="fraction of the ceiling that counts as no headroom")
ap.add_argument("--link-mbps", type=float, default=0.0,
help="uplink speed, for the transfer arithmetic")
ap.add_argument("--client-timeout", type=float, default=0.0,
help="the timeout your HTTP client uses for the byte step")
ap.add_argument("--failed-error", default="",
help="an error string your logs recorded for a failed upload")
ap.add_argument("--failed-size", type=int, default=0,
help="the byte count of that failed upload")
ap.add_argument("--page-size", type=int, default=200)
ap.add_argument("--max-pages", type=int, default=25)
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("set %s to a bot token with files:read", args.token_env)
return 2
transfer = int(args.transfer_mb * 1048576)
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
who_resp = s.get(API + "auth.test", timeout=30)
who = who_resp.json()
if who.get("ok") is not True:
log.error("auth.test unavailable %s", who.get("error"))
return 2
log.info("identity %s (%s) in %s", who.get("user_id"), who.get("user"),
who.get("team"))
scopes = who_resp.headers.get("x-oauth-scopes") or ""
if scopes and "files:read" not in scopes.split(","):
log.warning("scope files:read is not granted; files.list will refuse")
owner = "" if args.all_users else (args.user or who.get("user_id") or "")
files, pages = page_files(s, owner, args.page_size, args.max_pages)
log.info("files %d file(s) owned by %s, over %d page(s)",
len(files), owner or "anyone", pages)
flagged = 0
for f in files:
name, _size, detail = band(f, CEILING, transfer, args.near)
if name not in FINDINGS:
continue
flagged += 1
log.warning("band %-14s %s %s %s", name, f.get("id") or "?",
f.get("name") or "?", detail)
counts, biggest, percent = size_profile(files, CEILING, transfer, args.near)
if biggest:
log.info("profile biggest %s (%.1f%% of the ceiling); %d file(s) in the "
"transfer band", human(biggest[2]), percent,
counts.get("transfer-band", 0))
if counts.get("empty"):
log.info("handoff %d file(s) registered with zero bytes: a stalled upload "
"sequence rather than a size limit", counts["empty"])
if biggest and args.link_mbps and args.client_timeout:
_sec, verdict, detail = timeout_budget(biggest[2], args.link_mbps,
args.client_timeout)
(log.warning if verdict == "impossible" else log.info)(
"budget %-14s %s", verdict, detail)
if args.failed_error or args.failed_size:
cause, detail = refusal_cause(args.failed_error, args.failed_size, CEILING,
transfer)
log.warning("cause %-14s %s", cause, detail)
if not flagged:
log.info("verdict clear nothing this app owns is near a limit")
return 0
log.warning("verdict %d of %d file(s) are at or near a limit", flagged,
len(files))
log.warning(" repair: raise the timeout on the byte step; it is a plain HTTP "
"request to a storage host and does not use your Slack client's")
log.warning(" repair: put the large artifacts in object storage and post a link "
"into the channel, so Slack carries the notification not the payload")
log.warning(" repair: if the error was file_too_large on a small file, the "
"workspace storage quota is full and no code change will help")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Measure how close your uploads are to the ceiling that will stop them.
*
* Read only. files.list, auth.test and team.info are reads. Nothing here mints
* an upload URL, sends a byte anywhere, deletes a file or fetches a
* url_private: a bot token with files:read is enough.
*
* Slack caps one file at 1 GB and that is almost never the number you meet.
* The byte transfer goes to a storage host that is not a Slack API host, over a
* plain HTTP request that inherits none of your Slack client's configuration,
* and it is the step that gives up first.
*/
const API = 'https://slack.com/api/';
/** The documented per-file ceiling: one gibibyte. */
export const CEILING = 1024 ** 3;
/** Where the plain byte transfer starts being the problem instead of the size. */
export const TRANSFER_BAND = 25 * 1024 * 1024;
/** What counts as no room left: a file this close crosses the cap on growth. */
export const NEAR = 0.75;
/** The bands worth waking somebody for. */
export const FINDINGS = ['over-ceiling', 'no-headroom', 'transfer-band'];
/** Bytes as something a person can read. Pure. */
export function human(size) {
let n = Number(size);
if (!Number.isFinite(n)) return '?';
for (const unit of ['B', 'KB', 'MB', 'GB']) {
if (Math.abs(n) < 1024 || unit === 'GB') return `${n.toFixed(1)} ${unit}`;
n /= 1024;
}
return `${n.toFixed(1)} GB`;
}
/**
* Which size band is one file in? Pure. Returns [band, size, detail].
*
* The bands are not "ok" and "too big". A file at 79% of the ceiling is a
* scheduled failure, and a file in the transfer band fails on a slow morning.
* A zero-byte file is classified and handed off: that is a stalled upload
* sequence rather than a size, and it wants the opposite repair.
*/
export function band(f, ceiling = CEILING, transfer = TRANSFER_BAND, near = NEAR) {
const row = f ?? {};
if (row.is_external) {
return ['external', 0, 'hosted outside Slack, so the bytes and their limits belong '
+ 'to another system'];
}
if (row.size === undefined || row.size === null) {
return ['unknown', 0, 'no size field, so this file cannot be measured'];
}
const size = Number.parseInt(row.size, 10);
if (!Number.isFinite(size)) {
return ['unknown', 0, 'size is not a number, so this file cannot be measured'];
}
if (size <= 0) {
return ['empty', size, 'registered with zero bytes. That is the transfer step '
+ 'failing rather than a size limit, and it is a different note'];
}
if (size > ceiling) {
return ['over-ceiling', size, `${human(size)}, past the ${human(ceiling)} per-file `
+ 'ceiling. Slack refuses this outright'];
}
if (size >= ceiling * near) {
const pct = ((100 * size) / ceiling).toFixed(1);
return ['no-headroom', size, `${human(size)}, ${pct}% of the ${human(ceiling)} `
+ 'per-file ceiling. The next growth crosses it'];
}
if (size >= transfer) {
return ['transfer-band', size, `${human(size)}, above the band where the plain byte `
+ 'transfer starts to time out'];
}
return ['fine', size, `${human(size)}, comfortably inside every limit`];
}
/**
* The distribution, because one file's size is not a finding. Pure.
* Returns [counts, biggest, percent]; percent is the runway that is left.
*/
export function sizeProfile(files, ceiling = CEILING, transfer = TRANSFER_BAND,
near = NEAR) {
const counts = {};
let biggest = null;
for (const f of files ?? []) {
const [name, size] = band(f, ceiling, transfer, near);
counts[name] = (counts[name] ?? 0) + 1;
if (name === 'external' || name === 'unknown') continue;
if (biggest === null || size > biggest[2]) {
biggest = [(f ?? {}).id ?? '?', (f ?? {}).name ?? (f ?? {}).title ?? '?', size];
}
}
const percent = !biggest || !ceiling
? 0 : Math.round((1000 * biggest[2]) / ceiling) / 10;
return [counts, biggest, percent];
}
/**
* How long does the byte step need, against the timeout you gave it? Pure.
* Returns [seconds, verdict, detail]; impossible means the budget never
* balanced, which is a number in your own configuration rather than a network.
*/
export function timeoutBudget(size, mbps, timeoutS) {
const n = Number(size);
const rate = Number(mbps);
const budget = Number(timeoutS);
if (![n, rate, budget].every(Number.isFinite) || n <= 0 || rate <= 0 || budget <= 0) {
return [0, 'unknown', 'a size, a link speed and a timeout are all needed'];
}
const seconds = Math.round((n * 8 * 10) / (rate * 1000000)) / 10;
if (seconds > budget) {
return [seconds, 'impossible', `${human(n)} needs ${seconds.toFixed(1)}s at `
+ `${rate} Mbps and the client gives up after ${budget}s, so this upload cannot `
+ 'finish on a healthy link'];
}
if (seconds > budget / 2) {
return [seconds, 'tight', `${human(n)} needs ${seconds.toFixed(1)}s at ${rate} Mbps `
+ `against a ${budget}s timeout. A slow morning is enough to lose it`];
}
return [seconds, 'comfortable', `${human(n)} needs ${seconds.toFixed(1)}s at `
+ `${rate} Mbps against a ${budget}s timeout`];
}
/**
* Which limit did a failure you already recorded actually hit? Pure.
* file_too_large is the reason this exists: over the cap it is about the file,
* well under it, it is about the workspace's aggregate storage quota.
*/
export function refusalCause(error, size, ceiling = CEILING,
transfer = TRANSFER_BAND) {
const err = String(error ?? '').trim().toLowerCase();
const n = Number.parseInt(size, 10) || 0;
if (['file_too_large', 'too_large', 'request_entity_too_large'].includes(err)) {
if (n && n > ceiling) {
return ['ceiling', `${human(n)} is past the ${human(ceiling)} per-file cap. Split `
+ 'it, compress it, or put it somewhere that is not Slack'];
}
if (n) {
return ['workspace-quota', `${human(n)} is far under the ${human(ceiling)} cap, `
+ 'so the refusal is about the workspace aggregate storage rather than this '
+ 'file'];
}
return ['ceiling-or-quota', 'file_too_large with no size recorded. Record the byte '
+ 'count and this becomes decidable'];
}
if (['', 'none', 'timeout', 'timed_out', 'read_timeout', 'connection_error']
.includes(err)) {
if (n >= transfer) {
return ['transfer', 'no Slack error at all: the byte step is a plain HTTP request '
+ 'to a storage host and it ran out of time. Raise that client timeout, not '
+ 'the retry count'];
}
return ['not-a-size-problem', 'a transport failure on a small file, which is the '
+ 'network rather than any Slack limit'];
}
if (err === 'internal_error' && n >= transfer) {
return ['large-internal-error', `internal_error on ${human(n)}. On a large upload `
+ 'this is usually the size wearing a different costume; treat it as the '
+ 'transfer'];
}
if (['invalid_arguments', 'invalid_length'].includes(err)) {
return ['declared-length', 'the declared length and the bytes disagreed. That is '
+ 'the upload sequence rather than a size limit'];
}
return ['not-a-size-problem', `${err || 'an empty error string'} is not one of the `
+ 'size errors; read it on its own terms'];
}
function arg(args, name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function pageFiles(headers, user, pageSize, maxPages) {
const files = [];
let page = 1;
let pages = 1;
while (page <= Math.min(pages, maxPages)) {
const params = new URLSearchParams({
count: String(pageSize), types: 'all', page: String(page),
});
if (user) params.set('user', user);
const body = await (await fetch(`${API}files.list?${params}`, { headers })).json();
if (body.ok !== true) {
console.error(`files.list unavailable ${body.error}`);
return [files, pages];
}
files.push(...(body.files ?? []));
pages = Number((body.paging ?? {}).pages ?? 1) || 1;
page += 1;
}
return [files, pages];
}
async function main() {
const args = process.argv.slice(2);
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv} to a bot token with files:read`);
process.exitCode = 2;
return;
}
const transfer = Math.round(Number(arg(args, '--transfer-mb',
TRANSFER_BAND / 1048576)) * 1048576);
const near = Number(arg(args, '--near', NEAR));
const mbps = Number(arg(args, '--link-mbps', 0));
const clientTimeout = Number(arg(args, '--client-timeout', 0));
const headers = { Authorization: `Bearer ${token}` };
const whoResp = await fetch(`${API}auth.test`, { headers });
const who = await whoResp.json();
if (who.ok !== true) {
console.error(`auth.test unavailable ${who.error}`);
process.exitCode = 2;
return;
}
console.log(`identity ${who.user_id} (${who.user}) in ${who.team}`);
const scopes = whoResp.headers.get('x-oauth-scopes') ?? '';
if (scopes && !scopes.split(',').includes('files:read')) {
console.warn('scope files:read is not granted; files.list will refuse');
}
const owner = args.includes('--all-users') ? '' : (arg(args, '--user', '')
|| who.user_id || '');
const [files, pages] = await pageFiles(headers, owner,
Number(arg(args, '--page-size', 200)), Number(arg(args, '--max-pages', 25)));
console.log(`files ${files.length} file(s) owned by ${owner || 'anyone'}, over `
+ `${pages} page(s)`);
let flagged = 0;
for (const f of files) {
const [name, , detail] = band(f, CEILING, transfer, near);
if (!FINDINGS.includes(name)) continue;
flagged += 1;
console.warn(`band ${name.padEnd(14)} ${f.id ?? '?'} ${f.name ?? '?'} `
+ `${detail}`);
}
const [counts, biggest, percent] = sizeProfile(files, CEILING, transfer, near);
if (biggest) {
console.log(`profile biggest ${human(biggest[2])} (${percent}% of the ceiling); `
+ `${counts['transfer-band'] ?? 0} file(s) in the transfer band`);
}
if (counts.empty) {
console.log(`handoff ${counts.empty} file(s) registered with zero bytes: a `
+ 'stalled upload sequence rather than a size limit');
}
if (biggest && mbps && clientTimeout) {
const [, verdict, detail] = timeoutBudget(biggest[2], mbps, clientTimeout);
const line = `budget ${verdict.padEnd(14)} ${detail}`;
if (verdict === 'impossible') console.warn(line); else console.log(line);
}
const failedError = arg(args, '--failed-error', '');
const failedSize = Number(arg(args, '--failed-size', 0));
if (failedError || failedSize) {
const [cause, detail] = refusalCause(failedError, failedSize, CEILING, transfer);
console.warn(`cause ${cause.padEnd(14)} ${detail}`);
}
if (!flagged) {
console.log('verdict clear nothing this app owns is near a limit');
return;
}
console.warn(`verdict ${flagged} of ${files.length} file(s) are at or near a limit`);
console.warn(' repair: raise the timeout on the byte step; it is a plain HTTP '
+ 'request to a storage host and does not use your Slack client configuration');
console.warn(' repair: put the large artifacts in object storage and post a link '
+ 'into the channel, so Slack carries the notification and not the payload');
process.exitCode = 1;
}
// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The assertions worth reading are the boundaries. A zero-byte file has to come back as empty rather than as the smallest file in the report, because it is a stalled sequence and belongs to another note. file_too_large is tested twice, once over the cap and once far under it, since the same string means the file and then the workspace. And timeout_budget is tested for the case where the arithmetic never balanced, which is the finding that saves the most time: no retry policy rescues a transfer that needs longer than the timeout allows.
from slack_file_sizes import band, refusal_cause, size_profile, timeout_budget
MB = 1024 * 1024
GB = 1024 * MB
def test_a_small_file_is_inside_every_limit():
name, size, _detail = band({"id": "F1", "size": 4 * MB})
assert name == "fine"
assert size == 4 * MB
def test_a_file_in_the_transfer_band_is_flagged_below_the_cap():
name, _size, detail = band({"id": "F1", "size": 61 * MB})
assert name == "transfer-band"
assert "time out" in detail
def test_the_band_edge_is_an_argument_not_a_constant():
assert band({"size": 61 * MB}, transfer=200 * MB)[0] == "fine"
def test_three_quarters_of_the_ceiling_is_a_scheduled_failure():
name, _size, detail = band({"id": "F1", "size": 812 * MB})
assert name == "no-headroom"
assert "79.3%" in detail
def test_past_the_ceiling_is_refused_outright():
assert band({"size": GB + 1})[0] == "over-ceiling"
def test_zero_bytes_is_handed_off_rather_than_called_small():
name, _size, detail = band({"id": "F1", "size": 0})
assert name == "empty"
assert "different note" in detail
def test_an_external_file_is_not_measured_against_slack_limits():
assert band({"is_external": True, "size": 9 * GB})[0] == "external"
def test_a_file_with_no_size_field_is_unknown_rather_than_zero():
assert band({"id": "F1"})[0] == "unknown"
assert band({"id": "F1", "size": "big"})[0] == "unknown"
def test_the_profile_reports_the_biggest_as_a_fraction_of_the_ceiling():
counts, biggest, percent = size_profile([
{"id": "F1", "name": "a.csv", "size": 4 * MB},
{"id": "F2", "name": "b.zip", "size": 812 * MB},
])
assert counts == {"fine": 1, "no-headroom": 1}
assert biggest == ("F2", "b.zip", 812 * MB)
assert percent == 79.3
def test_external_and_unmeasurable_files_cannot_become_the_biggest():
_counts, biggest, _percent = size_profile([
{"id": "F1", "size": 4 * MB},
{"id": "F2", "is_external": True, "size": 9 * GB},
])
assert biggest[0] == "F1"
def test_an_empty_run_reports_no_biggest_rather_than_dividing():
assert size_profile([]) == ({}, None, 0.0)
def test_a_transfer_that_needs_longer_than_the_timeout_is_impossible():
seconds, verdict, detail = timeout_budget(812 * MB, 40, 30)
assert verdict == "impossible"
assert seconds == 170.3
assert "healthy link" in detail
def test_a_transfer_using_most_of_the_budget_is_tight():
assert timeout_budget(60 * MB, 40, 20)[1] == "tight"
def test_a_small_transfer_is_comfortable():
assert timeout_budget(4 * MB, 40, 30)[1] == "comfortable"
def test_the_budget_needs_all_three_numbers():
assert timeout_budget(4 * MB, 0, 30)[1] == "unknown"
assert timeout_budget(None, 40, 30)[1] == "unknown"
def test_file_too_large_over_the_cap_is_about_the_file():
cause, _detail = refusal_cause("file_too_large", GB + 1)
assert cause == "ceiling"
def test_file_too_large_on_a_small_file_is_about_the_workspace():
cause, detail = refusal_cause("file_too_large", 4 * MB)
assert cause == "workspace-quota"
assert "aggregate storage" in detail
def test_no_error_at_all_on_a_large_file_is_the_byte_transfer():
cause, detail = refusal_cause("", 300 * MB)
assert cause == "transfer"
assert "timeout" in detail
def test_internal_error_on_a_large_upload_is_read_as_the_transfer():
assert refusal_cause("internal_error", 300 * MB)[0] == "large-internal-error"
def test_a_length_complaint_belongs_to_the_upload_sequence_note():
assert refusal_cause("invalid_arguments", 4 * MB)[0] == "declared-length"
def test_an_unrelated_error_is_not_dressed_up_as_a_size_problem():
assert refusal_cause("not_in_channel", 4 * MB)[0] == "not-a-size-problem"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
band, refusalCause, sizeProfile, timeoutBudget,
} from './slack-file-sizes.mjs';
const MB = 1024 * 1024;
const GB = 1024 * MB;
test('a small file is inside every limit', () => {
const [name, size] = band({ id: 'F1', size: 4 * MB });
assert.equal(name, 'fine');
assert.equal(size, 4 * MB);
});
test('a file in the transfer band is flagged below the cap', () => {
const [name, , detail] = band({ id: 'F1', size: 61 * MB });
assert.equal(name, 'transfer-band');
assert.match(detail, /time out/);
});
test('the band edge is an argument not a constant', () => {
assert.equal(band({ size: 61 * MB }, GB, 200 * MB)[0], 'fine');
});
test('three quarters of the ceiling is a scheduled failure', () => {
const [name, , detail] = band({ id: 'F1', size: 812 * MB });
assert.equal(name, 'no-headroom');
assert.match(detail, /79\.3%/);
});
test('past the ceiling is refused outright', () => {
assert.equal(band({ size: GB + 1 })[0], 'over-ceiling');
});
test('zero bytes is handed off rather than called small', () => {
const [name, , detail] = band({ id: 'F1', size: 0 });
assert.equal(name, 'empty');
assert.match(detail, /different note/);
});
test('an external file is not measured against Slack limits', () => {
assert.equal(band({ is_external: true, size: 9 * GB })[0], 'external');
});
test('a file with no size field is unknown rather than zero', () => {
assert.equal(band({ id: 'F1' })[0], 'unknown');
assert.equal(band({ id: 'F1', size: 'big' })[0], 'unknown');
});
test('the profile reports the biggest as a fraction of the ceiling', () => {
const [counts, biggest, percent] = sizeProfile([
{ id: 'F1', name: 'a.csv', size: 4 * MB },
{ id: 'F2', name: 'b.zip', size: 812 * MB },
]);
assert.deepEqual(counts, { fine: 1, 'no-headroom': 1 });
assert.deepEqual(biggest, ['F2', 'b.zip', 812 * MB]);
assert.equal(percent, 79.3);
});
test('external and unmeasurable files cannot become the biggest', () => {
const [, biggest] = sizeProfile([
{ id: 'F1', size: 4 * MB },
{ id: 'F2', is_external: true, size: 9 * GB },
]);
assert.equal(biggest[0], 'F1');
});
test('an empty run reports no biggest rather than dividing', () => {
assert.deepEqual(sizeProfile([]), [{}, null, 0]);
});
test('a transfer that needs longer than the timeout is impossible', () => {
const [seconds, verdict, detail] = timeoutBudget(812 * MB, 40, 30);
assert.equal(verdict, 'impossible');
assert.equal(seconds, 170.3);
assert.match(detail, /healthy link/);
});
test('a transfer using most of the budget is tight', () => {
assert.equal(timeoutBudget(60 * MB, 40, 20)[1], 'tight');
});
test('a small transfer is comfortable', () => {
assert.equal(timeoutBudget(4 * MB, 40, 30)[1], 'comfortable');
});
test('the budget needs all three numbers', () => {
assert.equal(timeoutBudget(4 * MB, 0, 30)[1], 'unknown');
assert.equal(timeoutBudget(null, 40, 30)[1], 'unknown');
});
test('file_too_large over the cap is about the file', () => {
assert.equal(refusalCause('file_too_large', GB + 1)[0], 'ceiling');
});
test('file_too_large on a small file is about the workspace', () => {
const [cause, detail] = refusalCause('file_too_large', 4 * MB);
assert.equal(cause, 'workspace-quota');
assert.match(detail, /aggregate storage/);
});
test('no error at all on a large file is the byte transfer', () => {
const [cause, detail] = refusalCause('', 300 * MB);
assert.equal(cause, 'transfer');
assert.match(detail, /timeout/);
});
test('internal_error on a large upload is read as the transfer', () => {
assert.equal(refusalCause('internal_error', 300 * MB)[0], 'large-internal-error');
});
test('a length complaint belongs to the upload sequence note', () => {
assert.equal(refusalCause('invalid_arguments', 4 * MB)[0], 'declared-length');
});
test('an unrelated error is not dressed up as a size problem', () => {
assert.equal(refusalCause('not_in_channel', 4 * MB)[0], 'not-a-size-problem');
});
FAQ
The documentation says 1 GB. Why does a 60 MB file fail?
Because the cap and the failure are in different places. 1 GB is where Slack refuses the file. The byte transfer in the middle of the upload is a plain HTTP request to a storage host that is not a Slack API host, and it inherits none of your Slack client's timeout, retry or proxy configuration, so it times out at whatever your HTTP library's default happens to be. That number is usually thirty seconds, which at a typical office uplink is somewhere in the tens of megabytes.
We get file_too_large on a four megabyte CSV. How is that possible?
The workspace has run out of aggregate file storage. The per-file cap and the workspace quota share an error string, and when the quota is the one you hit, the size of the file you happened to be uploading is irrelevant. Nothing you do to that CSV will change the answer: somebody has to delete old files or the workspace needs more storage. The script tells the two apart by comparing the recorded size against the cap.
Should we just retry the upload with backoff?
Not with the policy you use for API calls. Retrying a failed chat.postMessage costs a few hundred bytes; retrying a failed 600 MB upload sends 600 MB again, and three attempts turn a slow morning into a saturated link. If the transfer arithmetic says the file needs longer than your timeout allows, every retry is guaranteed to fail in exactly the same way. Fix the timeout first, then retry once.
What size is actually safe to send to Slack?
There is no published safe number, which is why the script takes the band edge as an argument rather than asserting one. The number that belongs there is the size at which your own uploads started getting slow, and you can read that off your own logs. As a starting point, anything in the tens of megabytes deserves a deliberate decision, and anything in the hundreds belongs in object storage with a link posted into the channel.
The file appears in Slack but it is empty. Is that this problem?
No, and the script separates it out deliberately. A file registered with size zero means the completion call succeeded and the byte transfer did not, which is the upload sequence breaking in the middle rather than any size limit. It is counted and handed off rather than folded into the size report, because the two want opposite repairs: one wants a smaller file and the other wants the transfer to actually happen.
Related field notes
- the same sequence stopping between its three calls
- the method these limits used to apply to
- the other reason a file is not where you left it
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.
- files.getUploadURLExternal method reference - Slack Docs
- files.list method reference - Slack Docs
- python-slack-sdk #1681: large file uploads fail
- Working with files - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.