September 2026 Updates
September brought a redesigned pull list built around upcoming releases, new fields and a cover_date_range filter in the API, and fixes for stale API cache entries, Redis stalls that caused API 500s, and missing Retry-After headers on some 429 responses. Mokkari 4.8.0 adds optional rate-limit pacing and connection pooling. If you donate on Open Collective, please don't contribute as Incognito, because Metron can't link an Incognito contribution to your account. Here's the full rundown.
Monthly Statistics
During September the Metron Project added the following to its database:
- Users: 521
- Issues: 3,386
- Creators: 463
- Characters: 987
- Reading Lists: 20
Pull List Redesign

The pull list page has been reworked into an upcoming-releases view. The header now summarizes how many series you follow, how many issues are coming up, and the date of the next release, with the Add a series search moved up alongside it.
Upcoming issues grouped by release day. Issues with a store date on or after today are grouped under their release day, each group showing a countdown ("Today", "In 5 days", "In 1 week, 5 days") and its issue count. A toggle at the top of the panel switches between a compact list and a grid of covers. Up to 50 upcoming issues are shown at a time.
Final order cutoff warnings. Comic shops need to place their orders before a publisher's final order cutoff (FOC). Any issue whose FOC falls within the next 7 days gets a yellow tag and is listed in a warning banner at the top of the page, grouped by cutoff date, so you can let your shop know in time. Issues with a later FOC show it as a blue tag instead. The banner, like the header counts, always covers your whole pull list, even while filtering.
Series sidebar with filtering. The series panel lists every series you follow along with the date of its next issue (or "No upcoming issues"). Selecting a series narrows the upcoming list to just that series; selecting it again, or clicking Clear filter, shows everything again. Filtering and switching views are handled with HTMX, so the page doesn't reload, and the URL updates so you can bookmark a filtered view.
Remove with Undo. Series are now removed inline with the × button — no more separate confirmation page — and a notification names the removed series with an Undo button in case you clicked the wrong one. Your current filter and view are kept across both removing and undoing.
Dark mode fixes. Several fixed-colour styles on the page were replaced with theme-aware equivalents, so the pull list now renders properly in dark mode. Italian translations were also updated for all the new strings.
API Improvements
Series list endpoint gains publisher, series_type, cv_id, and gcd_id. /api/series/ now returns all four fields. The publisher's series_list action briefly split off its own PublisherSeriesListSerializer to keep that response unchanged while cv_id/gcd_id were scoped to the top-level endpoint, then was consolidated back onto SeriesListSerializer once publisher made the two payloads equivalent.
Pull list series endpoint kept unchanged. The pull list series endpoint nests SeriesListSerializer directly, so adding publisher/series_type there leaked into its response too. A new PullListSeriesInfoSerializer pins that endpoint's fields back to the pre-existing set — also dropping cv_id/gcd_id, which were never exposed there either. That serializer also carried an issue_count field that was never actually populated for this endpoint (a read-only field DRF silently omits rather than erroring on), so it was removed rather than wired up.
cover_date_range filter added to the issue API. /api/issue/ now accepts cover_date_range_after/cover_date_range_before, matching the existing store_date_range/foc_date_range filters.
API Response Caching
Closing remaining staleness gaps. Character.creators/teams/universes, Team.creators/universes, and Series.genres/associated had no m2m_changed wiring at all, so adding or removing a relation didn't bump the owning object's own modified timestamp — not just cosmetic staleness, the object's own listed relations could be wrong until the cache TTL lapsed. The variant/universe/reprint Issue cascades now also bump the Issue list-cache version, for consistency with the credits/credit-role cascades already covering it.
Universe and Team rename staleness. IssueViewSet now tracks Universe renames (measured at a low ~180 historical saves site-wide — lower write volume than Publisher, which was already tracked), and CharacterViewSet/TeamViewSet now track Universe and Team renames too (~3.1k saves for Team). Creator-driven staleness on Character/Team/Issue, and Genre-driven staleness on Series, are accepted as-is — Creator's write volume (~17.8k-18.6k saves) is roughly 100x Universe's and risks reproducing the cache-thrashing regression already fixed for Series, while Genre names are effectively static. Issue rating changes also intentionally continue to skip Issue cache invalidation, since ratings churn far more than any other field on popular issues.
Detail cache TTL adjustments. The safety-net TTL for cached detail responses — self-invalidating on write regardless of TTL — went from 48h to 72h, 96h, and briefly 5 days this month, before being brought back down to 3 days while the Redis configuration is tuned (see below). Cache effectiveness can now also be read straight from production logs: gunicorn's access log gained a cache= field carrying the same X-Cache HIT/MISS status the API response already sets, and DEPLOYMENT.md documents computing a day's hit rate from it, with a variant that excludes list endpoints since their short TTL skews the raw rate toward MISS.
Redis stalls and API 500s. Late in the month some /api/issue/ requests started failing with 500 errors. The cause was Redis itself: it ran with AOF persistence enabled on top of the default RDB snapshot rules, and with the constant stream of throttle writes, a ~700MB snapshot kicked off roughly every 5 minutes and took ~35s each time. That disk contention blocked AOF fsyncs, causing 400-700ms latency spikes and occasional 5s+ pauses, long enough for the API throttle's cache read to hit redis-py's 5s socket timeout and raise an uncaught TimeoutError. Since everything Metron keeps in Redis is disposable, AOF is now disabled, snapshots happen at most hourly (still enough to keep the sorl-thumbnail index across restarts), and memory is capped at 1GB with volatile-lru, so only cache entries with a TTL are ever evicted. Separately, Metron's cache backend is now a fail-open subclass of Django's RedisCache: any Redis error is logged and treated as a cache miss (reads return the default, writes are skipped) instead of turning into a 500. While Redis is failing, rate limits aren't enforced and list-cache version bumps are dropped, the latter bounded by the 2-minute list TTL.
Site Improvements
Calendar picker for issue filter dates. The Store Date and FOC Date From/To filters on the issue list and weekly release pages now use the same bulma-calendar widget as the issue form instead of the browser's native date inputs. Unlike on the issue form, the clear button stays enabled so a date filter can be removed after it's set.
Bug Fixes
500 error on invalid API detail lookups. get_object_modified() filtered the queryset directly with the raw URL kwarg for its cheap (pk, modified) lookup, bypassing the exception handling DRF's get_object_or_404() normally provides. A non-numeric value against an integer lookup field (e.g. /api/series/does-not-exist-9999/) raised an uncaught ValueError instead of a 404.
Wikipedia attribution license bumped to 4.0. The attribution footer still linked to Creative Commons Attribution-Share-Alike License 3.0, after Wikipedia's own switch to 4.0.
Missing cover images instead of the placeholder. sorl-thumbnail returns a sizeless placeholder object (not None) when an image field references a file missing from S3, which raised a TypeError as soon as a template read its width. sorl already recovers from that via the thumbnail tag's {% empty %} clause, but none of Metron's templates defined one, so covers silently vanished instead of falling back to the existing "image not found" graphic.
Missing Retry-After on some 429 responses. DRF's throttle wait() returns None once a user already has more requests in the current window than the (possibly just-lowered) limit allows, and the exception handler only set Retry-After when wait() was truthy — so those 429s went out with no Retry-After at all. The accompanying X-RateLimit-*-Reset headers were also wrong in that case: they reported when only the oldest request expires, rather than when enough of them expire to actually free a slot, so a client that waited until Reset could be rejected again. Both are now computed from the same corrected calculation.
Issue date pickers stopped at 1976. The bulma-calendar widget on the issue form was initialised without a minimum date, and it builds its year list as the visible date ±50 years, so cover, store, and FOC dates before 1976 couldn't be picked even though the form's validation accepts years back to 1900. The picker now allows dates back to 1900, using the same minimum year as the server-side check. The creator birth and death date pickers had the same limit and were fixed too. Thanks to Wendel Ortiz for the fix!
Developer Experience
Fixed .env.example missing required settings. settings.py reads STATIC_ROOT and MEDIA_ROOT through config() with no default whenever DEBUG is on, and the example file sets DEBUG=True — so copying it as-is raised UndefinedValueError on manage.py before anything else could run. Both variables are now declared, and staticfiles//media/ were added to .gitignore.
Upgraded to Django 6.1.1. Email settings were also migrated ahead of Django 7.0: the deprecated EMAIL_* settings are replaced by settings.MAILERS, and mail-sending call sites moved off the deprecated get_connection()/EmailMessage(connection=...) API onto mail.mailers.default and send(using="default"), clearing the RemovedInDjango70Warning the old API raised throughout the test suite.
nginx log rotation. nginx was writing every request both to its log file and to stdout, so each one also landed in the systemd journal; together with gunicorn's access log, that filled journald's default 4G cap in about 9 days, and the never-rotated log file had grown to 4.5G. The stdout copy is gone (fail2ban only reads the file), a logrotate config now rotates the nginx logs daily, and DEPLOYMENT.md documents installing it along with a larger journald size limit.
Retired the nginx-429 fail2ban jail. The jail had already been disabled on the server since the notify_throttled_clients management command took over — its 429 counts from the logs now feed the throttle-notice e-mails instead.
Tooling Releases
Mokkari 4.8.0
- 4.7.0 - Adds
publisher,series_type,cv_id, andgcd_idto the series schema, andissue_counttoPullListSeriesDetail, matching the series list endpoint changes above. Bumps pyright to target Python 3.14. Upgrades the ESLint toolchain to v10, removing unused dead dependencies and swapping the unmaintainedeslint-plugin-eslint-commentsfor the maintained@eslint-communityfork. - 4.7.1 - Drops
issue_countfromPullListSeriesDetailagain, matching Metron dropping it from the pull_list endpoint's response once it turned out nothing ever populated it. - 4.8.0 - Adds an opt-in
rate_limiterpacing gate toSession: aRateLimiterprotocol a caller can implement to block until capacity frees, plusHeaderPacedRateLimiter, a reference implementation that paces requests from Metron'sX-RateLimit-*headers instead ofSession's default fail-fast check — raising on an exhausted daily window rather than silently blocking for hours. Bounds pagination's 429 retries so a sustained rate limit (or a non-blocking custom limiter) can no longer hang a list call forever, now that Metron always sendsRetry-Afteron a 429 (above). Reuses a single pooledrequests.Sessioninstead of paying a fresh TCP+TLS handshake per request. Big thanks to AJ Slater for all his help with this release!
Darkseid 8.4.1
- 8.4.1 - Fixes
Comic.remove_pages()reporting success on PDFs while leaving the file unchanged. PDFs count as writable because metadata can be embedded in them, but their pages can't be removed, soPdfArchiver.remove_files()now refuses page removals (embeddedComicInfo.xml/MetronInfo.xmlcan still be removed). A newComic.can_remove_pages()lets callers check for page-removal support without hard-coding file suffixes. Also documents a single thread-safety contract for the archivers andComic, including PDF's stricter single-thread requirement from PyMuPDF.
Metron-Tagger 4.16.1
- 4.16.0 - Updates to Mokkari 4.8.0, closing its pooled HTTP connections once a run finishes. The duplicate page scan is much faster: comics are hashed concurrently (one per worker, defaulting to the CPU count capped at 8), and JPEG pages are decoded at reduced size before hashing, since the hash only needs an 8x8 image (about 4x faster for baseline JPEGs). The scan now uses darkseid 8.4.1's
can_remove_pages()to skip comics whose pages can't be removed, such as CBR and PDF, instead of opening and hashing them. - 4.16.1 - Requires darkseid >= 8.4.1.
For App Developers: Don't Run Every Install at the Same Time
If you maintain an application that other people install and run, and it talks to Metron on a schedule, please don't have every install run its scheduled jobs at the same time. Personal scripts are fine; this is about software that many people run.
This has happened more than once. The traffic from a third-party app shows up as a sharp spike at the same time every day, or every hour, because every install of that app is running the same job at the same moment. The time varies from app to app: sometimes it's midnight UTC, sometimes the top of the hour, and sometimes whatever time the app ships as its default.
Often nothing in the app's code sets that time directly. In the most recent case, the app used a job queue with a "repeat every 24 hours" option, and that queue lines interval jobs up with the Unix epoch instead of the time the server started. Every install with the default 24-hour interval therefore ran at exactly 00:00 UTC, no matter where it was or when it was started.
Each install stays inside its own rate limit, but they all hit the same server at once. When that happens, responses slow down for everyone, including people using the site at that time.
A few ways to avoid it:
- Pick a random offset once per install and save it. For example, choose a random minute and hour on first run, store it in the app's settings, and use it to build the cron expression or pass it as the scheduler's offset. Each install still runs once a day, but the installs are spread across the whole day.
- Check how your scheduler works out "every N hours". Some schedulers count from when the job was registered. Others, like BullMQ's
repeat.every, line up with the epoch. Cron patterns like0 0 * * *or@dailyalways run at the same wall-clock time on every machine. - Don't ship a fixed time as the default. Most users never change the default, so whatever time you ship, whether it's midnight, 3 AM, or the top of the hour, is the time nearly every install uses.
- Add jitter to retries as well. If every install retries after a failure with the same fixed delay, they'll all come back at the same moment too.
- Only fetch what you need. Filter by the series the user actually follows (for example with
series_id) instead of pulling every upcoming issue and filtering locally. It cuts the number of requests for both you and us. - Respect the rate limit headers. Honor
Retry-Afteron a 429, and use theX-RateLimit-*headers to pace your requests. If you're using Mokkari, 4.8.0'sHeaderPacedRateLimiterdoes this for you.
If you're not sure whether your app is affected, feel free to ask on Matrix and we'll help check.
OpenCollective
A huge thank you to everyone who has contributed to our Open Collective! Your support makes a real difference in keeping the Metron Project running and growing.
What Your Contributions Support
Funds from Open Collective go directly toward:
- Server hosting costs - Keeping the Metron website and API available
- Domain registration - Annual domain name renewals
- Future capacity increases - Scaling resources as the database and user base grows
All expenses are transparent and publicly viewable on our Open Collective page, so you can see exactly where every dollar goes.
Support the Project
As covered in our supporter rate limits post, donors now automatically get an elevated daily API rate limit. Any contribution, at any tier, genuinely helps keep Metron free for the whole community.
Don't Contribute as Incognito
When you contribute on Open Collective, you can choose to contribute as Incognito. Please don't use that option if you want the supporter rate limit.
Metron matches each contribution to an account by the contributor's email address. Open Collective doesn't share an Incognito contributor's email, so Metron has nothing to match, and the elevated rate limit is never applied to your account. This applies to recurring donations as well: every monthly charge from an Incognito contribution is skipped the same way.
When you contribute, use your normal profile and the same email address that's confirmed on your Metron account. Your email isn't shown publicly on Open Collective either way.
If you've already contributed as Incognito and your profile page doesn't show a supporter tier, e-mail me with your Open Collective order number and I'll apply it by hand.
Thanks to everyone who contributed issues, pull requests, and feedback this month. As always, the project is open source and community contributions are welcome.
