Files
10Backward/docs/architecture/mastodon-publishing.md

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.
publishing
mastodon
api
boundary
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 via THROWBACK_PREFIX).
  • Posts are sorted by (date, path) so output is deterministic regardless of upstream order.
  • hashtags is 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.

Related