6.5 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | ||||
|---|---|---|---|---|---|---|---|---|
| architecture | Mastodon Publishing | The single boundary between the pipeline runner and the Mastodon HTTP API — status composition, length validation, and the publish call. |
|
2026-08-04T18:55:00Z |
Purpose
tenbackward.publishing is the single success boundary between the
pipeline orchestrator and the Mastodon HTTP API. The runner calls
publish_mastodon() after dedupe and before state is persisted; if
the call raises, the retry loop re-attempts and posted.json is left
untouched. Composition and validation are pure functions that do not
touch the network so they are unit-tested without a server.
Public API
| Symbol | Responsibility |
|---|---|
MASTODON_STATUS_LIMIT |
500 — Mastodon's hard maximum status length. |
PublishError |
Raised on composition failure, length overflow, or any wrapped API exception. Subclass of RuntimeError. |
build_status_text(prefix, posts, hashtags) |
Pure composer. Returns the final status string. Raises PublishError("empty_posts: ...") when posts is empty. |
validate_status(status, limit=MASTODON_STATUS_LIMIT) |
Pure validator. Raises PublishError("status_too_long: len=N limit=L") when len(status) > limit. Never truncates. |
publish_mastodon(config, posts, *, client_factory=None) |
Composes, validates, and posts. Returns the composed status string. Wraps every third-party exception in PublishError(f"publish_failed: {ExcType}: {exc}", ) from exc. |
slugify_title(title) |
Re-exported from matching; used by callers that need to derive the same URL slug the matcher emits. |
Status Layout
build_status_text produces exactly one Mastodon status for any number
of matching posts, in this shape:
{prefix}
{title1}
{url1}
{title2}
{url2}
...
{hashtags}
- The prefix comes from
Config.throwback_prefix(configured viaTHROWBACK_PREFIX). - Posts are sorted by
(date, path)so output is deterministic regardless of upstream order. hashtagsis a comma-separated string; commas are collapsed to a single space so each tag starts with#and no trailing comma is emitted (empty tags are dropped).- A trailing newline is always appended.
Composition Examples
Single post
Heute vor 10 Jahren:
Mein erster Post
https://blog.example.com/2016/08/04/mein-erster-post/
#throwback #10backward
Multiple posts on the same day
When more than one Jekyll post matches the current day, they are
merged into one status — the runner does not issue separate API
calls per match. Ordering is (date, path), so identical dates sort
by relative path:
Heute vor 10 Jahren:
Post A
https://blog.example.com/2016/08/04/a/
Post B
https://blog.example.com/2016/08/04/b/
#throwback #10backward
No posts
Calling build_status_text("...", [], "#x") raises
PublishError("empty_posts: cannot compose status without posts")
before any API call. The runner only invokes the publisher when its
candidate list is non-empty, so this guard is a backstop for direct
callers and unit tests.
Length Validation
Mastodon's API rejects statuses longer than 500 characters. The
spec requires failing safely — the bot must never silently
truncate content. validate_status() enforces this contract: the
full status is measured, and overflow raises
PublishError("status_too_long: len=N limit=500") before the API
call. The runner's retry loop treats this exactly like any other
publish failure.
Mastodon API Call
_post_status_via_mastodon_py() constructs a mastodon.Mastodon client with (access_token=config.mastodon_access_token, api_base_url=config.mastodon_base_url) and calls status_post(status, visibility=config.mastodon_visibility).
The client_factory keyword argument on publish_mastodon lets
tests inject a fake client without monkey-patching. Production
callers leave it as None.
Failure Modes
| Source | Exception surfaced to runner | Cause attached? |
|---|---|---|
Empty posts argument |
PublishError("empty_posts: ...") |
No (no inner exc). |
| Composed status > 500 chars | PublishError("status_too_long: ...") |
No. |
| Any other exception from inside the boundary | PublishError("publish_failed: {ExcType}: {exc}") |
Yes, via raise ... from exc. |
The runner catches every Exception in _run_once, so any of the
above becomes a pipeline_error log line and the retry budget
decides whether to give up.
Wiring
_run_once(config) # main.py
├── ensure_repo(...)
├── store = PostedStore(config.data_dir)
├── candidates = list(_iter_candidates(config)) # find_anniversary_matches
├── unposted = [m for m in candidates if not store.is_posted(m.path)]
├── if unposted:
│ publish_mastodon(config, unposted) # <-- THIS boundary
│ store.mark_posted_many(posted_ids) # only after success
State is written only after publish_mastodon returns. A publish
failure leaves posted.json unchanged and lets the retry budget
re-attempt on the next iteration.
Configuration Surface
| Env var | Consumed via | Effect |
|---|---|---|
MASTODON_BASE_URL |
Config.mastodon_base_url |
Mastodon instance URL. |
MASTODON_ACCESS_TOKEN |
Config.mastodon_access_token |
OAuth token passed to the Mastodon client. |
MASTODON_VISIBILITY |
Config.mastodon_visibility |
Passed as visibility= to status_post. |
THROWBACK_PREFIX |
Config.throwback_prefix |
First line of every published status. |
HASHTAGS |
Config.hashtags |
Trailing hashtag line; commas become spaces. |
See Config Schema for full validation rules.
Key Files
| Path | Responsibility |
|---|---|
/repo/src/tenbackward/publishing.py |
MASTODON_STATUS_LIMIT, PublishError, build_status_text, validate_status, _post_status_via_mastodon_py, publish_mastodon. |
/repo/src/tenbackward/matching.py |
MatchedPost dataclass and slugify_title() reused here. |
/repo/src/tenbackward/main.py |
Calls publish_mastodon() between dedupe and mark_posted_many. |
/repo/tests/test_publishing.py |
Composition, length validation, visibility passthrough, API failure wrapping, and the no-persistence-on-failure contract. |