Skip to content

Port threads-go fixes #28–#31: auth hardening + publish recovery#16

Merged
tirthpatell merged 3 commits into
mainfrom
feat/sync-go-fixes
Jul 5, 2026
Merged

Port threads-go fixes #28–#31: auth hardening + publish recovery#16
tirthpatell merged 3 commits into
mainfrom
feat/sync-go-fixes

Conversation

@tirthpatell

@tirthpatell tirthpatell commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Summary

The Go client (threads-go) received several fixes since this Rust port last synced (at the Go conformance audit, ported here as the 32-issue audit PR). This brings the missing ones over, plus fixes from a follow-up audit of the ported code.

Ported from Go

  • debug_token app access token (Go #28) — calls Meta with TH|client_id|client_secret as the caller token (Meta resolves app context from the caller token and rejects user tokens for dev-mode apps), falls back to the user token when credentials are absent, and defaults an empty input_token to the stored token.
  • with_token /me fallback (Go #29) — when debug_token fails (graph.threads.net returns HTTP 500 for valid tokens on dev-mode apps), validates via /me?fields=id and bootstraps TokenInfo with the user ID and a 60-day expiry.
  • Error classification (Go #29/#30) — API codes 190/102 map to AuthenticationError regardless of HTTP status (Meta returns 5xx for these on some endpoints); auth errors are never retried; code 10 (GraphMethodException) is permanent and never retried, so retries don't burn publish quota.
  • Publish recovery (Go #30/#31) — new posts_publish_recovery module: when /threads_publish fails with code 10, the container may have been published anyway. Polls container status until PUBLISHED, then locates the post among recent posts via content-based matchers (text, topic tag, reply parent, quote state, carousel child count). Fails closed on ambiguity or content with no unique discriminator, surfacing the original error. All four create_*_post methods are wired through it.

Audit-round fixes (second commit)

  • Error-body parsing: code/subcode now parse as u64 with clamped conversion — Meta subcodes exceed u16 (e.g. 2207026) and previously failed the whole body parse, silently disabling all of the classification above. Codes also apply independently of the message, so {"code":190,"message":""} no longer classifies as a retryable 5xx.
  • MediaType::Unknown with #[serde(other)] — a new media_type value from Meta no longer fails deserialization of an entire posts response (which would abort recovery exactly when the post exists).
  • with_token error fidelity — the typed /me error propagates instead of a fabricated auth error, so transient outages aren't misreported as invalid tokens.
  • Recovery owns its false-failure code list instead of aliasing the retry policy's; shared publish_with_recovery helper replaces four copies; text_matcher routes through the shared matcher body; Default derives on the *PostContent structs.

Deliberately not ported

From Go #27: OAuth state CSRF was already done here (#14); URL log redaction (this client never logs query strings); the rate-limiter swap race (single shared Arc<RateLimiter> here); the DeletePostWithConfirmation ownership fix (that API was never part of the Rust surface). Go #34 (toolchain pin) is Go-specific.

Test plan

  • cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings
  • 200 unit tests + 1 doctest
  • New tests/go_parity_sync.rs wiremock suite (9 tests): debug_token caller token + input_token default, /me fallback success/both-fail, auth errors not retried, recovery happy path (including an unknown media_type post in the listing window), terminal-container and ambiguous-match fail-closed, no recovery for non-code-10 errors

The Go client (threads-go) received several fixes since this port last
synced (Go conformance audit #24 / Rust audit PR #2). This ports the
missing ones:

- debug_token now calls Meta with the app access token
  (TH|client_id|client_secret), falling back to the user token when
  credentials are absent, and defaults input_token to the stored token.
  Meta resolves the request's app context from the caller token and
  rejects user tokens for dev-mode apps (Go #28).

- Client::with_token falls back to /me?fields=id when debug_token fails
  (graph.threads.net returns HTTP 500 for valid tokens on dev-mode
  apps), bootstrapping TokenInfo with the user ID and a 60-day expiry
  (Go #29).

- HTTP error classification: API error codes 190/102 map to
  AuthenticationError regardless of HTTP status (Meta returns 5xx for
  these on some endpoints), and authentication errors are never
  retried. Code 10 (GraphMethodException) is classified as permanent
  and never retried — retrying burns publish quota (Go #29, #30).

- Recovery from /threads_publish false failures (Go #30/#31): when
  publish fails with code 10, the container may have been published
  anyway. The client polls the container status until PUBLISHED, then
  locates the post among the user's recent posts using content-based
  matchers (text/topic_tag/reply/quote state, children count for
  carousels). Matching fails closed on ambiguity or when the content
  has no unique discriminator, surfacing the original publish error.

Not ported from Go #27 (already covered or N/A here): OAuth state CSRF
(done in #14), URL log redaction (we never log query strings), the
rate-limiter swap race (single shared Arc<RateLimiter> here), and the
DeletePostWithConfirmation ownership fix (that API was never part of
the Rust surface).

Also puts the existing wiremock dev-dependency to use: new integration
suite covering the debug_token caller token, the /me fallback,
non-retryable auth errors, and the four recovery outcomes.
…eanups

Correctness fixes found by review of the ported changes:

- http.rs: parse API error code/error_subcode as u64 and clamp on
  conversion. Meta subcodes routinely exceed u16 (e.g. 2207026), and an
  out-of-range integer failed the WHOLE error-body parse — silently
  dropping code 190/102/10 classification and re-enabling retries for
  auth errors and code-10 publish failures, defeating the previous
  commit. Also apply code/is_transient/subcode independently of the
  message: an error body with a code but empty message was previously
  misclassified as a retryable 5xx.

- types: add MediaType::Unknown with #[serde(other)]. Without it, any
  new media_type value from Meta failed deserialization of the entire
  posts response — breaking every listing call and aborting publish
  recovery exactly when the post exists.

- client.rs: with_token no longer fabricates an AuthenticationError
  when the /me fallback fails; the typed /me error is propagated so
  callers can distinguish a transient outage from an invalid token
  (matches Go, which wraps with %w). An empty /me ID surfaces the
  original debug_token error.

- recovery: own the false-failure code list (is_publish_false_failure_code)
  instead of aliasing the retry policy's permanent-code list — tuning
  "don't retry this code" must not silently start triggering recovery
  polls for codes that genuinely failed.

Cleanups: collapse the four duplicated publish/recover blocks into
publish_with_recovery; route text_matcher through the shared
media_content_matches; drop a dead chrono unwrap_or fallback; single
extract_base_fields pass in is_retryable_error; derive Default on the
four *PostContent structs (Go zero-value ergonomics) and simplify
create_quote_post and test constructors with struct-update syntax.

Regression tests: subcode > u16, code-with-empty-message, unknown
media_type unit + end-to-end recovery with an unknown-type post in the
listing window.
@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 5/5

Safe to merge; all changed paths are covered by unit tests and 9 new wiremock integration tests with no blocking logic issues.

Auth-error classification, retry suppression, and recovery logic are all tested end-to-end with wiremock, including the ambiguous-match and terminal-container fail-closed paths. The u64 subcode fix and MediaType::Unknown fallback are also unit-tested. The two observations (unpaginated recovery list and missing carousel integration test) are both fail-closed in the worst case — the original publish error is returned, no incorrect data is surfaced.

tests/go_parity_sync.rs — carousel recovery has no end-to-end integration test; src/api/posts_publish_recovery.rs — recovery list is not paginated beyond 25 posts.

Important Files Changed

Filename Overview
src/api/posts_publish_recovery.rs New module implementing Meta-documented publish-recovery flow: polls container status, then locates the post via content-specific matchers. Logic is fail-closed on ambiguity; all matchers are unit-tested. Carousel recovery depends on children field availability in list responses (included in POST_EXTENDED_FIELDS).
src/http.rs Error body parsing widened to u64 for code/subcode fields to prevent parse failures with large Meta subcodes; code/transient/subcode now applied independently of message; auth codes (190/102) fast-path to AuthenticationError; code 10 permanently excluded from retry; all paths unit-tested.
src/auth.rs debug_token now uses the app access token shorthand (TH
src/client.rs with_token now falls back to /me when debug_token fails; propagates the typed /me error; documents the 60-day expiry approximation in the public API doc comment.
src/types/common.rs MediaType::Unknown added with #[serde(other)] for forward compatibility; prevents deserialization failure when Meta introduces new media_type values in list responses during recovery.
src/types/post.rs Default derived on all four *PostContent structs for struct-update ergonomics; each type's doc comment warns that the Default value has empty required fields and will fail API validation.
src/api/posts.rs All four create_*_post methods now route through publish_with_recovery; create_quote_post cleaned up with ..Default::default(); shared helper eliminates duplicated publish logic.
tests/go_parity_sync.rs 9 wiremock integration tests covering: debug_token app token + default input_token, with_token /me fallback success/both-fail, auth-190 not retried, text-post recovery (including unknown media_type in listing), terminal container fail-closed, ambiguous match fail-closed, no recovery for non-code-10 errors.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant Client
    participant Meta

    Caller->>Client: "create_*_post(content)"
    Client->>Meta: POST /threads (create container)
    Meta-->>Client: container_id
    Client->>Meta: POST /threads_publish(container_id)
    alt Publish succeeds
        Meta-->>Client: post_id
        Client-->>Caller: Ok(Post)
    else Publish fails with code 10
        Meta-->>Client: "HTTP 500 code=10"
        loop up to 5 polls x 1s
            Client->>Meta: GET /container_id (status)
            Meta-->>Client: status
        end
        alt "status = PUBLISHED"
            loop up to 3 polls x 1s
                Client->>Meta: "GET /user_id/threads?since=publish_start-5s&limit=25"
                Meta-->>Client: posts list
            end
            alt Unique content match found
                Client-->>Caller: Ok(recovered Post)
            else Zero or ambiguous match
                Client-->>Caller: Err(original publish error)
            end
        else "status = ERROR or EXPIRED or timeout"
            Client-->>Caller: Err(original publish error)
        end
    else Publish fails with other code
        Meta-->>Client: non-code-10 error
        Client-->>Caller: Err(error)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant Client
    participant Meta

    Caller->>Client: "create_*_post(content)"
    Client->>Meta: POST /threads (create container)
    Meta-->>Client: container_id
    Client->>Meta: POST /threads_publish(container_id)
    alt Publish succeeds
        Meta-->>Client: post_id
        Client-->>Caller: Ok(Post)
    else Publish fails with code 10
        Meta-->>Client: "HTTP 500 code=10"
        loop up to 5 polls x 1s
            Client->>Meta: GET /container_id (status)
            Meta-->>Client: status
        end
        alt "status = PUBLISHED"
            loop up to 3 polls x 1s
                Client->>Meta: "GET /user_id/threads?since=publish_start-5s&limit=25"
                Meta-->>Client: posts list
            end
            alt Unique content match found
                Client-->>Caller: Ok(recovered Post)
            else Zero or ambiguous match
                Client-->>Caller: Err(original publish error)
            end
        else "status = ERROR or EXPIRED or timeout"
            Client-->>Caller: Err(original publish error)
        end
    else Publish fails with other code
        Meta-->>Client: non-code-10 error
        Client-->>Caller: Err(error)
    end
Loading

Reviews (2): Last reviewed commit: "chore: bump to 0.3.0 for MediaType varia..." | Re-trigger Greptile

Comment thread src/client.rs
@tirthpatell tirthpatell self-assigned this Jul 5, 2026
@tirthpatell
tirthpatell force-pushed the feat/sync-go-fixes branch from 16f6d08 to e4bdace Compare July 5, 2026 21:39
…otes

- Bump version 0.2.0 -> 0.3.0: adding MediaType::Unknown to an exhaustive
  public enum is a breaking change (cargo-semver-checks enum_variant_added),
  consistent with the 0.1.1 -> 0.2.0 bump for the OAuth state change.

- Document the /me-fallback expiry tradeoff on Client::with_token: the
  fallback cannot read the token's real remaining lifetime, so expires_at
  assumes a full 60 days; callers hitting the fallback consistently should
  refresh proactively.

- Document that Default on the *PostContent structs yields values that
  fail validation (empty text/image_url/video_url/children) — required
  fields must always be set explicitly.
@tirthpatell
tirthpatell merged commit ea2a43e into main Jul 5, 2026
8 checks passed
@tirthpatell
tirthpatell deleted the feat/sync-go-fixes branch July 5, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant