Skip to content

fix(platform): disable redirect following on meta and x/twitter clients#30

Merged
dealako merged 10 commits into
mainfrom
fix/LFXV2-2641-meta-twitter-nofollow
Jul 20, 2026
Merged

fix(platform): disable redirect following on meta and x/twitter clients#30
dealako merged 10 commits into
mainfrom
fix/LFXV2-2641-meta-twitter-nofollow

Conversation

@mrautela365

Copy link
Copy Markdown
Contributor

Problem

The Meta and X/Twitter Ads clients built their *http.Client (and accepted WithHTTPClient clients) with no CheckRedirect, so the Go stdlib follows redirects by default. On a mutating POST, the client could follow a 3xx after the create was already committed upstream and misclassify the outcome — the same duplicate-create risk already fixed on the reddit (#27), linkedin (#22), and google-ads (#29) clients. For X/Twitter it's worse: a followed redirect resends an OAuth 1.0a request signed for the original URL to a different target.

This closes the last two clients that lacked the guard.

Changes

  • meta/client.go, twitter/client.go — add a shared noFollow policy (http.ErrUseLastResponse) on the default client, and enforce it unconditionally after options are applied (including on a WithHTTPClient-supplied client) via a shallow copy so the caller's client is not mutated. Mirrors the reddit client's NewClient enforcement.
  • Regression tests for each: default client has no-follow, and an injected client's CheckRedirect is overridden without mutating the caller's client.

Verification

go build, go test -race, golangci-lint all clean; existing tests unaffected (none relied on redirect-following). No new dependencies.

LFXV2-2641

@mrautela365
mrautela365 requested a review from a team as a code owner July 18, 2026 14:58
Copilot AI review requested due to automatic review settings July 18, 2026 14:58
@cursor

cursor Bot commented Jul 18, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes sit on paid campaign creation paths and error classification that gates blind retries; incorrect ambiguity handling could still duplicate resources, though the intent is to reduce that risk.

Overview
Meta and X/Twitter Ads clients now disable HTTP redirect following (noFollow / http.ErrUseLastResponse) on the default and WithHTTPClient clients, aligning with reddit/linkedin/googleads so mutating POSTs are not followed after a possible commit (OAuth 1.0a on X makes followed redirects especially unsafe).

On Meta, the change goes further than no-follow alone: NewClient builds a fresh *http.Client (Transport/Jar/Timeout only) instead of value-copying the caller’s client; createOutcomeAmbiguous treats mutating 3xx like 5xx/transport failures; doRequest keeps HTTP status on non-2xx oversized or unreadable bodies; ad-set create uses the same UNCONFIRMED path for ambiguous errors and for 2xx with no id; campaign UNCONFIRMED messaging mentions unfollowed redirects. Regression tests cover no-follow (including sentinel override), status preservation, and ambiguity cases.

Twitter gets no-follow plus a shallow-copy override and tests; typed 3xx UNCONFIRMED there remains a follow-up (LFXV2-2642). Concept docs and the knowledge log are updated.

Reviewed by Cursor Bugbot for commit e506639. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Disables automatic redirects in Meta and X/Twitter Ads clients to reduce duplicate-create risk.

Changes:

  • Adds enforced no-follow redirect policies.
  • Preserves caller-supplied HTTP clients via shallow copies.
  • Adds policy tests and a knowledge-log entry.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
internal/platform/meta/client.go Enforces no-follow redirects.
internal/platform/meta/client_test.go Tests Meta redirect configuration.
internal/platform/twitter/client.go Enforces no-follow redirects.
internal/platform/twitter/client_test.go Tests X redirect configuration.
docs/knowledge/log.md Records the redirect hardening.

Comment thread internal/platform/meta/client.go
Comment thread internal/platform/twitter/client.go
Comment thread docs/knowledge/log.md
mrautela365 added a commit that referenced this pull request Jul 18, 2026
… (PR #30)

Follow-up to the no-follow change: disabling redirect following surfaces a 3xx as
an error, but Meta's createOutcomeAmbiguous only treated 5xx/transport as
ambiguous — so a mutating POST that committed and then returned a 3xx was
classified as a definite failure, and a caller could retry into a duplicate.
Classify a mutating 3xx (300-399) as ambiguous alongside 5xx (a definite 4xx stays
non-ambiguous). Added a table-driven createOutcomeAmbiguous test. (per copilot)

Documented the enforced no-follow policy (and Meta's 3xx-UNCONFIRMED behavior) in
the meta and twitter KB concept docs, per the repo doc convention for notable
behavior changes. (per copilot)

The X/Twitter equivalent is larger — that client has no typed error or
createOutcomeAmbiguous helper at all (it returns a bare fmt.Errorf for non-2xx and
echoes the body), so typing its error + adding ambiguity classification is a
separate design change tracked as a follow-up (LFXV2-2642), noted in the twitter
concept doc. Keeping this PR focused on the no-follow hardening + Meta's small 3xx
classification.

Verified: build, go test -race, golangci-lint clean.

LFXV2-2641

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 18, 2026 15:07
@mrautela365

Copy link
Copy Markdown
Contributor Author

Review feedback addressed (round 2)

Commit: e8042d9

Changes made

  • meta/client.go — classify a mutating 3xx as ambiguous in createOutcomeAmbiguous (a Meta create that committed before redirecting is now UNCONFIRMED, not a definite failure → no duplicate retry). Added a table-driven test. (per copilot)
  • docs/knowledge — documented the no-follow policy (+ Meta's 3xx behavior) in the meta and twitter concept docs. (per copilot)

Deferred to follow-up

  • twitter/client.go 3xx classification — X has no typed error / createOutcomeAmbiguous helper at all (bare fmt.Errorf for non-2xx), so typing the error + adding ambiguity classification is a separate design change tracked as LFXV2-2642. The no-follow guard here is still a strict improvement. Thread left open to track it.

Verification

build / go test -race / golangci-lint clean.

Threads resolved

2 of 3 (twitter 3xx-classification deferred to LFXV2-2642).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

internal/platform/meta/client.go:682

  • Not every surfaced 3xx reaches this classifier. doRequest returns a plain fmt.Errorf when a non-2xx body is truncated (client.go:761-770) or exceeds the size cap (client.go:735-738), discarding the known status. A mutating POST that committed and then returned such a 3xx therefore makes createOutcomeAmbiguous return false and can be blind-retried. Preserve the status/method in a typed error for these paths and cover the POST→truncated/oversized-3xx flow.
	var ae *APIError
	// A mutating 3xx (redirect, not followed) or 5xx may follow a committed create.
	return errors.As(err, &ae) && ((ae.StatusCode >= 300 && ae.StatusCode < 400) || ae.StatusCode >= 500)

mrautela365 added a commit that referenced this pull request Jul 20, 2026
Address PR #30 review feedback from copilot[bot]:

TestNoFollowRedirectPolicy (meta + twitter) injected a client with a nil
CheckRedirect, so it would still pass if NewClient merely filled nil callbacks
and preserved a caller-supplied redirect policy — leaving the unconditional
override (the case that can re-enable redirect following) without regression
coverage.

- Inject a caller client carrying a SENTINEL CheckRedirect.
- Assert the client the code actually uses returns http.ErrUseLastResponse
  despite the sentinel (proves the override is unconditional).
- Assert the caller's original client still returns the sentinel (proves the
  shallow copy did not mutate it).

Resolves 2 review threads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 20, 2026 03:36
Comment thread internal/platform/meta/client.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

internal/platform/meta/client.go:682

  • The new 3xx classification is bypassed when the redirect body is truncated or exceeds the response cap. doRequest returns a plain fmt.Errorf from those body-failure paths before constructing APIError, so this predicate returns false even though the mutating POST reached a responder and may have committed. Preserve the status/method for 3xx body failures (for example via APIError or an ambiguous wrapper), and cover a POST→3xx response with a body read failure.
	return errors.As(err, &ae) && ((ae.StatusCode >= 300 && ae.StatusCode < 400) || ae.StatusCode >= 500)

mrautela365 added a commit that referenced this pull request Jul 20, 2026
Address PR #30 review feedback from cursor:

createOutcomeAmbiguous treated every 3xx APIError as UNCONFIRMED without checking
the method, diverging from the reddit client (which gates 3xx on isMutatingMethod)
despite the doc claiming to mirror it. All call sites pass POST today so behavior
is unchanged, but the helper's contract was wrong for any future caller — a GET
redirect is not a create and should not be UNCONFIRMED.

- internal/platform/meta/client.go: added isMutatingMethod and gated the 3xx
  branch on it; 5xx and transport errors stay ambiguous regardless of method.
- internal/platform/meta/client_test.go: extended the ambiguity test with
  GET/POST/DELETE method cases (GET 302 -> not ambiguous, GET 500 -> ambiguous).
- docs/knowledge/log.md: log entries for this and the no-follow test hardening.

Resolves 1 review thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 20, 2026 03:40
mrautela365 added a commit that referenced this pull request Jul 20, 2026
Align the X/Twitter createOutcomeAmbiguous with the meta (PR #30) and reddit
clients after Cursor flagged the same divergence on meta: the predicate treated
every 3xx as UNCONFIRMED without checking the method. A GET redirect is not a
create and should not be ambiguous.

- internal/platform/twitter/client.go: added isMutatingMethod and gated the 3xx
  branch on it; 5xx and transport errors stay ambiguous regardless of method.
- internal/platform/twitter/client_test.go: added GET-302 (not ambiguous),
  DELETE-307 (ambiguous), and GET-500 (still ambiguous) cases.
- docs/knowledge: corrected the classifier description (method-gated 3xx) and
  added a log entry.

All three clients (reddit/meta/twitter) now share an identical method-gated
create-outcome contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Comment thread internal/platform/meta/client.go Outdated
Comment thread internal/platform/twitter/client.go
Comment thread internal/platform/meta/client.go
mrautela365 added a commit that referenced this pull request Jul 20, 2026
…mbiguity

Address PR #30 review feedback from copilot[bot]:

- internal/platform/meta/client.go (doRequest): a NON-2xx response whose body
  fails to read was returned as a plain error, stripping the HTTP status. A
  mutating 3xx (surfaced because redirects aren't followed) or 5xx with an
  unreadable body means Meta may have committed the create, but
  createOutcomeAmbiguous keys on the *APIError status — so it was mis-classified
  as a definite failure. Now returns an *APIError preserving the status on a
  non-2xx read failure (2xx read failures still wrap as transportError).
- internal/platform/meta/client.go (ad-set create): routed the error through
  createOutcomeAmbiguous like the campaign and ad/creative creates — an ambiguous
  3xx/5xx now reads UNCONFIRMED (verify before retrying) instead of a definite
  "ad set creation failed", so a caller does not blind-retry into a duplicate.
- internal/platform/meta/client_test.go: added TestDoRequestNon2xxReadErrorPreservesStatus
  and TestCreateCampaignAdSetAmbiguousIsUnconfirmed.
- docs/knowledge/log.md: log entry.

Resolves 2 review threads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 20, 2026 03:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread internal/platform/meta/client.go
mrautela365 added a commit that referenced this pull request Jul 20, 2026
Address PR #30 review feedback from copilot[bot]:

- internal/platform/meta/client.go: NewClient value-copied a WithHTTPClient-
  supplied *http.Client to override CheckRedirect. An http.Client must not be
  copied after first use — the copy duplicates its internal mutex while sharing
  the request-cancellation map, so concurrent use of the caller's client and the
  copy can race. Now builds a fresh *http.Client carrying only the exported
  reusable fields (Transport, Jar, Timeout) with CheckRedirect: noFollow. The
  caller's client is neither copied nor mutated.
- internal/platform/meta/client.go: the campaign UNCONFIRMED step said "timeout or
  server error"; a mutating 3xx now routes there too, so reworded to a reason-
  neutral "ambiguous response — timeout, server error, or an unfollowed redirect".
- internal/platform/meta/client_test.go: the no-follow test now asserts the fresh
  client preserves the caller's Transport/Timeout and is a distinct pointer.

Resolves 2 review threads. (The reddit client shares the value-copy pattern and
will get the same fix as a follow-up; twitter gets it on PR #31.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 20, 2026 03:53

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 377c750. Configure here.

Comment thread internal/platform/twitter/client.go
mrautela365 added a commit that referenced this pull request Jul 20, 2026
…ment

Mirror the meta fix (PR #30): NewClient value-copied a WithHTTPClient-supplied
*http.Client to override CheckRedirect, but an http.Client must not be copied
after first use (the copy duplicates its internal mutex while sharing the request-
cancellation map, so concurrent use of the caller's client and the copy can race).

- internal/platform/twitter/client.go: build a fresh *http.Client carrying only
  the exported reusable fields (Transport, Jar, Timeout) with CheckRedirect:
  noFollow. The caller's client is neither copied nor mutated.
- internal/platform/twitter/client_test.go: the no-follow test now asserts the
  fresh client preserves the caller's Transport/Timeout and is a distinct pointer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
mrautela365 added a commit that referenced this pull request Jul 20, 2026
…ment

Mirror the meta fix (PR #30): NewClient value-copied a WithHTTPClient-supplied
*http.Client to override CheckRedirect, but an http.Client must not be copied
after first use (the copy duplicates its internal mutex while sharing the request-
cancellation map, so concurrent use of the caller's client and the copy can race).

- internal/platform/twitter/client.go: build a fresh *http.Client carrying only
  the exported reusable fields (Transport, Jar, Timeout) with CheckRedirect:
  noFollow. The caller's client is neither copied nor mutated.
- internal/platform/twitter/client_test.go: the no-follow test now asserts the
  fresh client preserves the caller's Transport/Timeout and is a distinct pointer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

mrautela365 added a commit that referenced this pull request Jul 20, 2026
The Reddit client's NewClient value-copied a WithHTTPClient-supplied *http.Client
(hc := *c.httpClient) to override CheckRedirect. An http.Client must not be copied
after first use — go vet's copylocks analyzer flags it: the struct transitively
holds sync primitives, so the copy duplicates its internal mutex while sharing the
request-cancellation map, and concurrent use of the caller's client and the copy
can race.

reddit is the only client on main that enforces no-follow on a caller-supplied
client (merged via PR #27), so it is the only one with this value-copy today. The
same fresh-client construction is being made to meta (#30) and twitter (#31) as
part of ADDING no-follow to those clients.

- internal/platform/reddit/client.go: build a fresh *http.Client carrying only the
  exported reusable fields (Transport, Jar, Timeout) with CheckRedirect: noFollow.
  The caller's client is neither copied nor mutated. Documented the copylocks
  rationale and the defensive nil guard.
- internal/platform/reddit/client_test.go: assert the fresh client preserves the
  caller's Transport AND Jar (on top of Timeout / distinct-pointer / caller-not-
  mutated); reworded stale "copy" test comments to "fresh client".
- docs/knowledge: corrected the reddit concept doc's "shallow copy" wording and the
  log entry (no phantom meta/twitter-on-main precedent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
mrautela365 added a commit that referenced this pull request Jul 20, 2026
…ment

The Reddit client's NewClient applied its no-follow CheckRedirect to a
WithHTTPClient-supplied client by value-copying it (hc := *c.httpClient). This now
builds a fresh *http.Client carrying only the caller's documented exported fields
(Transport, Jar, Timeout) with CheckRedirect: noFollow.

This is a defensive/clarity change, NOT a race fix: on the repo's Go target
net/http.Client is exactly those four exported fields with no internal
synchronization state, so the old value copy was also correct and go vet's
copylocks analyzer does not flag it. The rebuild depends only on the type's public
API rather than the struct's internal shape (layout-independent) and won't silently
carry over any future unexported field.

- internal/platform/reddit/client.go: fresh-client construction with an accurate
  rationale (no "must not be copied"/race claim); documented the defensive nil guard.
- internal/platform/reddit/client_test.go: assert the client used preserves the
  caller's Transport/Jar/Timeout and the caller is not mutated. The test comment is
  explicit that it checks observable guarantees, not fresh-vs-copy (both yield a
  distinct pointer with the same fields).
- docs/knowledge: reframed the concept doc + log entry as layout-independence and
  scoped the log entry to reddit (meta #30 / twitter #31 are separate open PRs).

Scope: reddit only — it is the sole client on main enforcing no-follow on a
caller-supplied client (merged via PR #27).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
mrautela365 added a commit that referenced this pull request Jul 20, 2026
…ment

The Reddit client's NewClient applied its no-follow CheckRedirect to a
WithHTTPClient-supplied client by value-copying it (hc := *c.httpClient). This now
builds a fresh *http.Client carrying only the caller's documented exported fields
(Transport, Jar, Timeout) with CheckRedirect: noFollow.

This is a defensive/clarity change, NOT a race fix: on the repo's Go target
net/http.Client is exactly those four exported fields with no internal
synchronization state, so the old value copy was also correct and go vet's
copylocks analyzer does not flag it. The rebuild depends only on the type's public
API rather than the struct's internal shape (layout-independent) and won't silently
carry over any future unexported field.

- internal/platform/reddit/client.go: fresh-client construction with an accurate
  rationale (no "must not be copied"/race claim); documented the defensive nil guard.
- internal/platform/reddit/client_test.go: assert the client used preserves the
  caller's Transport/Jar/Timeout and the caller is not mutated. The test comment is
  explicit that it checks observable guarantees, not fresh-vs-copy (both yield a
  distinct pointer with the same fields).
- docs/knowledge: reframed the concept doc + log entry as layout-independence and
  scoped the log entry to reddit (meta #30 / twitter #31 are separate open PRs).

Scope: reddit only — it is the sole client on main enforcing no-follow on a
caller-supplied client (merged via PR #27).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
mrautela365 and others added 9 commits July 20, 2026 11:01
…ts (LFXV2-2641)

The Meta and X/Twitter Ads clients built their *http.Client (and accepted
WithHTTPClient clients) without setting CheckRedirect, so the stdlib could follow a
3xx on a mutating POST after the create was already committed, muddying outcome
classification — the same duplicate-create risk already fixed on the reddit,
linkedin, and google-ads clients. For X/Twitter it's worse: a followed redirect
resends an OAuth-1.0a request signed for the original URL to a different one.

Add a shared noFollow CheckRedirect policy (returns http.ErrUseLastResponse) on the
default client and enforce it UNCONDITIONALLY after options are applied — including
on a WithHTTPClient-supplied client — via a shallow copy so the caller's client is
not mutated. Mirrors the reddit client. Adds regression tests (default + injected
client override without mutation).

Verified: build, go test -race, golangci-lint clean.

LFXV2-2641

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
… (PR #30)

Follow-up to the no-follow change: disabling redirect following surfaces a 3xx as
an error, but Meta's createOutcomeAmbiguous only treated 5xx/transport as
ambiguous — so a mutating POST that committed and then returned a 3xx was
classified as a definite failure, and a caller could retry into a duplicate.
Classify a mutating 3xx (300-399) as ambiguous alongside 5xx (a definite 4xx stays
non-ambiguous). Added a table-driven createOutcomeAmbiguous test. (per copilot)

Documented the enforced no-follow policy (and Meta's 3xx-UNCONFIRMED behavior) in
the meta and twitter KB concept docs, per the repo doc convention for notable
behavior changes. (per copilot)

The X/Twitter equivalent is larger — that client has no typed error or
createOutcomeAmbiguous helper at all (it returns a bare fmt.Errorf for non-2xx and
echoes the body), so typing its error + adding ambiguity classification is a
separate design change tracked as a follow-up (LFXV2-2642), noted in the twitter
concept doc. Keeping this PR focused on the no-follow hardening + Meta's small 3xx
classification.

Verified: build, go test -race, golangci-lint clean.

LFXV2-2641

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Address PR #30 review feedback from copilot[bot]:

TestNoFollowRedirectPolicy (meta + twitter) injected a client with a nil
CheckRedirect, so it would still pass if NewClient merely filled nil callbacks
and preserved a caller-supplied redirect policy — leaving the unconditional
override (the case that can re-enable redirect following) without regression
coverage.

- Inject a caller client carrying a SENTINEL CheckRedirect.
- Assert the client the code actually uses returns http.ErrUseLastResponse
  despite the sentinel (proves the override is unconditional).
- Assert the caller's original client still returns the sentinel (proves the
  shallow copy did not mutate it).

Resolves 2 review threads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Address PR #30 review feedback from cursor:

createOutcomeAmbiguous treated every 3xx APIError as UNCONFIRMED without checking
the method, diverging from the reddit client (which gates 3xx on isMutatingMethod)
despite the doc claiming to mirror it. All call sites pass POST today so behavior
is unchanged, but the helper's contract was wrong for any future caller — a GET
redirect is not a create and should not be UNCONFIRMED.

- internal/platform/meta/client.go: added isMutatingMethod and gated the 3xx
  branch on it; 5xx and transport errors stay ambiguous regardless of method.
- internal/platform/meta/client_test.go: extended the ambiguity test with
  GET/POST/DELETE method cases (GET 302 -> not ambiguous, GET 500 -> ambiguous).
- docs/knowledge/log.md: log entries for this and the no-follow test hardening.

Resolves 1 review thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
…mbiguity

Address PR #30 review feedback from copilot[bot]:

- internal/platform/meta/client.go (doRequest): a NON-2xx response whose body
  fails to read was returned as a plain error, stripping the HTTP status. A
  mutating 3xx (surfaced because redirects aren't followed) or 5xx with an
  unreadable body means Meta may have committed the create, but
  createOutcomeAmbiguous keys on the *APIError status — so it was mis-classified
  as a definite failure. Now returns an *APIError preserving the status on a
  non-2xx read failure (2xx read failures still wrap as transportError).
- internal/platform/meta/client.go (ad-set create): routed the error through
  createOutcomeAmbiguous like the campaign and ad/creative creates — an ambiguous
  3xx/5xx now reads UNCONFIRMED (verify before retrying) instead of a definite
  "ad set creation failed", so a caller does not blind-retry into a duplicate.
- internal/platform/meta/client_test.go: added TestDoRequestNon2xxReadErrorPreservesStatus
  and TestCreateCampaignAdSetAmbiguousIsUnconfirmed.
- docs/knowledge/log.md: log entry.

Resolves 2 review threads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Address PR #30 review feedback from copilot[bot]:

- internal/platform/meta/client.go: NewClient value-copied a WithHTTPClient-
  supplied *http.Client to override CheckRedirect. An http.Client must not be
  copied after first use — the copy duplicates its internal mutex while sharing
  the request-cancellation map, so concurrent use of the caller's client and the
  copy can race. Now builds a fresh *http.Client carrying only the exported
  reusable fields (Transport, Jar, Timeout) with CheckRedirect: noFollow. The
  caller's client is neither copied nor mutated.
- internal/platform/meta/client.go: the campaign UNCONFIRMED step said "timeout or
  server error"; a mutating 3xx now routes there too, so reworded to a reason-
  neutral "ambiguous response — timeout, server error, or an unfollowed redirect".
- internal/platform/meta/client_test.go: the no-follow test now asserts the fresh
  client preserves the caller's Transport/Timeout and is a distinct pointer.

Resolves 2 review threads. (The reddit client shares the value-copy pattern and
will get the same fix as a follow-up; twitter gets it on PR #31.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
… doc

Address PR #30 review feedback from copilot[bot]:

- internal/platform/meta/client.go: the oversized-body branch (>1 MiB) returned a
  plain error before recording the status, so a mutating 3xx/5xx over the cap was
  still mis-classified as a definite failure (bypassing the non-2xx read-error fix
  from the previous commit). It now preserves the status the same way — 2xx wraps
  as transportError, non-2xx carries the status via *APIError — so an ambiguous
  outcome is never downgraded to a definite failure by an oversized body.
- internal/platform/meta/client_test.go: added TestDoRequestOversizedNon2xxPreservesStatus.
- docs/knowledge/code/internal-platform-meta.md: the concept doc still described a
  shallow copy; updated to document the fresh-client construction and the status
  preservation on unreadable/oversized bodies.

Resolves 2 review threads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
The Build and Test CI job's `make check-fmt` failed on comment alignment in
client_test.go (test cases I added). Applied gofmt -w; no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Address PR #30 review feedback from copilot[bot]:

- internal/platform/meta/client.go: a 2xx ad-set response with an empty id fell
  through to a definite "returned no ad set ID" error, bypassing the ambiguity
  handling the error path uses. Meta may have created the ad set, so a blind retry
  could duplicate it — now surfaces UNCONFIRMED (verify before retrying), mirroring
  the campaign/ad and twitter no-id paths.
- internal/platform/meta/client_test.go: added TestCreateCampaignAdSetNoIDIsUnconfirmed.
- docs/knowledge/log.md: log entry.

Resolves 1 review thread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 20, 2026 18:06
@mrautela365
mrautela365 force-pushed the fix/LFXV2-2641-meta-twitter-nofollow branch from dee7629 to 24304a2 Compare July 20, 2026 18:06
mrautela365 added a commit that referenced this pull request Jul 20, 2026
Align the X/Twitter createOutcomeAmbiguous with the meta (PR #30) and reddit
clients after Cursor flagged the same divergence on meta: the predicate treated
every 3xx as UNCONFIRMED without checking the method. A GET redirect is not a
create and should not be ambiguous.

- internal/platform/twitter/client.go: added isMutatingMethod and gated the 3xx
  branch on it; 5xx and transport errors stay ambiguous regardless of method.
- internal/platform/twitter/client_test.go: added GET-302 (not ambiguous),
  DELETE-307 (ambiguous), and GET-500 (still ambiguous) cases.
- docs/knowledge: corrected the classifier description (method-gated 3xx) and
  added a log entry.

All three clients (reddit/meta/twitter) now share an identical method-gated
create-outcome contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
mrautela365 added a commit that referenced this pull request Jul 20, 2026
…ment

Mirror the meta fix (PR #30): NewClient value-copied a WithHTTPClient-supplied
*http.Client to override CheckRedirect, but an http.Client must not be copied
after first use (the copy duplicates its internal mutex while sharing the request-
cancellation map, so concurrent use of the caller's client and the copy can race).

- internal/platform/twitter/client.go: build a fresh *http.Client carrying only
  the exported reusable fields (Transport, Jar, Timeout) with CheckRedirect:
  noFollow. The caller's client is neither copied nor mutated.
- internal/platform/twitter/client_test.go: the no-follow test now asserts the
  fresh client preserves the caller's Transport/Timeout and is a distinct pointer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread internal/platform/meta/client_test.go Outdated
Comment thread docs/knowledge/log.md Outdated
Address review from copilot[bot]:

- internal/platform/meta/client_test.go: TestDoRequestOversizedNon2xxPreservesStatus
  built a ~1 MiB payload, but maxResponseBody is 10 MiB, so the response took the
  ordinary 500 path and the test passed even if the oversized-body status-
  preservation branch were broken. Build the payload from maxResponseBody so it
  actually crosses the configured cap.
- docs/knowledge/log.md: the entry said the oversized branch triggers at >1 MiB;
  maxResponseBody is 10 MiB. Corrected.

Resolves 2 review threads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 20, 2026 18:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

//
// Build a FRESH *http.Client rather than value-copying the caller's: an
// http.Client must not be copied after first use (a value copy duplicates its
// internal mutex while sharing the request-cancellation map, so concurrent use

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Fresh-client rebuild is justified by a factually incorrect claim

Issue: The comment states an http.Client "must not be copied after first use (a value copy duplicates its internal mutex while sharing the request-cancellation map)". http.Client has no internal mutex and no request-cancellation map — its only fields are Transport, CheckRedirect, Jar, Timeout. The lock/connection state lives on http.Transport, which is shared by pointer in both the value-copy and fresh-client approaches.

Proof: go doc net/http.Client shows the four exported fields and nothing else; go vet ./internal/platform/twitter/ (which value-copies hc := *c.httpClient) passes clean — the copylocks analyzer would flag any struct embedding a sync.Locker.

Why it matters: The rebuild itself is harmless, but the rationale is the stated basis for diverging Meta from the reddit/twitter value-copy pattern this PR claims to "mirror," and the same incorrect claim is propagated into docs/knowledge/code/internal-platform-meta.md:96-97 and docs/knowledge/log.md:18-19. AGENTS.md asks the KB to stay accurate, so the myth shouldn't be enshrined there.

Fix: Either keep the fresh-client build but correct the reason (e.g. "build a fresh client so we set our own CheckRedirect without mutating the caller's client; carry over the reusable Transport/Jar/Timeout"), or revert to the value-copy that matches reddit/twitter. Update the two KB docs to match.

@dealako dealako left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — @mrautela365

Hi @mrautela365 👋 — thorough, well-tested piece of hardening. This closes the last two clients (Meta + X/Twitter) that lacked a CheckRedirect guard, and the OAuth-1.0a angle for X is a genuinely good catch: with noFollow returning http.ErrUseLastResponse, Do returns the 3xx without issuing a second request, so a request signed for the original URL is never replayed against a redirect target. The Meta ambiguity work (method-gated 3xx, status preservation on unreadable/oversized bodies, ad-set 5xx + 2xx-no-id → UNCONFIRMED) is careful and each branch is backed by a real, reachable test. go build, go vet, and go test -race on both packages are clean, and the branch is mergeable.

I traced the correctness-critical paths independently and ran parallel security + code-quality passes. All prior Copilot/Cursor threads are addressed. The one substantive item is a documentation-accuracy problem, not a behavioral one.

Issue count

  • 🔴 Blocking: 0
  • 🟡 Minor: 1 — the fresh-client rebuild in meta/client.go is justified by a factually incorrect claim (http.Client "duplicates its internal mutex while sharing the request-cancellation map"). It has no mutex or cancellation map — those are on http.Transport, shared by pointer either way. The same wrong claim is now in the KB concept doc and log.md. go vet copylocks passes on the value-copy, confirming it's safe. (inline)
  • Nit: 1 — the fresh-client rebuild enumerates Transport/CheckRedirect/Jar/Timeout; a value-copy (reddit/twitter) would carry any future http.Client field automatically. Trivial robustness trade-off, take-it-or-leave-it.
  • Question: 1 — Meta was changed to build a fresh client while X/Twitter and reddit stay on value-copy (X deferred to #31). Since the value-copy is provably safe, deferring is fine — but the split makes a safe pattern look like a bug fix. Worth reconciling on one enforcement pattern across the three clients (already tracked, so just confirming it's intentional).

Data privacy / security

No exposure introduced. Auth travels in headers, never URLs; error types surface method/path/status, not the signed request URL or tokens; body reads stay bounded by maxResponseBody; test fixtures are obviously synthetic (tok-abc, ck/cs/at/ats, events.example.org).

Decision

Approved with minor comments — the one minor and the parity question are documentation/consistency, not correctness. Nothing blocks merge; addressing the inaccurate http.Client rationale (in code + the two KB docs) before or right after merge would keep the knowledge base trustworthy.

@dealako
dealako merged commit 2ff302c into main Jul 20, 2026
12 checks passed
@dealako
dealako deleted the fix/LFXV2-2641-meta-twitter-nofollow branch July 20, 2026 18:47
mrautela365 added a commit that referenced this pull request Jul 21, 2026
…ment

The Reddit client's NewClient applied its no-follow CheckRedirect to a
WithHTTPClient-supplied client by value-copying it (hc := *c.httpClient). This now
builds a fresh *http.Client carrying only the caller's documented exported fields
(Transport, Jar, Timeout) with CheckRedirect: noFollow.

This is a defensive/clarity change, NOT a race fix: on the repo's Go target
net/http.Client is exactly those four exported fields with no internal
synchronization state, so the old value copy was also correct and go vet's
copylocks analyzer does not flag it. The rebuild depends only on the type's public
API rather than the struct's internal shape (layout-independent) and won't silently
carry over any future unexported field.

- internal/platform/reddit/client.go: fresh-client construction with an accurate
  rationale (no "must not be copied"/race claim); documented the defensive nil guard.
- internal/platform/reddit/client_test.go: assert the client used preserves the
  caller's Transport/Jar/Timeout and the caller is not mutated. The test comment is
  explicit that it checks observable guarantees, not fresh-vs-copy (both yield a
  distinct pointer with the same fields).
- docs/knowledge: reframed the concept doc + log entry as layout-independence and
  scoped the log entry to reddit (meta #30 / twitter #31 are separate open PRs).

Scope: reddit only — it is the sole client on main enforcing no-follow on a
caller-supplied client (merged via PR #27).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Misha Rautela <mrautela@linuxfoundation.org>
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.

3 participants