--- type: architecture title: Mastodon Publishing description: The single boundary between the pipeline runner and the Mastodon HTTP API — status composition, length validation, and the publish call. tags: [publishing, mastodon, api, boundary] timestamp: 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](/architecture/anniversary-matching.md); 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: ```text {prefix} {title1} {url1} {title2} {url2} ... {hashtags} ``` * The prefix comes from `Config.throwback_prefix` (default `Heute vor 10 Jahren:`, overridable via `THROWNBACK_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 ```text 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: ```text 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.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. | | `VISIBILITY` | `Config.visibility` | Passed as `visibility=` to `status_post`. | | `THROWNBACK_PREFIX` | `Config.throwback_prefix` | First line of every published status. | | `HASHTAGS` | `Config.hashtags` | Trailing hashtag line; commas become spaces. | See [Config Schema](/architecture/config-schema.md) 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 * [Pipeline Runner](/architecture/pipeline-runner.md) * [Anniversary Matching](/architecture/anniversary-matching.md) * [Config Schema](/architecture/config-schema.md) * [Daily Run Guide](/guides/daily-run.md)