Skip to content

Commit 3f5db91

Browse files
tonyxiaoclaude
andauthored
feat: proxy-aware transport (--use-env-proxy), backfill CLI, retry improvements (#290)
* fix: improve retry logging, fault-tolerant getAccount, curl trace format Add labels to retry log messages for easier debugging, include error messages in retry output, make getAccount gracefully fall back on failure, and format HTTP trace logs as reproducible curl commands. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Committed-By-Agent: claude * Adding temp backfill command * scripts: add mitmweb intercept env setup and test Sources mitmweb-env.sh to route Node/Bun/curl traffic through mitmweb on localhost:8080, auto-starting it with the internal upstream proxy if needed. mitmweb-env.test.sh verifies all three runtimes in parallel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * source-stripe: assert --use-env-proxy when proxy env vars are set Node's built-in fetch (undici) silently bypasses HTTP_PROXY/HTTPS_PROXY without the --use-env-proxy flag. assertUseEnvProxy() throws at startup if a proxy is configured but the flag is absent, preventing silent proxy bypass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * Revert "source-stripe: assert --use-env-proxy when proxy env vars are set" This reverts commit eb4e061. * scripts: standalone assert-use-env-proxy with tests Fails fast if HTTPS_PROXY/HTTP_PROXY is set without --use-env-proxy, preventing silent proxy bypass in Node's built-in fetch (undici). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * scripts: convert assert-use-env-proxy to TypeScript with bun:test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * ts-cli: add assertUseEnvProxy with unit and subprocess tests Throws when HTTPS_PROXY/HTTP_PROXY is set but --use-env-proxy is absent (Node's built-in fetch silently bypasses the proxy without it). Bun is exempt since it always respects proxy env natively. Subprocess tests spawn real node/bun processes to verify the flag check works end-to-end, not just in unit logic. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * ts-cli: rename proxy -> env-proxy, move test next to source Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * ts-cli: move ndjson and config tests next to source files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * scripts: improve mitmweb-env.sh; add mitmweb test to CI - Auto-install mitmproxy via pip if not found - Auto-detect upstream proxy from http_proxy/https_proxy env vars instead of hardcoding the Stripe egress proxy URL; falls back to direct mode in CI and clean environments - Add mitmweb proxy intercept test step to CI test job (httpbin.org) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * ci: move mitmweb test to its own parallel job Avoids adding latency to the main test job. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * ts-cli: skip bun subprocess test gracefully when bun is not installed Fixes CI failure in the test job where bun isn't available yet. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * scripts: add ws WebSocket test to mitmweb intercept suite Tests that ws connections route through mitmweb via explicit HttpsProxyAgent (ws ignores HTTP_PROXY and --use-env-proxy). CI mitmweb job now installs pnpm deps so ws/https-proxy-agent resolve. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * source-stripe: replace fetchWithProxy with native fetch + tracedFetch Node 24 --use-env-proxy makes undici/fetch respect HTTPS_PROXY natively, so the manual ProxyAgent dispatcher injection in fetchWithProxy is redundant. Replace all call sites with tracedFetch (curl trace logging only, no proxy logic) and remove withFetchProxy/fetchWithProxy entirely. getHttpsProxyAgentForTarget is kept — ws does not respect --use-env-proxy and still needs an explicit HttpsProxyAgent for WebSocket connections. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * fix: restore getAccount retry and update getAccountCreatedTimestamp test - Restore getAccount to use requestWithRetry (e0fb332 unintentionally removed retry by switching to request directly) - Update test that expected getAccount failure to halt backfill: since getAccountCreatedTimestamp now swallows errors (fault-tolerant), backfill proceeds with STRIPE_LAUNCH_TIMESTAMP fallback instead of failing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Committed-By-Agent: claude * fix: add --use-env-proxy to Docker entrypoints and assertUseEnvProxy at startup - Add --use-env-proxy flag to both engine and service Docker ENTRYPOINT so fetch/undici respects HTTPS_PROXY env var in container deployments - Call assertUseEnvProxy() at startup in both CLI entry points so proxy misconfiguration (proxy set without --use-env-proxy) is caught immediately with a clear error rather than silently bypassing the proxy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3bf414e commit 3f5db91

25 files changed

Lines changed: 777 additions & 198 deletions

.github/workflows/ci.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,42 @@ jobs:
158158
if: always()
159159
run: '[ -f /tmp/vitest-skip-warnings.txt ] && cat /tmp/vitest-skip-warnings.txt || true'
160160

161+
# ---------------------------------------------------------------------------
162+
# mitmweb — proxy intercept smoke test (curl, Node, Bun → httpbin.org)
163+
# ---------------------------------------------------------------------------
164+
mitmweb:
165+
name: mitmweb proxy intercept test
166+
runs-on: ubuntu-24.04-arm
167+
168+
steps:
169+
- uses: actions/checkout@v5
170+
171+
- name: Install pnpm
172+
uses: pnpm/action-setup@v5
173+
174+
- name: Set up Node
175+
uses: actions/setup-node@v6
176+
with:
177+
node-version-file: ./.nvmrc
178+
179+
- name: Install Bun
180+
uses: oven-sh/setup-bun@v2
181+
with:
182+
bun-version: latest
183+
184+
- name: Install dependencies
185+
run: pnpm install --frozen-lockfile
186+
187+
- name: Install mitmproxy
188+
run: pip3 install --quiet mitmproxy
189+
190+
- name: Run mitmweb intercept test
191+
run: bash scripts/mitmweb-env.test.sh
192+
193+
- name: Stop mitmweb
194+
if: always()
195+
run: pkill -f mitmweb || true
196+
161197
# ---------------------------------------------------------------------------
162198
# Publish npm — publish @stripe/* packages to GitHub Packages
163199
# ---------------------------------------------------------------------------

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ ENV NODE_ENV=production
4949
ENV GIT_COMMIT=$GIT_COMMIT
5050
ENV BUILD_DATE=$BUILD_DATE
5151
ENV COMMIT_URL=$COMMIT_URL
52-
ENTRYPOINT ["node", "dist/cli/index.js"]
52+
ENTRYPOINT ["node", "--use-env-proxy", "dist/cli/index.js"]
5353
CMD ["serve"]
5454

5555
# ===========================================================================
@@ -88,5 +88,5 @@ ENV NODE_ENV=production
8888
ENV GIT_COMMIT=$GIT_COMMIT
8989
ENV BUILD_DATE=$BUILD_DATE
9090
ENV COMMIT_URL=$COMMIT_URL
91-
ENTRYPOINT ["node", "dist/bin/sync-service.js"]
91+
ENTRYPOINT ["node", "--use-env-proxy", "dist/bin/sync-service.js"]
9292
CMD ["serve", "--temporal-address", "temporal:7233", "--temporal-task-queue", "sync-engine"]

apps/engine/src/cli/backfill.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { access, readFile, writeFile } from 'node:fs/promises'
2+
import { defineCommand } from 'citty'
3+
import { parseJsonOrFile } from '@stripe/sync-ts-cli'
4+
import {
5+
type PipelineConfig,
6+
PipelineConfig as PipelineConfigSchema,
7+
coerceSyncState,
8+
createRemoteEngine,
9+
} from '../index.js'
10+
import { pipelineSyncUntilComplete } from '../lib/backfill.js'
11+
12+
async function readState(path?: string) {
13+
if (!path) return undefined
14+
try {
15+
await access(path)
16+
} catch {
17+
return undefined
18+
}
19+
return coerceSyncState(JSON.parse(await readFile(path, 'utf8')))
20+
}
21+
22+
async function writeState(path: string | undefined, state: unknown) {
23+
if (!path) return
24+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
25+
}
26+
27+
export const backfillCmd = defineCommand({
28+
meta: {
29+
name: 'backfill',
30+
description: 'Call a remote sync engine until pipeline_sync reaches eof:complete',
31+
},
32+
args: {
33+
syncEngineUrl: {
34+
type: 'string',
35+
description: 'Remote sync engine base URL (or SYNC_ENGINE_URL env)',
36+
},
37+
pipeline: {
38+
type: 'string',
39+
description: 'Pipeline config as inline JSON or a JSON file path',
40+
},
41+
statePath: {
42+
type: 'string',
43+
description: 'Optional JSON file to load/save SyncState between attempts',
44+
},
45+
stateLimit: {
46+
type: 'string',
47+
default: '100',
48+
description: 'Per-call state_limit passed to pipeline_sync (default: 100)',
49+
},
50+
timeLimit: {
51+
type: 'string',
52+
default: '10',
53+
description: 'Per-call time_limit in seconds passed to pipeline_sync (default: 10)',
54+
},
55+
},
56+
async run({ args }) {
57+
const syncEngineUrl = args.syncEngineUrl || process.env.SYNC_ENGINE_URL
58+
if (!syncEngineUrl) throw new Error('Missing --sync-engine-url or SYNC_ENGINE_URL env')
59+
if (!args.pipeline) throw new Error('Missing --pipeline')
60+
61+
const pipeline = PipelineConfigSchema.parse(parseJsonOrFile(args.pipeline)) as PipelineConfig
62+
const state = await readState(args.statePath)
63+
const stateLimit = parseInt(args.stateLimit, 10)
64+
const timeLimit = parseInt(args.timeLimit, 10)
65+
66+
if (!Number.isInteger(stateLimit) || stateLimit <= 0) {
67+
throw new Error('--state-limit must be a positive integer')
68+
}
69+
if (!Number.isInteger(timeLimit) || timeLimit <= 0) {
70+
throw new Error('--time-limit must be a positive integer')
71+
}
72+
73+
const engine = createRemoteEngine(syncEngineUrl)
74+
const result = await pipelineSyncUntilComplete(engine, pipeline, {
75+
state,
76+
state_limit: stateLimit,
77+
time_limit: timeLimit,
78+
onAttempt: (attempt, currentState) => {
79+
console.error(
80+
JSON.stringify({
81+
event: 'pipeline_sync_attempt_started',
82+
attempt,
83+
state_provided: currentState != null,
84+
})
85+
)
86+
},
87+
onMessage: (message, attempt) => {
88+
process.stdout.write(`${JSON.stringify(message)}\n`)
89+
if (message.type === 'eof') {
90+
console.error(
91+
JSON.stringify({
92+
event: 'pipeline_sync_attempt_finished',
93+
attempt,
94+
eof_reason: message.eof.reason,
95+
})
96+
)
97+
}
98+
},
99+
onState: async (nextState) => {
100+
await writeState(args.statePath, nextState)
101+
},
102+
})
103+
104+
await writeState(args.statePath, result.state)
105+
console.error(
106+
JSON.stringify({
107+
event: 'pipeline_sync_complete',
108+
attempts: result.attempts,
109+
eof_reason: result.eof.reason,
110+
state_path: args.statePath ?? null,
111+
})
112+
)
113+
},
114+
})

apps/engine/src/cli/command.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { parseJsonOrFile } from '@stripe/sync-ts-cli'
66
import { createConnectorResolver, createEngine } from '../lib/index.js'
77
import { createApp } from '../api/app.js'
88
import { serveAction } from '../serve-command.js'
9+
import { backfillCmd } from './backfill.js'
910
import { supabaseCmd } from './supabase.js'
1011
import { createSyncCmd } from './sync.js'
1112
import { defaultConnectors } from '../lib/default-connectors.js'
@@ -100,6 +101,7 @@ export async function createProgram() {
100101
return defineCommand({
101102
...specCli,
102103
subCommands: {
104+
backfill: backfillCmd,
103105
serve: serveCmd,
104106
supabase: supabaseCmd,
105107
sync: createSyncCmd(engine, resolver),

apps/engine/src/cli/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#!/usr/bin/env node
2+
import { assertUseEnvProxy } from '@stripe/sync-ts-cli/env-proxy'
23
import { runMain } from 'citty'
34
import { createProgram } from './command.js'
45

6+
assertUseEnvProxy()
7+
58
const program = await createProgram()
69
runMain(program)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import type { Engine } from './engine.js'
3+
import type { PipelineConfig, SyncOutput } from '@stripe/sync-protocol'
4+
import { pipelineSyncUntilComplete } from './backfill.js'
5+
6+
const pipeline: PipelineConfig = {
7+
source: { type: 'test', test: {} },
8+
destination: { type: 'test', test: {} },
9+
}
10+
11+
async function* toAsync<T>(items: T[]): AsyncIterable<T> {
12+
for (const item of items) yield item
13+
}
14+
15+
describe('pipelineSyncUntilComplete', () => {
16+
it('retries until eof complete and carries forward state', async () => {
17+
const calls: Array<unknown> = []
18+
const onState = vi.fn()
19+
const engine: Engine = {
20+
meta_sources_list: vi.fn(),
21+
meta_sources_get: vi.fn(),
22+
meta_destinations_list: vi.fn(),
23+
meta_destinations_get: vi.fn(),
24+
source_discover: vi.fn(),
25+
pipeline_check: vi.fn(),
26+
pipeline_setup: vi.fn(),
27+
pipeline_teardown: vi.fn(),
28+
pipeline_read: vi.fn(),
29+
pipeline_write: vi.fn(),
30+
pipeline_sync: vi.fn((_pipeline, opts) => {
31+
calls.push(opts?.state)
32+
const outputs: SyncOutput[] =
33+
calls.length === 1
34+
? [
35+
{
36+
type: 'source_state',
37+
source_state: { stream: 'customers', data: { cursor: 'cus_1' } },
38+
},
39+
{
40+
type: 'eof',
41+
eof: {
42+
reason: 'state_limit',
43+
state: {
44+
source: { streams: { customers: { cursor: 'cus_1' } }, global: {} },
45+
destination: { streams: {}, global: {} },
46+
engine: { streams: {}, global: {} },
47+
},
48+
},
49+
},
50+
]
51+
: [{ type: 'eof', eof: { reason: 'complete' } }]
52+
return toAsync(outputs)
53+
}),
54+
} as unknown as Engine
55+
56+
const result = await pipelineSyncUntilComplete(engine, pipeline, { state_limit: 1, onState })
57+
58+
expect(calls).toEqual([
59+
undefined,
60+
{
61+
source: { streams: { customers: { cursor: 'cus_1' } }, global: {} },
62+
destination: { streams: {}, global: {} },
63+
engine: { streams: {}, global: {} },
64+
},
65+
])
66+
expect(result.attempts).toBe(2)
67+
expect(result.eof.reason).toBe('complete')
68+
expect(onState).toHaveBeenLastCalledWith({
69+
source: { streams: { customers: { cursor: 'cus_1' } }, global: {} },
70+
destination: { streams: {}, global: {} },
71+
engine: { streams: {}, global: {} },
72+
}, 1)
73+
})
74+
75+
it('throws when pipeline_sync ends with an unexpected eof reason', async () => {
76+
const engine: Engine = {
77+
meta_sources_list: vi.fn(),
78+
meta_sources_get: vi.fn(),
79+
meta_destinations_list: vi.fn(),
80+
meta_destinations_get: vi.fn(),
81+
source_discover: vi.fn(),
82+
pipeline_check: vi.fn(),
83+
pipeline_setup: vi.fn(),
84+
pipeline_teardown: vi.fn(),
85+
pipeline_read: vi.fn(),
86+
pipeline_write: vi.fn(),
87+
pipeline_sync: vi.fn(() => toAsync<SyncOutput>([{ type: 'eof', eof: { reason: 'aborted' } }])),
88+
} as unknown as Engine
89+
90+
await expect(pipelineSyncUntilComplete(engine, pipeline)).rejects.toThrow(
91+
/unexpected eof reason: aborted/
92+
)
93+
})
94+
})

apps/engine/src/lib/backfill.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { EofPayload, PipelineConfig, SourceStateMessage } from '@stripe/sync-protocol'
2+
import { emptySyncState, type SyncOutput, type SyncState } from '@stripe/sync-protocol'
3+
import type { Engine, SourceReadOptions } from './engine.js'
4+
5+
export interface PipelineSyncUntilCompleteOptions
6+
extends Omit<SourceReadOptions, 'state'> {
7+
state?: SyncState
8+
onAttempt?: (attempt: number, state: SyncState | undefined) => void | Promise<void>
9+
onMessage?: (message: SyncOutput, attempt: number) => void | Promise<void>
10+
onState?: (state: SyncState, attempt: number) => void | Promise<void>
11+
}
12+
13+
export interface PipelineSyncUntilCompleteResult {
14+
attempts: number
15+
state: SyncState
16+
eof: EofPayload
17+
}
18+
19+
function mergeStateMessage(state: SyncState | undefined, msg: SourceStateMessage): SyncState {
20+
const next = structuredClone(state ?? emptySyncState())
21+
if (msg.source_state.state_type === 'global') {
22+
next.source.global = msg.source_state.data as Record<string, unknown>
23+
return next
24+
}
25+
next.source.streams[msg.source_state.stream] = msg.source_state.data
26+
return next
27+
}
28+
29+
export async function pipelineSyncUntilComplete(
30+
engine: Engine,
31+
pipeline: PipelineConfig,
32+
opts: PipelineSyncUntilCompleteOptions = {}
33+
): Promise<PipelineSyncUntilCompleteResult> {
34+
const { state: initialState, onAttempt, onMessage, onState, ...readOpts } = opts
35+
let state = initialState
36+
let attempts = 0
37+
38+
while (true) {
39+
attempts += 1
40+
await onAttempt?.(attempts, state)
41+
42+
let eof: EofPayload | undefined
43+
for await (const message of engine.pipeline_sync(pipeline, { ...readOpts, state })) {
44+
await onMessage?.(message, attempts)
45+
46+
if (message.type === 'source_state') {
47+
state = mergeStateMessage(state, message)
48+
await onState?.(state, attempts)
49+
}
50+
51+
if (message.type === 'eof') {
52+
eof = message.eof
53+
if (message.eof.state) {
54+
state = message.eof.state
55+
await onState?.(state, attempts)
56+
}
57+
}
58+
}
59+
60+
if (!eof) {
61+
throw new Error(`pipeline_sync attempt ${attempts} ended without eof`)
62+
}
63+
64+
if (eof.reason === 'complete') {
65+
return { attempts, state: state ?? emptySyncState(), eof }
66+
}
67+
68+
if (eof.reason !== 'state_limit' && eof.reason !== 'time_limit') {
69+
throw new Error(`pipeline_sync attempt ${attempts} ended with unexpected eof reason: ${eof.reason}`)
70+
}
71+
}
72+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
#!/usr/bin/env node
22
import 'dotenv/config'
3+
import { assertUseEnvProxy } from '@stripe/sync-ts-cli/env-proxy'
34
import { runMain } from 'citty'
45
import { createProgram } from '../cli.js'
56

7+
assertUseEnvProxy()
8+
69
const program = await createProgram()
710
runMain(program)

0 commit comments

Comments
 (0)