From 006029aad1ca77b20a9e3829ce8c7109340d4895 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sat, 11 Apr 2026 21:29:57 +0400 Subject: [PATCH 1/8] feat: add captcha solving template funcs for 2Captcha, Anti-Captcha, and CapSolver --- internal/sarin/captcha.go | 271 +++++++++++++++++++++++++++++++++++++ internal/sarin/helpers.go | 7 + internal/sarin/template.go | 52 ++++++- internal/types/errors.go | 62 +++++++++ 4 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 internal/sarin/captcha.go diff --git a/internal/sarin/captcha.go b/internal/sarin/captcha.go new file mode 100644 index 0000000..fc9e49c --- /dev/null +++ b/internal/sarin/captcha.go @@ -0,0 +1,271 @@ +package sarin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "go.aykhans.me/sarin/internal/types" +) + +const ( + captchaPollInterval = 5 * time.Second + captchaTimeout = 120 * time.Second +) + +var captchaHTTPClient = &http.Client{Timeout: captchaTimeout} + +// solveCaptcha creates a task and polls for the result. +// baseURL is the service API base (e.g. "https://api.2captcha.com"). +// taskIDIsString controls whether taskId is sent back as a string or number. +// solutionKey is the field name in the solution object that holds the token. +func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey string, taskIDIsString bool) (string, error) { + if apiKey == "" { + return "", types.ErrCaptchaKeyEmpty + } + + taskID, err := captchaCreateTask(baseURL, apiKey, task) + if err != nil { + return "", err + } + return captchaPollResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString) +} + +func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, error) { + body := map[string]any{ + "clientKey": apiKey, + "task": task, + } + + data, err := json.Marshal(body) + if err != nil { + return "", types.NewCaptchaRequestError("createTask", err) + } + + resp, err := captchaHTTPClient.Post( + baseURL+"/createTask", + "application/json", + bytes.NewReader(data), + ) + if err != nil { + return "", types.NewCaptchaRequestError("createTask", err) + } + defer resp.Body.Close() //nolint:errcheck + + var result struct { + ErrorID int `json:"errorId"` + ErrorCode string `json:"errorCode"` + ErrorDescription string `json:"errorDescription"` + TaskID json.RawMessage `json:"taskId"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", types.NewCaptchaRequestError("createTask", err) + } + + if result.ErrorID != 0 { + return "", types.NewCaptchaAPIError("createTask", result.ErrorCode, result.ErrorDescription) + } + + // taskId may be a JSON number (2captcha, anti-captcha) or a quoted string (capsolver UUIDs). + // Strip surrounding quotes if present so we always work with the underlying value. + return strings.Trim(string(result.TaskID), `"`), nil +} + +func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), captchaTimeout) + defer cancel() + + ticker := time.NewTicker(captchaPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return "", types.NewCaptchaTimeoutError(taskID) + case <-ticker.C: + token, done, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString) + if err != nil { + return "", err + } + if done { + return token, nil + } + } + } +} + +func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, bool, error) { + var bodyMap map[string]any + if taskIDIsString { + bodyMap = map[string]any{"clientKey": apiKey, "taskId": taskID} + } else { + bodyMap = map[string]any{"clientKey": apiKey, "taskId": json.Number(taskID)} + } + + data, err := json.Marshal(bodyMap) + if err != nil { + return "", false, types.NewCaptchaRequestError("getTaskResult", err) + } + + resp, err := captchaHTTPClient.Post( + baseURL+"/getTaskResult", + "application/json", + bytes.NewReader(data), + ) + if err != nil { + return "", false, types.NewCaptchaRequestError("getTaskResult", err) + } + defer resp.Body.Close() //nolint:errcheck + + var result struct { + ErrorID int `json:"errorId"` + ErrorCode string `json:"errorCode"` + ErrorDescription string `json:"errorDescription"` + Status string `json:"status"` + Solution map[string]any `json:"solution"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", false, types.NewCaptchaRequestError("getTaskResult", err) + } + + if result.ErrorID != 0 { + return "", false, types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription) + } + + if result.Status == "processing" || result.Status == "idle" { + return "", false, nil + } + + token, ok := result.Solution[solutionKey] + if !ok { + return "", false, types.NewCaptchaSolutionKeyError(solutionKey) + } + tokenStr, ok := token.(string) + if !ok { + return "", false, types.NewCaptchaSolutionKeyError(solutionKey) + } + + return tokenStr, true, nil +} + +// ======================================== 2Captcha ======================================== + +const twoCaptchaBaseURL = "https://api.2captcha.com" + +func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { + return solveCaptcha(twoCaptchaBaseURL, apiKey, map[string]any{ + "type": "RecaptchaV2TaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + }, "gRecaptchaResponse", false) +} + +func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { + task := map[string]any{ + "type": "RecaptchaV3TaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + if pageAction != "" { + task["pageAction"] = pageAction + } + return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false) +} + +func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { + task := map[string]any{ + "type": "TurnstileTaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + if cData != "" { + task["data"] = cData + } + return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "token", false) +} + +// ======================================== Anti-Captcha ======================================== + +const antiCaptchaBaseURL = "https://api.anti-captcha.com" + +func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { + return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{ + "type": "RecaptchaV2TaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + }, "gRecaptchaResponse", false) +} + +func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { + // Anti-Captcha requires minScore for reCAPTCHA v3. 0.3 is the loosest threshold. + task := map[string]any{ + "type": "RecaptchaV3TaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + "minScore": 0.3, + } + if pageAction != "" { + task["pageAction"] = pageAction + } + return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false) +} + +func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, error) { + // Anti-Captcha returns hCaptcha tokens under "gRecaptchaResponse" (not "token"). + return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{ + "type": "HCaptchaTaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + }, "gRecaptchaResponse", false) +} + +func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { + task := map[string]any{ + "type": "TurnstileTaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + if cData != "" { + task["cData"] = cData + } + return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "token", false) +} + +// ======================================== CapSolver ======================================== + +const capSolverBaseURL = "https://api.capsolver.com" + +func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { + return solveCaptcha(capSolverBaseURL, apiKey, map[string]any{ + "type": "ReCaptchaV2TaskProxyLess", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + }, "gRecaptchaResponse", true) +} + +func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { + task := map[string]any{ + "type": "ReCaptchaV3TaskProxyLess", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + if pageAction != "" { + task["pageAction"] = pageAction + } + return solveCaptcha(capSolverBaseURL, apiKey, task, "gRecaptchaResponse", true) +} + +func capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { + task := map[string]any{ + "type": "AntiTurnstileTaskProxyLess", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + if cData != "" { + task["metadata"] = map[string]any{"cdata": cData} + } + return solveCaptcha(capSolverBaseURL, apiKey, task, "token", true) +} diff --git a/internal/sarin/helpers.go b/internal/sarin/helpers.go index dab4942..6e70365 100644 --- a/internal/sarin/helpers.go +++ b/internal/sarin/helpers.go @@ -12,3 +12,10 @@ func NewDefaultRandSource() rand.Source { uint64(now>>32), ) } + +func firstOrEmpty(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} diff --git a/internal/sarin/template.go b/internal/sarin/template.go index 917ff92..c74ca8b 100644 --- a/internal/sarin/template.go +++ b/internal/sarin/template.go @@ -574,8 +574,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem "fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() }, "fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() }, "fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() }, - // "fakeit_ErrorInput": func() string { return fakeit.ErrorInput().Error() }, - "fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() }, + "fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() }, // Fakeit / School "fakeit_School": fakeit.School, @@ -585,6 +584,55 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem "fakeit_SongName": fakeit.SongName, "fakeit_SongArtist": fakeit.SongArtist, "fakeit_SongGenre": fakeit.SongGenre, + + // Captcha / 2Captcha + // Usage: {{ twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }} + "twocaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) { + return twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey) + }, + // Usage: {{ twocaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }} + "twocaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) { + return twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction) + }, + // Usage: {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }} + // {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }} + "twocaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) { + return twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData)) + }, + + // Captcha / Anti-Captcha + // Usage: {{ anticaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }} + "anticaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) { + return antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey) + }, + // Usage: {{ anticaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }} + "anticaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) { + return antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction) + }, + // Usage: {{ anticaptcha_HCaptcha "API_KEY" "SITE_KEY" "https://example.com" }} + "anticaptcha_HCaptcha": func(apiKey, websiteKey, websiteURL string) (string, error) { + return antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey) + }, + // Usage: {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }} + // {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }} + "anticaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) { + return antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData)) + }, + + // Captcha / CapSolver + // Usage: {{ capsolver_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }} + "capsolver_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) { + return capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey) + }, + // Usage: {{ capsolver_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }} + "capsolver_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) { + return capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction) + }, + // Usage: {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }} + // {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }} + "capsolver_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) { + return capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData)) + }, } } diff --git a/internal/types/errors.go b/internal/types/errors.go index 6bc6a76..aed797d 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -442,3 +442,65 @@ func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError { func (e ScriptUnknownEngineError) Error() string { return "unknown engine type: " + e.EngineType } + +// ======================================== Captcha ======================================== + +var ErrCaptchaKeyEmpty = errors.New("captcha API key cannot be empty") + +type CaptchaAPIError struct { + Endpoint string + Code string + Description string +} + +func NewCaptchaAPIError(endpoint, code, description string) CaptchaAPIError { + return CaptchaAPIError{Endpoint: endpoint, Code: code, Description: description} +} + +func (e CaptchaAPIError) Error() string { + return fmt.Sprintf("captcha %s error: %s (%s)", e.Endpoint, e.Code, e.Description) +} + +type CaptchaRequestError struct { + Endpoint string + Err error +} + +func NewCaptchaRequestError(endpoint string, err error) CaptchaRequestError { + if err == nil { + err = errNoError + } + return CaptchaRequestError{Endpoint: endpoint, Err: err} +} + +func (e CaptchaRequestError) Error() string { + return fmt.Sprintf("captcha %s request failed: %v", e.Endpoint, e.Err) +} + +func (e CaptchaRequestError) Unwrap() error { + return e.Err +} + +type CaptchaTimeoutError struct { + TaskID string +} + +func NewCaptchaTimeoutError(taskID string) CaptchaTimeoutError { + return CaptchaTimeoutError{TaskID: taskID} +} + +func (e CaptchaTimeoutError) Error() string { + return fmt.Sprintf("captcha solving timed out (taskId: %s)", e.TaskID) +} + +type CaptchaSolutionKeyError struct { + Key string +} + +func NewCaptchaSolutionKeyError(key string) CaptchaSolutionKeyError { + return CaptchaSolutionKeyError{Key: key} +} + +func (e CaptchaSolutionKeyError) Error() string { + return fmt.Sprintf("captcha solution missing expected key %q", e.Key) +} From 1bd58a02b7203b54b8058e6d9bfb55378747f4b1 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 12 Apr 2026 01:10:20 +0400 Subject: [PATCH 2/8] refactor: tighten captcha poll loop and document solver funcs --- internal/sarin/captcha.go | 163 +++++++++++++++++++++++++++++++++----- internal/types/errors.go | 8 +- 2 files changed, 151 insertions(+), 20 deletions(-) diff --git a/internal/sarin/captcha.go b/internal/sarin/captcha.go index fc9e49c..8159bf0 100644 --- a/internal/sarin/captcha.go +++ b/internal/sarin/captcha.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "strings" "time" @@ -12,16 +13,27 @@ import ( ) const ( - captchaPollInterval = 5 * time.Second + captchaPollInterval = 1 * time.Second captchaTimeout = 120 * time.Second ) var captchaHTTPClient = &http.Client{Timeout: captchaTimeout} -// solveCaptcha creates a task and polls for the result. +// solveCaptcha creates a task on the given captcha service and polls until it is solved, +// returning the extracted token from the solution object. +// // baseURL is the service API base (e.g. "https://api.2captcha.com"). -// taskIDIsString controls whether taskId is sent back as a string or number. +// task is the task payload the service expects (type + service-specific fields). // solutionKey is the field name in the solution object that holds the token. +// taskIDIsString controls whether taskId is sent back as a string (CapSolver UUIDs) +// or a JSON number (2Captcha, Anti-Captcha). +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey string, taskIDIsString bool) (string, error) { if apiKey == "" { return "", types.ErrCaptchaKeyEmpty @@ -34,6 +46,13 @@ func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey strin return captchaPollResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString) } +// captchaCreateTask submits a task to the captcha service and returns the assigned taskId. +// The taskId is normalized to a string: numeric IDs are preserved via json.RawMessage, +// and quoted string IDs (CapSolver UUIDs) have their surrounding quotes stripped. +// +// It can return the following errors: +// - types.CaptchaRequestError +// - types.CaptchaAPIError func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, error) { body := map[string]any{ "clientKey": apiKey, @@ -71,9 +90,21 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err // taskId may be a JSON number (2captcha, anti-captcha) or a quoted string (capsolver UUIDs). // Strip surrounding quotes if present so we always work with the underlying value. - return strings.Trim(string(result.TaskID), `"`), nil + taskID := strings.Trim(string(result.TaskID), `"`) + if taskID == "" { + return "", types.NewCaptchaAPIError("createTask", "EMPTY_TASK_ID", "service returned a successful response with no taskId") + } + return taskID, nil } +// captchaPollResult polls the getTaskResult endpoint at captchaPollInterval until the task +// is solved, an error is returned by the service, or the overall captchaTimeout is hit. +// +// It can return the following errors: +// - types.CaptchaTimeoutError +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaSolutionKeyError func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), captchaTimeout) defer cancel() @@ -86,18 +117,26 @@ func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsStri case <-ctx.Done(): return "", types.NewCaptchaTimeoutError(taskID) case <-ticker.C: - token, done, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString) + token, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString) + if errors.Is(err, types.ErrCaptchaProcessing) { + continue + } if err != nil { return "", err } - if done { - return token, nil - } + return token, nil } } } -func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, bool, error) { +// captchaGetTaskResult fetches a single task result from the captcha service. +// +// It can return the following errors: +// - types.ErrCaptchaProcessing +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaSolutionKeyError +func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) { var bodyMap map[string]any if taskIDIsString { bodyMap = map[string]any{"clientKey": apiKey, "taskId": taskID} @@ -107,7 +146,7 @@ func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsS data, err := json.Marshal(bodyMap) if err != nil { - return "", false, types.NewCaptchaRequestError("getTaskResult", err) + return "", types.NewCaptchaRequestError("getTaskResult", err) } resp, err := captchaHTTPClient.Post( @@ -116,7 +155,7 @@ func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsS bytes.NewReader(data), ) if err != nil { - return "", false, types.NewCaptchaRequestError("getTaskResult", err) + return "", types.NewCaptchaRequestError("getTaskResult", err) } defer resp.Body.Close() //nolint:errcheck @@ -128,33 +167,41 @@ func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsS Solution map[string]any `json:"solution"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", false, types.NewCaptchaRequestError("getTaskResult", err) + return "", types.NewCaptchaRequestError("getTaskResult", err) } if result.ErrorID != 0 { - return "", false, types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription) + return "", types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription) } if result.Status == "processing" || result.Status == "idle" { - return "", false, nil + return "", types.ErrCaptchaProcessing } token, ok := result.Solution[solutionKey] if !ok { - return "", false, types.NewCaptchaSolutionKeyError(solutionKey) + return "", types.NewCaptchaSolutionKeyError(solutionKey) } tokenStr, ok := token.(string) if !ok { - return "", false, types.NewCaptchaSolutionKeyError(solutionKey) + return "", types.NewCaptchaSolutionKeyError(solutionKey) } - return tokenStr, true, nil + return tokenStr, nil } // ======================================== 2Captcha ======================================== const twoCaptchaBaseURL = "https://api.2captcha.com" +// twoCaptchaSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via 2Captcha. +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { return solveCaptcha(twoCaptchaBaseURL, apiKey, map[string]any{ "type": "RecaptchaV2TaskProxyless", @@ -163,6 +210,15 @@ func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, }, "gRecaptchaResponse", false) } +// twoCaptchaSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via 2Captcha. +// pageAction may be empty. +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { task := map[string]any{ "type": "RecaptchaV3TaskProxyless", @@ -175,6 +231,15 @@ func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction strin return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false) } +// twoCaptchaSolveTurnstile solves a Cloudflare Turnstile challenge via 2Captcha. +// cData may be empty. +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { task := map[string]any{ "type": "TurnstileTaskProxyless", @@ -191,6 +256,14 @@ func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (str const antiCaptchaBaseURL = "https://api.anti-captcha.com" +// antiCaptchaSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via Anti-Captcha. +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{ "type": "RecaptchaV2TaskProxyless", @@ -199,8 +272,17 @@ func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, }, "gRecaptchaResponse", false) } +// antiCaptchaSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via Anti-Captcha. +// pageAction may be empty. minScore is hardcoded to 0.3 (the loosest threshold) because +// Anti-Captcha rejects the request without it. +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { - // Anti-Captcha requires minScore for reCAPTCHA v3. 0.3 is the loosest threshold. task := map[string]any{ "type": "RecaptchaV3TaskProxyless", "websiteURL": websiteURL, @@ -213,8 +295,16 @@ func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction stri return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false) } +// antiCaptchaSolveHCaptcha solves an hCaptcha challenge via Anti-Captcha. +// Anti-Captcha returns hCaptcha tokens under "gRecaptchaResponse" (not "token"). +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, error) { - // Anti-Captcha returns hCaptcha tokens under "gRecaptchaResponse" (not "token"). return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{ "type": "HCaptchaTaskProxyless", "websiteURL": websiteURL, @@ -222,6 +312,15 @@ func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, er }, "gRecaptchaResponse", false) } +// antiCaptchaSolveTurnstile solves a Cloudflare Turnstile challenge via Anti-Captcha. +// cData may be empty. +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { task := map[string]any{ "type": "TurnstileTaskProxyless", @@ -238,6 +337,14 @@ func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (st const capSolverBaseURL = "https://api.capsolver.com" +// capSolverSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via CapSolver. +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { return solveCaptcha(capSolverBaseURL, apiKey, map[string]any{ "type": "ReCaptchaV2TaskProxyLess", @@ -246,6 +353,15 @@ func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, e }, "gRecaptchaResponse", true) } +// capSolverSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via CapSolver. +// pageAction may be empty. +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { task := map[string]any{ "type": "ReCaptchaV3TaskProxyLess", @@ -258,6 +374,15 @@ func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string return solveCaptcha(capSolverBaseURL, apiKey, task, "gRecaptchaResponse", true) } +// capSolverSolveTurnstile solves a Cloudflare Turnstile challenge via CapSolver. +// cData may be empty. CapSolver nests cData under a "metadata" object. +// +// It can return the following errors: +// - types.ErrCaptchaKeyEmpty +// - types.CaptchaRequestError +// - types.CaptchaAPIError +// - types.CaptchaTimeoutError +// - types.CaptchaSolutionKeyError func capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { task := map[string]any{ "type": "AntiTurnstileTaskProxyLess", diff --git a/internal/types/errors.go b/internal/types/errors.go index aed797d..98903f2 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -445,7 +445,13 @@ func (e ScriptUnknownEngineError) Error() string { // ======================================== Captcha ======================================== -var ErrCaptchaKeyEmpty = errors.New("captcha API key cannot be empty") +var ( + ErrCaptchaKeyEmpty = errors.New("captcha API key cannot be empty") + // ErrCaptchaProcessing is an internal sentinel returned by the captcha solver polling + // code to signal that a task is not yet solved and polling should continue. + // It should never be surfaced to callers outside of the captcha poll loop. + ErrCaptchaProcessing = errors.New("captcha task still processing") +) type CaptchaAPIError struct { Endpoint string From 16b0081d3e698472b6b18515835596aa233aef99 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 12 Apr 2026 01:34:28 +0400 Subject: [PATCH 3/8] docs: document captcha solving template functions in README and guides --- README.md | 3 +- docs/examples.md | 23 +++++++++++ docs/templating.md | 95 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fd85ba1..e9360db 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ Sarin is designed for efficient HTTP load testing with minimal resource consumpt | Dynamic requests via 320+ template functions | Web UI or complex TUI | | Request scripting with Lua and JavaScript | Distributed load testing | | Multiple proxy protocols
(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC | -| Flexible config (CLI, ENV, YAML) | Plugins / extensions ecosystem | +| Captcha solving
(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem | +| Flexible config (CLI, ENV, YAML) | | ## Installation diff --git a/docs/examples.md b/docs/examples.md index 14b59ad..615e726 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -8,6 +8,7 @@ This guide provides practical examples for common Sarin use cases. - [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests) - [Headers, Cookies, and Parameters](#headers-cookies-and-parameters) - [Dynamic Requests with Templating](#dynamic-requests-with-templating) +- [Solving Captchas](#solving-captchas) - [Request Bodies](#request-bodies) - [File Uploads](#file-uploads) - [Using Proxies](#using-proxies) @@ -373,6 +374,28 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act > For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**. +## Solving Captchas + +Sarin can solve captchas through third-party services and embed the resulting token into the request. Three services are supported via dedicated template functions: **2Captcha**, **Anti-Captcha**, and **CapSolver**. + +**Solve a reCAPTCHA v2 and submit the token in the request body:** + +```sh +sarin -U https://example.com/login -M POST -r 1 \ + -B '{"g-recaptcha-response": "{{ twocaptcha_RecaptchaV2 "YOUR_API_KEY" "SITE_KEY" "https://example.com/login" }}"}' +``` + +**Reuse a single solved token across multiple requests via `values`:** + +```sh +sarin -U https://example.com/api -M POST -r 5 \ + -V 'TOKEN={{ anticaptcha_Turnstile "YOUR_API_KEY" "SITE_KEY" "https://example.com/api" }}' \ + -H "X-Turnstile-Token: {{ .Values.TOKEN }}" \ + -B '{"token": "{{ .Values.TOKEN }}"}' +``` + +> See the **[Templating Guide](templating.md#captcha-functions)** for the full list of captcha functions and per-service support. + ## Request Bodies **Simple JSON body:** diff --git a/docs/templating.md b/docs/templating.md index a3156a2..cd7c6da 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -4,6 +4,8 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook > **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates. +> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take. + ## Table of Contents - [Using Values](#using-values) @@ -14,6 +16,10 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook - [Crypto Functions](#crypto-functions) - [Body Functions](#body-functions) - [File Functions](#file-functions) +- [Captcha Functions](#captcha-functions) + - [2Captcha](#2captcha) + - [Anti-Captcha](#anti-captcha) + - [CapSolver](#capsolver) - [Fake Data Functions](#fake-data-functions) - [File](#file) - [ID](#id) @@ -196,6 +202,95 @@ values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}" body: '{"data": "{{ .Values.FILE_DATA }}"}' ``` +## Captcha Functions + +Captcha functions solve a captcha challenge through a third-party solving service and return the resulting token, which can then be embedded directly into a request. They are intended for load testing endpoints protected by reCAPTCHA, hCaptcha, or Cloudflare Turnstile. + +The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required — bring your own key and use any of the supported services per template. + +> **Important — performance and cost:** +> +> - **Each call is slow.** Solving typically takes ~5–60 seconds because the function blocks the template render until the third-party service returns a token. Internally the solver polls every 1s and gives up after 120s. +> - **Each call costs money.** Every successful solve is billed by the captcha service (typically $0.001–$0.003 per solve). For high-volume tests, your captcha bill grows linearly with request count. + +**Common parameters across all captcha functions:** + +- `apiKey` - Your API key for the chosen captcha solving service +- `siteKey` - The captcha sitekey extracted from the target page (e.g. the `data-sitekey` attribute on a reCAPTCHA, hCaptcha, or Turnstile element) +- `pageURL` - The URL of the page where the captcha is hosted + +### 2Captcha + +Functions for the [2Captcha](https://2captcha.com) service. Note: 2Captcha **does not currently support hCaptcha** through their API. + +| Function | Description | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| `twocaptcha_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge | +| `twocaptcha_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. Pass `""` for `pageAction` to omit | +| `twocaptcha_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument | + +### Anti-Captcha + +Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is currently the only service that supports all four captcha types end-to-end. + +| Function | Description | +| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `anticaptcha_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge | +| `anticaptcha_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. `minScore` is hardcoded to `0.3` (Anti-Captcha rejects the request without it) | +| `anticaptcha_HCaptcha(apiKey, siteKey, pageURL string)` | Solve an hCaptcha challenge | +| `anticaptcha_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument | + +### CapSolver + +Functions for the [CapSolver](https://capsolver.com) service. Note: CapSolver no longer supports hCaptcha. + +| Function | Description | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `capsolver_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge | +| `capsolver_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. Pass `""` for `pageAction` to omit | +| `capsolver_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument | + +**Examples:** + +```yaml +# reCAPTCHA v2 in a JSON body via 2Captcha +method: POST +url: https://example.com/login +body: | + { + "username": "test", + "g-recaptcha-response": "{{ twocaptcha_RecaptchaV2 "YOUR_API_KEY" "6LfD3PIb..." "https://example.com/login" }}" + } +``` + +```yaml +# Turnstile via Anti-Captcha with cData +method: POST +url: https://example.com/submit +body: | + { + "cf-turnstile-response": "{{ anticaptcha_Turnstile "YOUR_API_KEY" "0x4AAAAAAA..." "https://example.com/submit" "session-cdata" }}" + } +``` + +```yaml +# hCaptcha via Anti-Captcha (the only service that still supports it) +method: POST +url: https://example.com/protected +body: | + { + "h-captcha-response": "{{ anticaptcha_HCaptcha "YOUR_API_KEY" "338af34c-..." "https://example.com/protected" }}" + } +``` + +```yaml +# Share a single solved token across body and headers via values +values: 'TOKEN={{ capsolver_Turnstile "YOUR_API_KEY" "0x4AAAAAAA..." "https://example.com" }}' +headers: + X-Turnstile-Token: "{{ .Values.TOKEN }}" +body: '{"token": "{{ .Values.TOKEN }}"}' +``` + ## Fake Data Functions These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library. From 88f5171132c689cc88dfd605f8363b16ae037294 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 12 Apr 2026 01:40:37 +0400 Subject: [PATCH 4/8] docs: update template function count to 340+ --- README.md | 2 +- docs/examples.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e9360db..b049e5e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Sarin is designed for efficient HTTP load testing with minimal resource consumpt | ---------------------------------------------------------- | ------------------------------- | | High-performance with low memory footprint | Detailed response body analysis | | Long-running duration/count based tests | Extensive response statistics | -| Dynamic requests via 320+ template functions | Web UI or complex TUI | +| Dynamic requests via 340+ template functions | Web UI or complex TUI | | Request scripting with Lua and JavaScript | Distributed load testing | | Multiple proxy protocols
(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC | | Captcha solving
(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem | diff --git a/docs/examples.md b/docs/examples.md index 615e726..3356737 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -372,7 +372,7 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act -> For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**. +> For the complete list of 340+ template functions, see the **[Templating Guide](templating.md)**. ## Solving Captchas From cea692cf1b097ddd883f2365994d07c9857bc8b1 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 12 Apr 2026 04:42:52 +0400 Subject: [PATCH 5/8] feat: add json_Object and json_Encode template funcs --- docs/templating.md | 28 ++++++++++++++++++++++++++++ internal/sarin/template.go | 33 +++++++++++++++++++++++++++++++++ internal/types/errors.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/docs/templating.md b/docs/templating.md index cd7c6da..dad180f 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -12,6 +12,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook - [General Functions](#general-functions) - [String Functions](#string-functions) - [Collection Functions](#collection-functions) + - [JSON Functions](#json-functions) - [Time Functions](#time-functions) - [Crypto Functions](#crypto-functions) - [Body Functions](#body-functions) @@ -117,6 +118,33 @@ sarin -U http://example.com/users \ | `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` | | `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` | +### JSON Functions + +Build JSON payloads programmatically without manual quoting or escaping. `json_Object` is the ergonomic shortcut for flat objects; `json_Encode` marshals any value (slice, map, etc.) to a JSON string. + +| Function | Description | Example | +| --------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | +| `json_Object(pairs ...any)` | Build an object from interleaved key-value pairs and return it as a JSON string. Keys must be strings. | `{{ json_Object "name" "Alice" "age" 30 }}` | +| `json_Encode(v any)` | Marshal any value (slice, map, etc.) to a JSON string. | `{{ json_Encode (slice_Str "a" "b") }}` → `["a","b"]` | + +**Examples:** + +```yaml +# Flat object with fake data +body: '{{ json_Object "name" (fakeit_FirstName) "email" (fakeit_Email) }}' + +# Embed a solved captcha token +body: '{{ json_Object "g-recaptcha-response" (twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com") }}' + +# Encode a slice as a JSON array +body: '{{ json_Encode (slice_Str "a" "b" "c") }}' + +# Encode a string dictionary (map[string]string) +body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}' +``` + +> **Note:** Object keys are serialized in alphabetical order (Go's `encoding/json` default), not insertion order. For API payloads this is almost always fine because JSON key order is semantically irrelevant. + ### Time Functions | Function | Description | Example | diff --git a/internal/sarin/template.go b/internal/sarin/template.go index c74ca8b..df8d50a 100644 --- a/internal/sarin/template.go +++ b/internal/sarin/template.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "encoding/json" "math/rand/v2" "mime/multipart" "strings" @@ -85,6 +86,38 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem "slice_Uint": func(values ...uint) []uint { return values }, "slice_Join": strings.Join, + // JSON + // json_Encode marshals any value to a JSON string. + // Usage: {{ json_Encode (dict_Str "key" "value") }} + "json_Encode": func(v any) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", types.NewJSONEncodeError(err) + } + return string(data), nil + }, + // json_Object builds a JSON object from interleaved key-value pairs and returns it + // as a JSON string. Keys must be strings; values may be any JSON-encodable type. + // Usage: {{ json_Object "name" "Alice" "age" 30 }} + "json_Object": func(pairs ...any) (string, error) { + if len(pairs)%2 != 0 { + return "", types.ErrJSONObjectOddArgs + } + obj := make(map[string]any, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + key, ok := pairs[i].(string) + if !ok { + return "", types.NewJSONObjectKeyError(i, pairs[i]) + } + obj[key] = pairs[i+1] + } + data, err := json.Marshal(obj) + if err != nil { + return "", types.NewJSONEncodeError(err) + } + return string(data), nil + }, + // Time "time_NowUnix": func() int64 { return time.Now().Unix() }, "time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() }, diff --git a/internal/types/errors.go b/internal/types/errors.go index 98903f2..6be8b55 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -208,8 +208,41 @@ func (e URLParseError) Unwrap() error { var ( ErrFileCacheNotInitialized = errors.New("file cache is not initialized") ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)") + ErrJSONObjectOddArgs = errors.New("json_Object requires an even number of arguments (key-value pairs)") ) +type JSONObjectKeyError struct { + Index int + Value any +} + +func NewJSONObjectKeyError(index int, value any) JSONObjectKeyError { + return JSONObjectKeyError{Index: index, Value: value} +} + +func (e JSONObjectKeyError) Error() string { + return fmt.Sprintf("json_Object key at index %d must be a string, got %T", e.Index, e.Value) +} + +type JSONEncodeError struct { + Err error +} + +func NewJSONEncodeError(err error) JSONEncodeError { + if err == nil { + err = errNoError + } + return JSONEncodeError{Err: err} +} + +func (e JSONEncodeError) Error() string { + return "json_Encode failed: " + e.Err.Error() +} + +func (e JSONEncodeError) Unwrap() error { + return e.Err +} + type TemplateParseError struct { Err error } From c839b71c9ebaf205567d17a4c054e505bc0c02b8 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 12 Apr 2026 21:09:24 +0400 Subject: [PATCH 6/8] refactor: rename CaptchaTimeoutError to CaptchaPollTimeoutError and separate HTTP client timeout from poll timeout --- internal/sarin/captcha.go | 34 +++++++++++++++++----------------- internal/types/errors.go | 8 ++++---- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/internal/sarin/captcha.go b/internal/sarin/captcha.go index 8159bf0..07073e0 100644 --- a/internal/sarin/captcha.go +++ b/internal/sarin/captcha.go @@ -14,10 +14,10 @@ import ( const ( captchaPollInterval = 1 * time.Second - captchaTimeout = 120 * time.Second + captchaPollTimeout = 120 * time.Second ) -var captchaHTTPClient = &http.Client{Timeout: captchaTimeout} +var captchaHTTPClient = &http.Client{Timeout: 5 * time.Second} // solveCaptcha creates a task on the given captcha service and polls until it is solved, // returning the extracted token from the solution object. @@ -32,7 +32,7 @@ var captchaHTTPClient = &http.Client{Timeout: captchaTimeout} // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey string, taskIDIsString bool) (string, error) { if apiKey == "" { @@ -98,15 +98,15 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err } // captchaPollResult polls the getTaskResult endpoint at captchaPollInterval until the task -// is solved, an error is returned by the service, or the overall captchaTimeout is hit. +// is solved, an error is returned by the service, or the overall captchaPollTimeout is hit. // // It can return the following errors: -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaRequestError // - types.CaptchaAPIError // - types.CaptchaSolutionKeyError func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), captchaTimeout) + ctx, cancel := context.WithTimeout(context.Background(), captchaPollTimeout) defer cancel() ticker := time.NewTicker(captchaPollInterval) @@ -115,7 +115,7 @@ func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsStri for { select { case <-ctx.Done(): - return "", types.NewCaptchaTimeoutError(taskID) + return "", types.NewCaptchaPollTimeoutError(taskID) case <-ticker.C: token, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString) if errors.Is(err, types.ErrCaptchaProcessing) { @@ -200,7 +200,7 @@ const twoCaptchaBaseURL = "https://api.2captcha.com" // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { return solveCaptcha(twoCaptchaBaseURL, apiKey, map[string]any{ @@ -217,7 +217,7 @@ func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { task := map[string]any{ @@ -238,7 +238,7 @@ func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction strin // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { task := map[string]any{ @@ -262,7 +262,7 @@ const antiCaptchaBaseURL = "https://api.anti-captcha.com" // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{ @@ -280,7 +280,7 @@ func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { task := map[string]any{ @@ -302,7 +302,7 @@ func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction stri // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, error) { return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{ @@ -319,7 +319,7 @@ func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, er // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { task := map[string]any{ @@ -343,7 +343,7 @@ const capSolverBaseURL = "https://api.capsolver.com" // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { return solveCaptcha(capSolverBaseURL, apiKey, map[string]any{ @@ -360,7 +360,7 @@ func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, e // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { task := map[string]any{ @@ -381,7 +381,7 @@ func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError // - types.CaptchaAPIError -// - types.CaptchaTimeoutError +// - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError func capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { task := map[string]any{ diff --git a/internal/types/errors.go b/internal/types/errors.go index 6be8b55..8b706c5 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -520,15 +520,15 @@ func (e CaptchaRequestError) Unwrap() error { return e.Err } -type CaptchaTimeoutError struct { +type CaptchaPollTimeoutError struct { TaskID string } -func NewCaptchaTimeoutError(taskID string) CaptchaTimeoutError { - return CaptchaTimeoutError{TaskID: taskID} +func NewCaptchaPollTimeoutError(taskID string) CaptchaPollTimeoutError { + return CaptchaPollTimeoutError{TaskID: taskID} } -func (e CaptchaTimeoutError) Error() string { +func (e CaptchaPollTimeoutError) Error() string { return fmt.Sprintf("captcha solving timed out (taskId: %s)", e.TaskID) } From 8577c771e4fde2cd3d4130b105223f8ae9661ef1 Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Sun, 12 Apr 2026 22:03:21 +0400 Subject: [PATCH 7/8] feat: add CaptchaDecodeError type and retry transient HTTP errors during captcha polling --- internal/sarin/captcha.go | 29 ++++++++++++++++++++++++----- internal/types/errors.go | 20 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/sarin/captcha.go b/internal/sarin/captcha.go index 07073e0..fe02e79 100644 --- a/internal/sarin/captcha.go +++ b/internal/sarin/captcha.go @@ -31,6 +31,7 @@ var captchaHTTPClient = &http.Client{Timeout: 5 * time.Second} // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError @@ -52,6 +53,7 @@ func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey strin // // It can return the following errors: // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, error) { body := map[string]any{ @@ -61,7 +63,7 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err data, err := json.Marshal(body) if err != nil { - return "", types.NewCaptchaRequestError("createTask", err) + return "", types.NewCaptchaDecodeError("createTask", err) } resp, err := captchaHTTPClient.Post( @@ -81,7 +83,7 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err TaskID json.RawMessage `json:"taskId"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", types.NewCaptchaRequestError("createTask", err) + return "", types.NewCaptchaDecodeError("createTask", err) } if result.ErrorID != 0 { @@ -102,7 +104,7 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err // // It can return the following errors: // - types.CaptchaPollTimeoutError -// - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaSolutionKeyError func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) { @@ -121,6 +123,12 @@ func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsStri if errors.Is(err, types.ErrCaptchaProcessing) { continue } + // Retry on transient HTTP errors (timeouts, connection resets, etc.) + // instead of failing the entire solve. The poll loop timeout will + // eventually catch permanently unreachable services. + if _, ok := errors.AsType[types.CaptchaRequestError](err); ok { + continue + } if err != nil { return "", err } @@ -134,6 +142,7 @@ func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsStri // It can return the following errors: // - types.ErrCaptchaProcessing // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaSolutionKeyError func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) { @@ -146,7 +155,7 @@ func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsS data, err := json.Marshal(bodyMap) if err != nil { - return "", types.NewCaptchaRequestError("getTaskResult", err) + return "", types.NewCaptchaDecodeError("getTaskResult", err) } resp, err := captchaHTTPClient.Post( @@ -167,7 +176,7 @@ func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsS Solution map[string]any `json:"solution"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", types.NewCaptchaRequestError("getTaskResult", err) + return "", types.NewCaptchaDecodeError("getTaskResult", err) } if result.ErrorID != 0 { @@ -199,6 +208,7 @@ const twoCaptchaBaseURL = "https://api.2captcha.com" // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError @@ -216,6 +226,7 @@ func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError @@ -237,6 +248,7 @@ func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction strin // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError @@ -261,6 +273,7 @@ const antiCaptchaBaseURL = "https://api.anti-captcha.com" // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError @@ -279,6 +292,7 @@ func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError @@ -301,6 +315,7 @@ func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction stri // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError @@ -318,6 +333,7 @@ func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, er // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError @@ -342,6 +358,7 @@ const capSolverBaseURL = "https://api.capsolver.com" // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError @@ -359,6 +376,7 @@ func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, e // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError @@ -380,6 +398,7 @@ func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string // It can return the following errors: // - types.ErrCaptchaKeyEmpty // - types.CaptchaRequestError +// - types.CaptchaDecodeError // - types.CaptchaAPIError // - types.CaptchaPollTimeoutError // - types.CaptchaSolutionKeyError diff --git a/internal/types/errors.go b/internal/types/errors.go index 8b706c5..c55d96c 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -520,6 +520,26 @@ func (e CaptchaRequestError) Unwrap() error { return e.Err } +type CaptchaDecodeError struct { + Endpoint string + Err error +} + +func NewCaptchaDecodeError(endpoint string, err error) CaptchaDecodeError { + if err == nil { + err = errNoError + } + return CaptchaDecodeError{Endpoint: endpoint, Err: err} +} + +func (e CaptchaDecodeError) Error() string { + return fmt.Sprintf("captcha %s decode failed: %v", e.Endpoint, e.Err) +} + +func (e CaptchaDecodeError) Unwrap() error { + return e.Err +} + type CaptchaPollTimeoutError struct { TaskID string } From e9b9b8890c0bd9aaa0dca8b85bba349ad73d55df Mon Sep 17 00:00:00 2001 From: Aykhan Shahsuvarov Date: Wed, 15 Apr 2026 18:42:06 +0400 Subject: [PATCH 8/8] update docs --- README.md | 4 ++-- docs/configuration.md | 12 ++++++------ docs/templating.md | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b049e5e..d89fdcb 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## Overview -Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused. +Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity and features like templating add zero overhead when unused. | ✅ Supported | ❌ Not Supported | | ---------------------------------------------------------- | ------------------------------- | @@ -106,7 +106,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c ## Templating -Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request. +Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 340+ built-in functions to generate dynamic data for each request. **Example:** diff --git a/docs/configuration.md b/docs/configuration.md index 2cceee7..913ccb0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,6 @@ # Configuration -Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent—YAML files have the most configuration options, followed by CLI flags, and then environment variables. +Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent: YAML files have the most configuration options, followed by CLI flags, and then environment variables. When the same option is specified in multiple sources, the following priority order applies: @@ -107,9 +107,9 @@ If all four files define `url`, the value from `config3.yaml` wins. **Merge behavior by field:** -- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) — higher priority overrides lower priority -- **Method and Body** — higher priority overrides lower priority (no merging) -- **Headers, Params, Cookies, Proxies, Values, Lua, and Js** — accumulated across all config files +- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.): higher priority overrides lower priority +- **Method and Body**: higher priority overrides lower priority (no merging) +- **Headers, Params, Cookies, Proxies, Values, Lua, and Js**: accumulated across all config files ## URL @@ -408,7 +408,7 @@ SARIN_VALUES="key1=value1" Lua script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent. -If multiple Lua scripts are provided, they are chained in order—the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts. +If multiple Lua scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts. **Script sources:** @@ -473,7 +473,7 @@ SARIN_LUA='function transform(req) req.headers["X-Custom"] = "my-value" return r JavaScript script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent. -If multiple JavaScript scripts are provided, they are chained in order—the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts. +If multiple JavaScript scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts. **Script sources:** diff --git a/docs/templating.md b/docs/templating.md index dad180f..336627b 100644 --- a/docs/templating.md +++ b/docs/templating.md @@ -236,7 +236,7 @@ Captcha functions solve a captcha challenge through a third-party solving servic The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required — bring your own key and use any of the supported services per template. -> **Important — performance and cost:** +> **Important: performance and cost:** > > - **Each call is slow.** Solving typically takes ~5–60 seconds because the function blocks the template render until the third-party service returns a token. Internally the solver polls every 1s and gives up after 120s. > - **Each call costs money.** Every successful solve is billed by the captcha service (typically $0.001–$0.003 per solve). For high-volume tests, your captcha bill grows linearly with request count.