From 88fc30c7084f5155351fde4945ecc0a7208cc122 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Sun, 22 Mar 2026 09:45:27 +0530 Subject: [PATCH 01/14] fix(github-graphql): prevent panic in graphql rate limit polling goroutine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace panic in GraphqlAsyncClient rate-limit polling goroutine with graceful error handling. Previously, any error while fetching rate limit (e.g., transient network issues or 401 responses) would trigger a panic inside a background goroutine, crashing the entire DevLake process. Now, errors are logged and the client retries in the next cycle while retaining the last known rate limit. Design decisions: - Avoid panic in background goroutines: rate-limit polling is non-critical and should not bring down the entire pipeline. - Use last known rateRemaining on runtime failures instead of resetting or blocking, ensuring continued progress with eventual consistency. - Retry via existing polling mechanism instead of immediate retry to prevent tight retry loops and unnecessary API pressure. - Introduce a default fallback (5000) only for initial rate-limit fetch failures, since no prior state exists at startup. - Separate handling of initial vs runtime failures: - Initial failure → fallback to default (5000) - Runtime failure → retain previous value Fixes #8788 (bug 1) --- .../helpers/pluginhelper/api/graphql_async_client.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/helpers/pluginhelper/api/graphql_async_client.go b/backend/helpers/pluginhelper/api/graphql_async_client.go index 32dac66a9eb..83f00dc7557 100644 --- a/backend/helpers/pluginhelper/api/graphql_async_client.go +++ b/backend/helpers/pluginhelper/api/graphql_async_client.go @@ -47,6 +47,8 @@ type GraphqlAsyncClient struct { getRateCost func(q interface{}) int } +const defaultRateLimit = 5000 + // CreateAsyncGraphqlClient creates a new GraphqlAsyncClient func CreateAsyncGraphqlClient( taskCtx plugin.TaskContext, @@ -68,9 +70,11 @@ func CreateAsyncGraphqlClient( if getRateRemaining != nil { rateRemaining, resetAt, err := getRateRemaining(taskCtx.GetContext(), graphqlClient, logger) if err != nil { - panic(err) + graphqlAsyncClient.logger.Warn(err, "failed to fetch initial graphql rate limit, fallback to default") + graphqlAsyncClient.updateRateRemaining(defaultRateLimit, nil) + } else { + graphqlAsyncClient.updateRateRemaining(rateRemaining, resetAt) } - graphqlAsyncClient.updateRateRemaining(rateRemaining, resetAt) } // load retry/timeout from configuration @@ -126,7 +130,9 @@ func (apiClient *GraphqlAsyncClient) updateRateRemaining(rateRemaining int, rese case <-time.After(nextDuring): newRateRemaining, newResetAt, err := apiClient.getRateRemaining(apiClient.ctx, apiClient.client, apiClient.logger) if err != nil { - panic(err) + apiClient.logger.Warn(err, "failed to update graphql rate limit, will retry next cycle") + apiClient.updateRateRemaining(apiClient.rateRemaining, nil) + return } apiClient.updateRateRemaining(newRateRemaining, newResetAt) } From b5889552ebf92a82c72ed00ff8dc2105ad2cf324 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Sun, 22 Mar 2026 09:48:12 +0530 Subject: [PATCH 02/14] fix(github-graphql): reuse ApiClient transport for GraphQL to enable token refresh Replace oauth2.StaticTokenSource-based HTTP client with the underlying http.Client from ApiAsyncClient. Previously, the GraphQL client constructed its own HTTP client using StaticTokenSource, which froze the access token at task start time. This caused GitHub App installation tokens (which expire after ~1 hour) to become invalid during long-running pipelines, leading to persistent 401 errors. Now, the GraphQL client reuses apiClient.GetClient(), which is already configured with RefreshRoundTripper and TokenProvider. This enables automatic token refresh on 401 responses, aligning GraphQL behavior with the REST client. Design decisions: - Reuse transport layer instead of duplicating authentication logic to ensure consistency across REST and GraphQL clients. - Avoid StaticTokenSource, as it prevents token refresh and breaks long-running pipelines. - Leverage existing RefreshRoundTripper for transparent token rotation without modifying GraphQL query logic. - Keep protocol-specific logic (GraphQL vs REST) separate while sharing the underlying HTTP transport. This ensures GraphQL pipelines using GitHub App authentication can run beyond token expiry without failure. Fixes #8788 (bug 2) --- backend/plugins/github_graphql/impl/impl.go | 34 ++------------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/backend/plugins/github_graphql/impl/impl.go b/backend/plugins/github_graphql/impl/impl.go index 3efbe10a272..4606726b41d 100644 --- a/backend/plugins/github_graphql/impl/impl.go +++ b/backend/plugins/github_graphql/impl/impl.go @@ -20,10 +20,8 @@ package impl import ( "context" "fmt" - "net/http" "net/url" "reflect" - "strings" "time" "github.com/apache/incubator-devlake/core/models/domainlayer/devops" @@ -39,7 +37,6 @@ import ( "github.com/apache/incubator-devlake/plugins/github_graphql/model/migrationscripts" "github.com/apache/incubator-devlake/plugins/github_graphql/tasks" "github.com/merico-ai/graphql" - "golang.org/x/oauth2" ) // make sure interface is implemented @@ -180,45 +177,18 @@ func (p GithubGraphql) PrepareTaskData(taskCtx plugin.TaskContext, options map[s return nil, err } - tokens := strings.Split(connection.Token, ",") - src := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: tokens[0]}, - ) - oauthContext := taskCtx.GetContext() - proxy := connection.GetProxy() - if proxy != "" { - pu, err := url.Parse(proxy) - if err != nil { - return nil, errors.Convert(err) - } - if pu.Scheme == "http" || pu.Scheme == "socks5" { - proxyClient := &http.Client{ - Transport: &http.Transport{Proxy: http.ProxyURL(pu)}, - } - oauthContext = context.WithValue( - taskCtx.GetContext(), - oauth2.HTTPClient, - proxyClient, - ) - logger.Debug("Proxy set in oauthContext to %s", proxy) - } else { - return nil, errors.BadInput.New("Unsupported scheme set in proxy") - } - } - - httpClient := oauth2.NewClient(oauthContext, src) endpoint, err := errors.Convert01(url.Parse(connection.Endpoint)) if err != nil { return nil, errors.BadInput.Wrap(err, fmt.Sprintf("malformed connection endpoint supplied: %s", connection.Endpoint)) } - // github.com and github enterprise have different graphql endpoints endpoint.Path = "/graphql" // see https://docs.github.com/en/graphql/guides/forming-calls-with-graphql if endpoint.Hostname() != "api.github.com" { // see https://docs.github.com/en/enterprise-server@3.11/graphql/guides/forming-calls-with-graphql endpoint.Path = "/api/graphql" } - client := graphql.NewClient(endpoint.String(), httpClient) + + client := graphql.NewClient(endpoint.String(), apiClient.GetClient()) graphqlClient, err := helper.CreateAsyncGraphqlClient(taskCtx, client, taskCtx.GetLogger(), func(ctx context.Context, client *graphql.Client, logger log.Logger) (rateRemaining int, resetAt *time.Time, err errors.Error) { var query GraphQueryRateLimit From 611ff3e11d64293d0d5e8c7c42c674a2d826d36c Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Sun, 22 Mar 2026 18:50:50 +0530 Subject: [PATCH 03/14] refactor(github): extract shared authenticated http client from api client - moved token provider and refresh round tripper setup into a reusable helper - introduced CreateAuthenticatedHttpClient to centralize auth + transport logic - updated CreateApiClient to use shared http client instead of inline setup Rationale: - decouples authentication (transport layer) from REST-specific client logic - enables reuse for GraphQL client without duplicating token refresh logic - aligns architecture with separation of concerns (http transport vs api clients) --- backend/plugins/github/tasks/api_client.go | 39 ++--------- backend/plugins/github/tasks/http_client.go | 78 +++++++++++++++++++++ 2 files changed, 82 insertions(+), 35 deletions(-) create mode 100644 backend/plugins/github/tasks/http_client.go diff --git a/backend/plugins/github/tasks/api_client.go b/backend/plugins/github/tasks/api_client.go index 42181ff139e..c5be8a0f22b 100644 --- a/backend/plugins/github/tasks/api_client.go +++ b/backend/plugins/github/tasks/api_client.go @@ -26,7 +26,6 @@ import ( "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/helpers/pluginhelper/api" "github.com/apache/incubator-devlake/plugins/github/models" - "github.com/apache/incubator-devlake/plugins/github/token" ) func CreateApiClient(taskCtx plugin.TaskContext, connection *models.GithubConnection) (*api.ApiAsyncClient, errors.Error) { @@ -35,40 +34,10 @@ func CreateApiClient(taskCtx plugin.TaskContext, connection *models.GithubConnec return nil, err } - logger := taskCtx.GetLogger() - db := taskCtx.GetDal() - encryptionSecret := taskCtx.GetConfig(plugin.EncodeKeyEnvStr) - - // Inject TokenProvider for OAuth refresh or GitHub App installation tokens. - var tp *token.TokenProvider - if connection.RefreshToken != "" { - tp = token.NewTokenProvider(connection, db, apiClient.GetClient(), logger, encryptionSecret) - } else if connection.AuthMethod == models.AppKey && connection.InstallationID != 0 { - tp = token.NewAppInstallationTokenProvider(connection, db, apiClient.GetClient(), logger, encryptionSecret) - } - if tp != nil { - // Wrap the transport - baseTransport := apiClient.GetClient().Transport - if baseTransport == nil { - baseTransport = http.DefaultTransport - } - - rt := token.NewRefreshRoundTripper(baseTransport, tp) - apiClient.GetClient().Transport = rt - logger.Info("Installed token refresh round tripper for connection %d (authMethod=%s)", - connection.ID, connection.AuthMethod) - } - - // Persist the freshly minted token so the DB has a correctly encrypted value. - // PrepareApiClient (called by NewApiClientFromConnection) mints the token - // in-memory but does not persist it; without this, the DB may contain a stale - // or corrupted token that breaks GET /connections. - if connection.AuthMethod == models.AppKey && connection.Token != "" { - if err := token.PersistEncryptedTokenColumns(db, connection, encryptionSecret, logger, false); err != nil { - logger.Warn(err, "Failed to persist initial token for connection %d", connection.ID) - } else { - logger.Info("Persisted initial token for connection %d", connection.ID) - } + // inject the shared auth layer + _, err = CreateAuthenticatedHttpClient(taskCtx, connection, apiClient.GetClient()) + if err != nil { + return nil, err } // create rate limit calculator diff --git a/backend/plugins/github/tasks/http_client.go b/backend/plugins/github/tasks/http_client.go new file mode 100644 index 00000000000..ca91ace58a5 --- /dev/null +++ b/backend/plugins/github/tasks/http_client.go @@ -0,0 +1,78 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "net/http" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/plugins/github/models" + "github.com/apache/incubator-devlake/plugins/github/token" +) + +func CreateAuthenticatedHttpClient( + taskCtx plugin.TaskContext, + connection *models.GithubConnection, + baseClient *http.Client, +) (*http.Client, errors.Error) { + + logger := taskCtx.GetLogger() + db := taskCtx.GetDal() + encryptionSecret := taskCtx.GetConfig(plugin.EncodeKeyEnvStr) + + if baseClient == nil { + baseClient = &http.Client{} + } + + // Inject TokenProvider for OAuth refresh or GitHub App installation tokens. + var tp *token.TokenProvider + if connection.RefreshToken != "" { + tp = token.NewTokenProvider(connection, db, baseClient, logger, encryptionSecret) + } else if connection.AuthMethod == models.AppKey && connection.InstallationID != 0 { + tp = token.NewAppInstallationTokenProvider(connection, db, baseClient, logger, encryptionSecret) + } + + if tp != nil { + baseTransport := baseClient.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + + baseClient.Transport = token.NewRefreshRoundTripper(baseTransport, tp) + logger.Info( + "Installed token refresh round tripper for connection %d (authMethod=%s)", + connection.ID, + connection.AuthMethod, + ) + } + + // Persist the freshly minted token so the DB has a correctly encrypted value. + // PrepareApiClient (called by NewApiClientFromConnection) mints the token + // in-memory but does not persist it; without this, the DB may contain a stale + // or corrupted token that breaks GET /connections. + if connection.AuthMethod == models.AppKey && connection.Token != "" { + if err := token.PersistEncryptedTokenColumns(db, connection, encryptionSecret, logger, false); err != nil { + logger.Warn(err, "Failed to persist initial token for connection %d", connection.ID) + } else { + logger.Info("Persisted initial token for connection %d", connection.ID) + } + } + + return baseClient, nil +} From 83ccc18d307fd4d8360117c06fdbbb7166f09ce0 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Sun, 22 Mar 2026 19:20:01 +0530 Subject: [PATCH 04/14] feat(github-graphql): introduce graphql client with shared auth and integrate into task flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - added CreateGraphqlClient to encapsulate graphql client construction - reused CreateAuthenticatedHttpClient from github/tasks to inject token refresh via RoundTripper - replaced manual graphql client setup in PrepareTaskData with new factory function - preserved existing rate limit handling via getRateRemaining callback - preserved query cost calculation using SetGetRateCost Technical details: - graphql client now uses http transport with TokenProvider and RefreshRoundTripper - removes dependency on oauth2 client and avoids token expiration issues - decouples graphql client from REST ApiClient by avoiding reuse of apiClient.GetClient() - maintains compatibility with github.com and enterprise graphql endpoints Note: - shared auth logic remains in github/tasks and is imported with alias to avoid package name collision - introduces cross-plugin dependency (github_graphql → github/tasks) as a pragmatic tradeoff to avoid duplication --- backend/plugins/github_graphql/impl/impl.go | 17 +---- .../github_graphql/tasks/graphql_client.go | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 backend/plugins/github_graphql/tasks/graphql_client.go diff --git a/backend/plugins/github_graphql/impl/impl.go b/backend/plugins/github_graphql/impl/impl.go index 4606726b41d..12638fee54f 100644 --- a/backend/plugins/github_graphql/impl/impl.go +++ b/backend/plugins/github_graphql/impl/impl.go @@ -20,7 +20,6 @@ package impl import ( "context" "fmt" - "net/url" "reflect" "time" @@ -177,19 +176,9 @@ func (p GithubGraphql) PrepareTaskData(taskCtx plugin.TaskContext, options map[s return nil, err } - endpoint, err := errors.Convert01(url.Parse(connection.Endpoint)) - if err != nil { - return nil, errors.BadInput.Wrap(err, fmt.Sprintf("malformed connection endpoint supplied: %s", connection.Endpoint)) - } - // github.com and github enterprise have different graphql endpoints - endpoint.Path = "/graphql" // see https://docs.github.com/en/graphql/guides/forming-calls-with-graphql - if endpoint.Hostname() != "api.github.com" { - // see https://docs.github.com/en/enterprise-server@3.11/graphql/guides/forming-calls-with-graphql - endpoint.Path = "/api/graphql" - } - - client := graphql.NewClient(endpoint.String(), apiClient.GetClient()) - graphqlClient, err := helper.CreateAsyncGraphqlClient(taskCtx, client, taskCtx.GetLogger(), + graphqlClient, err := tasks.CreateGraphqlClient( + taskCtx, + connection, func(ctx context.Context, client *graphql.Client, logger log.Logger) (rateRemaining int, resetAt *time.Time, err errors.Error) { var query GraphQueryRateLimit dataErrors, err := errors.Convert01(client.Query(taskCtx.GetContext(), &query, nil)) diff --git a/backend/plugins/github_graphql/tasks/graphql_client.go b/backend/plugins/github_graphql/tasks/graphql_client.go new file mode 100644 index 00000000000..c25fa45394e --- /dev/null +++ b/backend/plugins/github_graphql/tasks/graphql_client.go @@ -0,0 +1,69 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "context" + "fmt" + "github.com/apache/incubator-devlake/core/log" + "net/url" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/github/models" + githubTasks "github.com/apache/incubator-devlake/plugins/github/tasks" + "github.com/merico-ai/graphql" +) + +func CreateGraphqlClient( + taskCtx plugin.TaskContext, + connection *models.GithubConnection, + getRateRemaining func(context.Context, *graphql.Client, log.Logger) (rateRemaining int, resetAt *time.Time, err errors.Error), +) (*helper.GraphqlAsyncClient, errors.Error) { + + // inject the shared auth layer + httpClient, err := githubTasks.CreateAuthenticatedHttpClient(taskCtx, connection, nil) + if err != nil { + return nil, err + } + + // Build endpoint + endpoint, err := errors.Convert01(url.Parse(connection.Endpoint)) + if err != nil { + return nil, errors.BadInput.Wrap(err, fmt.Sprintf("malformed connection endpoint supplied: %s", connection.Endpoint)) + } + // github.com and github enterprise have different graphql endpoints + if endpoint.Hostname() == "api.github.com" { + // see https://docs.github.com/en/graphql/guides/forming-calls-with-graphql + endpoint.Path = "/graphql" + } else { + // see https://docs.github.com/en/enterprise-server@3.11/graphql/guides/forming-calls-with-graphql + endpoint.Path = "/api/graphql" + } + + gqlClient := graphql.NewClient(endpoint.String(), httpClient) + + return helper.CreateAsyncGraphqlClient( + taskCtx, + gqlClient, + taskCtx.GetLogger(), + getRateRemaining, + ) +} From 1d380aee618f2f1110b87e6a91d9cc3b1f58deef Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Sun, 22 Mar 2026 23:57:54 +0530 Subject: [PATCH 05/14] feat(github): support static token transport for GraphQL and REST clients add StaticRoundTripper for PAT authentication and use it in the shared http client. since the same client is used by both REST and GraphQL, auth handling must distinguish between refreshable tokens and static tokens. avoid applying refresh/retry logic to PAT. ensures correct behavior across clients and prevents unnecessary retries for static auth. --- backend/plugins/github/tasks/http_client.go | 21 +++++++++++++---- backend/plugins/github/token/round_tripper.go | 23 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/backend/plugins/github/tasks/http_client.go b/backend/plugins/github/tasks/http_client.go index ca91ace58a5..3a740d3f3f5 100644 --- a/backend/plugins/github/tasks/http_client.go +++ b/backend/plugins/github/tasks/http_client.go @@ -48,18 +48,28 @@ func CreateAuthenticatedHttpClient( tp = token.NewAppInstallationTokenProvider(connection, db, baseClient, logger, encryptionSecret) } - if tp != nil { - baseTransport := baseClient.Transport - if baseTransport == nil { - baseTransport = http.DefaultTransport - } + baseTransport := baseClient.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + if tp != nil { baseClient.Transport = token.NewRefreshRoundTripper(baseTransport, tp) logger.Info( "Installed token refresh round tripper for connection %d (authMethod=%s)", connection.ID, connection.AuthMethod, ) + + } else if connection.Token != "" { + baseClient.Transport = token.NewStaticRoundTripper( + baseTransport, + connection.Token, + ) + logger.Info( + "Installed static token round tripper for connection %d", + connection.ID, + ) } // Persist the freshly minted token so the DB has a correctly encrypted value. @@ -73,6 +83,7 @@ func CreateAuthenticatedHttpClient( logger.Info("Persisted initial token for connection %d", connection.ID) } } + println("http client HIT3") return baseClient, nil } diff --git a/backend/plugins/github/token/round_tripper.go b/backend/plugins/github/token/round_tripper.go index 8868572dae6..e5e51b35ed8 100644 --- a/backend/plugins/github/token/round_tripper.go +++ b/backend/plugins/github/token/round_tripper.go @@ -93,3 +93,26 @@ func (rt *RefreshRoundTripper) roundTripWithRetry(req *http.Request, refreshAtte return resp, nil } + +// StaticRoundTripper is an HTTP transport that injects a fixed bearer token. +// Unlike RefreshRoundTripper, it does NOT attempt refresh or retries. +type StaticRoundTripper struct { + base http.RoundTripper + token string +} + +func NewStaticRoundTripper(base http.RoundTripper, token string) *StaticRoundTripper { + if base == nil { + base = http.DefaultTransport + } + return &StaticRoundTripper{ + base: base, + token: token, + } +} + +func (rt *StaticRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + reqClone := req.Clone(req.Context()) + reqClone.Header.Set("Authorization", "Bearer "+rt.token) + return rt.base.RoundTrip(reqClone) +} From ab0a5fb55042b9a558e8f29ef0fe1704c45591c1 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Mon, 23 Mar 2026 23:02:26 +0530 Subject: [PATCH 06/14] feat(github-graphql): introduce hierarchical fallback for GraphQL rate limit Implement a layered fallback mechanism for GraphQL rate limiting: 1. Dynamic rate limit from provider (getRateRemaining) 2. Per-client override (WithFallbackRateLimit) 3. Config override (GRAPHQL_RATE_LIMIT) 4. Default fallback (1000) Also moved GitHub-specific fallback (5000) via WithFallbackRateLimit to the Graphql client. --- .../pluginhelper/api/graphql_async_client.go | 54 +++++++++++++++++-- backend/plugins/github_graphql/impl/impl.go | 4 +- .../github_graphql/tasks/graphql_client.go | 2 + 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/backend/helpers/pluginhelper/api/graphql_async_client.go b/backend/helpers/pluginhelper/api/graphql_async_client.go index 83f00dc7557..0e8c3e459ff 100644 --- a/backend/helpers/pluginhelper/api/graphql_async_client.go +++ b/backend/helpers/pluginhelper/api/graphql_async_client.go @@ -24,12 +24,16 @@ import ( "github.com/apache/incubator-devlake/core/log" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/core/utils" + "strconv" "sync" "time" "github.com/merico-ai/graphql" ) +// GraphqlClientOption is a function that configures a GraphqlAsyncClient +type GraphqlClientOption func(*GraphqlAsyncClient) + // GraphqlAsyncClient send graphql one by one type GraphqlAsyncClient struct { ctx context.Context @@ -47,7 +51,10 @@ type GraphqlAsyncClient struct { getRateCost func(q interface{}) int } -const defaultRateLimit = 5000 +// defaultRateLimitConst is the generic fallback rate limit for GraphQL requests. +// It is used as the initial remaining quota when dynamic rate limit +// information is unavailable from the provider. +const defaultRateLimitConst = 1000 // CreateAsyncGraphqlClient creates a new GraphqlAsyncClient func CreateAsyncGraphqlClient( @@ -55,26 +62,37 @@ func CreateAsyncGraphqlClient( graphqlClient *graphql.Client, logger log.Logger, getRateRemaining func(context.Context, *graphql.Client, log.Logger) (rateRemaining int, resetAt *time.Time, err errors.Error), + opts ...GraphqlClientOption, ) (*GraphqlAsyncClient, errors.Error) { ctxWithCancel, cancel := context.WithCancel(taskCtx.GetContext()) + + rateLimit := resolveRateLimit(taskCtx, logger) + graphqlAsyncClient := &GraphqlAsyncClient{ ctx: ctxWithCancel, cancel: cancel, client: graphqlClient, logger: logger, rateExhaustCond: sync.NewCond(&sync.Mutex{}), - rateRemaining: 0, + rateRemaining: rateLimit, getRateRemaining: getRateRemaining, } + // apply options + for _, opt := range opts { + opt(graphqlAsyncClient) + } + if getRateRemaining != nil { rateRemaining, resetAt, err := getRateRemaining(taskCtx.GetContext(), graphqlClient, logger) if err != nil { graphqlAsyncClient.logger.Warn(err, "failed to fetch initial graphql rate limit, fallback to default") - graphqlAsyncClient.updateRateRemaining(defaultRateLimit, nil) + graphqlAsyncClient.updateRateRemaining(graphqlAsyncClient.rateRemaining, nil) } else { graphqlAsyncClient.updateRateRemaining(rateRemaining, resetAt) } + } else { + graphqlAsyncClient.updateRateRemaining(graphqlAsyncClient.rateRemaining, nil) } // load retry/timeout from configuration @@ -119,6 +137,10 @@ func (apiClient *GraphqlAsyncClient) updateRateRemaining(rateRemaining int, rese apiClient.rateExhaustCond.Signal() } go func() { + if apiClient.getRateRemaining == nil { + return + } + nextDuring := 3 * time.Minute if resetAt != nil && resetAt.After(time.Now()) { nextDuring = time.Until(*resetAt) @@ -224,3 +246,29 @@ func (apiClient *GraphqlAsyncClient) Wait() { func (apiClient *GraphqlAsyncClient) Release() { apiClient.cancel() } + +// WithFallbackRateLimit sets the initial/fallback rate limit used when +// rate limit information cannot be fetched dynamically. +// This value may be overridden later by getRateRemaining. +func WithFallbackRateLimit(limit int) GraphqlClientOption { + return func(c *GraphqlAsyncClient) { + if limit > 0 { + c.rateRemaining = limit + } + } +} + +// resolveRateLimit determines the rate limit for GraphQL requests using task configuration -> else default constant. +func resolveRateLimit(taskCtx plugin.TaskContext, logger log.Logger) int { + rateLimit := defaultRateLimitConst + + if v := taskCtx.GetConfig("GRAPHQL_RATE_LIMIT"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + rateLimit = parsed + } else { + logger.Warn(err, "invalid GRAPHQL_RATE_LIMIT, using default") + } + } + + return rateLimit +} diff --git a/backend/plugins/github_graphql/impl/impl.go b/backend/plugins/github_graphql/impl/impl.go index 12638fee54f..ff1f0854c4e 100644 --- a/backend/plugins/github_graphql/impl/impl.go +++ b/backend/plugins/github_graphql/impl/impl.go @@ -189,8 +189,8 @@ func (p GithubGraphql) PrepareTaskData(taskCtx plugin.TaskContext, options map[s return 0, nil, errors.Default.Wrap(dataErrors[0], `query rate limit fail`) } if query.RateLimit == nil { - logger.Info(`github graphql rate limit are disabled, fallback to 5000req/hour`) - return 5000, nil, nil + logger.Info(`github graphql rate limit unavailable, using fallback rate limit`) + return 0, nil, errors.Default.New("rate limit unavailable") } logger.Info(`github graphql init success with remaining %d/%d and will reset at %s`, query.RateLimit.Remaining, query.RateLimit.Limit, query.RateLimit.ResetAt) diff --git a/backend/plugins/github_graphql/tasks/graphql_client.go b/backend/plugins/github_graphql/tasks/graphql_client.go index c25fa45394e..b41c4963f6a 100644 --- a/backend/plugins/github_graphql/tasks/graphql_client.go +++ b/backend/plugins/github_graphql/tasks/graphql_client.go @@ -65,5 +65,7 @@ func CreateGraphqlClient( gqlClient, taskCtx.GetLogger(), getRateRemaining, + // GitHub GraphQL default fallback aligns with GitHub's standard rate limit (~5000) + helper.WithFallbackRateLimit(5000), ) } From 07132ec95f144ea13da19ec2b978b06c4bf174c8 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Mon, 23 Mar 2026 23:02:57 +0530 Subject: [PATCH 07/14] feat(github-graphql): Add graphql rate limit to .env example --- env.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/env.example b/env.example index 19acb7c94af..eaa995eef84 100755 --- a/env.example +++ b/env.example @@ -38,6 +38,8 @@ MODE=release NOTIFICATION_ENDPOINT= NOTIFICATION_SECRET= +# Default fallback rate limit for GraphQL clients (used if not dynamically fetched) +GRAPHQL_RATE_LIMIT= API_TIMEOUT=120s API_RETRY=3 API_REQUESTS_PER_HOUR=10000 From 6b1268db34b53995e7b9f13961ca602799276d5b Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Wed, 1 Apr 2026 20:55:21 +0530 Subject: [PATCH 08/14] fix(github): Fix leaked debug statement --- backend/plugins/github/tasks/http_client.go | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/plugins/github/tasks/http_client.go b/backend/plugins/github/tasks/http_client.go index 3a740d3f3f5..33ef6a3df6b 100644 --- a/backend/plugins/github/tasks/http_client.go +++ b/backend/plugins/github/tasks/http_client.go @@ -83,7 +83,6 @@ func CreateAuthenticatedHttpClient( logger.Info("Persisted initial token for connection %d", connection.ID) } } - println("http client HIT3") return baseClient, nil } From 4e49845872d6e1305d78cf289f0063bb5efc7027 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Wed, 1 Apr 2026 21:02:28 +0530 Subject: [PATCH 09/14] fix(github-graphql): reuse http.Client proxy, auth configurations Reused `http.Client` inside the apiClient returned by `CreateApiClient` method, so keeping the proxy and auth configurations the same.That also keep the centralized management of logic. --- backend/plugins/github_graphql/impl/impl.go | 1 + backend/plugins/github_graphql/tasks/graphql_client.go | 10 ++-------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/backend/plugins/github_graphql/impl/impl.go b/backend/plugins/github_graphql/impl/impl.go index ff1f0854c4e..fa1329d3d57 100644 --- a/backend/plugins/github_graphql/impl/impl.go +++ b/backend/plugins/github_graphql/impl/impl.go @@ -179,6 +179,7 @@ func (p GithubGraphql) PrepareTaskData(taskCtx plugin.TaskContext, options map[s graphqlClient, err := tasks.CreateGraphqlClient( taskCtx, connection, + apiClient.ApiClient.GetClient(), func(ctx context.Context, client *graphql.Client, logger log.Logger) (rateRemaining int, resetAt *time.Time, err errors.Error) { var query GraphQueryRateLimit dataErrors, err := errors.Convert01(client.Query(taskCtx.GetContext(), &query, nil)) diff --git a/backend/plugins/github_graphql/tasks/graphql_client.go b/backend/plugins/github_graphql/tasks/graphql_client.go index b41c4963f6a..7682df41779 100644 --- a/backend/plugins/github_graphql/tasks/graphql_client.go +++ b/backend/plugins/github_graphql/tasks/graphql_client.go @@ -22,28 +22,22 @@ import ( "fmt" "github.com/apache/incubator-devlake/core/log" "net/url" + "net/http" "time" "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/plugin" helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" "github.com/apache/incubator-devlake/plugins/github/models" - githubTasks "github.com/apache/incubator-devlake/plugins/github/tasks" "github.com/merico-ai/graphql" ) func CreateGraphqlClient( taskCtx plugin.TaskContext, connection *models.GithubConnection, + httpClient *http.Client, getRateRemaining func(context.Context, *graphql.Client, log.Logger) (rateRemaining int, resetAt *time.Time, err errors.Error), ) (*helper.GraphqlAsyncClient, errors.Error) { - - // inject the shared auth layer - httpClient, err := githubTasks.CreateAuthenticatedHttpClient(taskCtx, connection, nil) - if err != nil { - return nil, err - } - // Build endpoint endpoint, err := errors.Convert01(url.Parse(connection.Endpoint)) if err != nil { From f8c3078a3902c9ef74b338eefaa0d6ab1d2d8935 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Wed, 1 Apr 2026 22:14:54 +0530 Subject: [PATCH 10/14] fix(helpers): fix the priority order of fallback rate limit Priority order fixed for fallback rate limit, priority order is: 1.Env variable 2.Value set with `WithFallbackRateLimit` 3.default value in the code This all works only when the `getRateRemaining` fails: hence the fallback --- .../pluginhelper/api/graphql_async_client.go | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/helpers/pluginhelper/api/graphql_async_client.go b/backend/helpers/pluginhelper/api/graphql_async_client.go index 0e8c3e459ff..f9cff5e3792 100644 --- a/backend/helpers/pluginhelper/api/graphql_async_client.go +++ b/backend/helpers/pluginhelper/api/graphql_async_client.go @@ -66,15 +66,13 @@ func CreateAsyncGraphqlClient( ) (*GraphqlAsyncClient, errors.Error) { ctxWithCancel, cancel := context.WithCancel(taskCtx.GetContext()) - rateLimit := resolveRateLimit(taskCtx, logger) - graphqlAsyncClient := &GraphqlAsyncClient{ ctx: ctxWithCancel, cancel: cancel, client: graphqlClient, logger: logger, rateExhaustCond: sync.NewCond(&sync.Mutex{}), - rateRemaining: rateLimit, + rateRemaining: defaultRateLimitConst, getRateRemaining: getRateRemaining, } @@ -83,6 +81,12 @@ func CreateAsyncGraphqlClient( opt(graphqlAsyncClient) } + // Env config wins over everything, only if explicitly set + if rateLimit := resolveRateLimit(taskCtx, logger); rateLimit != -1 { + logger.Info("GRAPHQL_RATE_LIMIT env override applied: %d (was %d)", rateLimit, graphqlAsyncClient.rateRemaining) + graphqlAsyncClient.rateRemaining = rateLimit + } + if getRateRemaining != nil { rateRemaining, resetAt, err := getRateRemaining(taskCtx.GetContext(), graphqlClient, logger) if err != nil { @@ -258,17 +262,13 @@ func WithFallbackRateLimit(limit int) GraphqlClientOption { } } -// resolveRateLimit determines the rate limit for GraphQL requests using task configuration -> else default constant. +// resolveRateLimit returns -1 if GRAPHQL_RATE_LIMIT is not set or invalid func resolveRateLimit(taskCtx plugin.TaskContext, logger log.Logger) int { - rateLimit := defaultRateLimitConst - - if v := taskCtx.GetConfig("GRAPHQL_RATE_LIMIT"); v != "" { - if parsed, err := strconv.Atoi(v); err == nil { - rateLimit = parsed - } else { - logger.Warn(err, "invalid GRAPHQL_RATE_LIMIT, using default") - } - } - - return rateLimit + if v := taskCtx.GetConfig("GRAPHQL_RATE_LIMIT"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + return parsed + } + logger.Warn(nil, "invalid GRAPHQL_RATE_LIMIT, using default") + } + return -1 } From afe168bf36c294112859c1fa78ca7aa309f1c8e2 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Thu, 2 Apr 2026 00:18:39 +0530 Subject: [PATCH 11/14] fix(github): StaticRoundTripper now owns token splitting and rotation for AccessToken connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, connection.Token (comma-separated PATs) was injected as-is into the Authorization header, sending "Bearer tok1,tok2,tok3" instead of a single rotated token. StaticRoundTripper now splits the raw token string on comma and rotates through tokens round-robin using an atomic counter. For REST: StaticRoundTripper operates at transport level and always overwrites the Authorization header set by SetupAuthentication. SetupAuthentication is retained because conn.tokens is still required by GetTokensCount() for rate limit calculation — but its header write is superseded by StaticRoundTripper on every request. For GraphQL: SetupAuthentication is never called by the graphql client, so StaticRoundTripper is the only auth mechanism on this path — without this fix, GraphQL requests were sent with the full unsplit token string. --- backend/plugins/github/token/round_tripper.go | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/backend/plugins/github/token/round_tripper.go b/backend/plugins/github/token/round_tripper.go index e5e51b35ed8..3fbc8d9065c 100644 --- a/backend/plugins/github/token/round_tripper.go +++ b/backend/plugins/github/token/round_tripper.go @@ -19,6 +19,8 @@ package token import ( "net/http" + "strings" + "sync/atomic" ) // RefreshRoundTripper is an HTTP transport middleware that automatically manages OAuth token refreshes. @@ -97,22 +99,32 @@ func (rt *RefreshRoundTripper) roundTripWithRetry(req *http.Request, refreshAtte // StaticRoundTripper is an HTTP transport that injects a fixed bearer token. // Unlike RefreshRoundTripper, it does NOT attempt refresh or retries. type StaticRoundTripper struct { - base http.RoundTripper - token string + base http.RoundTripper + tokens []string + idx atomic.Uint64 } -func NewStaticRoundTripper(base http.RoundTripper, token string) *StaticRoundTripper { +func NewStaticRoundTripper(base http.RoundTripper, rawToken string) *StaticRoundTripper { if base == nil { base = http.DefaultTransport } - return &StaticRoundTripper{ - base: base, - token: token, + parts := strings.Split(rawToken, ",") + tokens := make([]string, 0, len(parts)) + for _, t := range parts { + if t = strings.TrimSpace(t); t != "" { + tokens = append(tokens, t) + } + } + if len(tokens) == 0 { + tokens = []string{rawToken} } + return &StaticRoundTripper{base: base, tokens: tokens} } func (rt *StaticRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // always overrides headers put by SetupAuthentication, to make sure the token is always injected + tok := rt.tokens[rt.idx.Add(1)%uint64(len(rt.tokens))] reqClone := req.Clone(req.Context()) - reqClone.Header.Set("Authorization", "Bearer "+rt.token) + reqClone.Header.Set("Authorization", "Bearer "+tok) return rt.base.RoundTrip(reqClone) } From 9cb097013c63ac50b430d0ca02c06329d43423f5 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Wed, 8 Apr 2026 07:54:48 +0530 Subject: [PATCH 12/14] refactor(github-graphql): Downgrade fetch failure logs from Warn to Info --- backend/helpers/pluginhelper/api/graphql_async_client.go | 2 +- backend/plugins/github_graphql/impl/impl.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/helpers/pluginhelper/api/graphql_async_client.go b/backend/helpers/pluginhelper/api/graphql_async_client.go index f9cff5e3792..e91a30e3dca 100644 --- a/backend/helpers/pluginhelper/api/graphql_async_client.go +++ b/backend/helpers/pluginhelper/api/graphql_async_client.go @@ -90,7 +90,7 @@ func CreateAsyncGraphqlClient( if getRateRemaining != nil { rateRemaining, resetAt, err := getRateRemaining(taskCtx.GetContext(), graphqlClient, logger) if err != nil { - graphqlAsyncClient.logger.Warn(err, "failed to fetch initial graphql rate limit, fallback to default") + graphqlAsyncClient.logger.Info("failed to fetch initial graphql rate limit, fallback to default: %v", err) graphqlAsyncClient.updateRateRemaining(graphqlAsyncClient.rateRemaining, nil) } else { graphqlAsyncClient.updateRateRemaining(rateRemaining, resetAt) diff --git a/backend/plugins/github_graphql/impl/impl.go b/backend/plugins/github_graphql/impl/impl.go index fa1329d3d57..f56c77644bf 100644 --- a/backend/plugins/github_graphql/impl/impl.go +++ b/backend/plugins/github_graphql/impl/impl.go @@ -190,7 +190,6 @@ func (p GithubGraphql) PrepareTaskData(taskCtx plugin.TaskContext, options map[s return 0, nil, errors.Default.Wrap(dataErrors[0], `query rate limit fail`) } if query.RateLimit == nil { - logger.Info(`github graphql rate limit unavailable, using fallback rate limit`) return 0, nil, errors.Default.New("rate limit unavailable") } logger.Info(`github graphql init success with remaining %d/%d and will reset at %s`, From c7c5e72360e8817b805b8a26197e78586f398bf4 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Wed, 8 Apr 2026 10:00:38 +0530 Subject: [PATCH 13/14] fix(helper): use inline func type for GraphqlClientOption to avoid mock cycle Replace exported GraphqlClientOption type with inline func(*GraphqlAsyncClient) in CreateAsyncGraphqlClient signature. The named type caused mockery to generate a mock file (GraphqlClientOption.go) that created an import cycle in tests. --- .../pluginhelper/api/graphql_async_client.go | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/backend/helpers/pluginhelper/api/graphql_async_client.go b/backend/helpers/pluginhelper/api/graphql_async_client.go index e91a30e3dca..f79a248eefd 100644 --- a/backend/helpers/pluginhelper/api/graphql_async_client.go +++ b/backend/helpers/pluginhelper/api/graphql_async_client.go @@ -20,20 +20,18 @@ package api import ( "context" "fmt" + "strconv" + "sync" + "time" + "github.com/apache/incubator-devlake/core/errors" "github.com/apache/incubator-devlake/core/log" "github.com/apache/incubator-devlake/core/plugin" "github.com/apache/incubator-devlake/core/utils" - "strconv" - "sync" - "time" "github.com/merico-ai/graphql" ) -// GraphqlClientOption is a function that configures a GraphqlAsyncClient -type GraphqlClientOption func(*GraphqlAsyncClient) - // GraphqlAsyncClient send graphql one by one type GraphqlAsyncClient struct { ctx context.Context @@ -62,7 +60,7 @@ func CreateAsyncGraphqlClient( graphqlClient *graphql.Client, logger log.Logger, getRateRemaining func(context.Context, *graphql.Client, log.Logger) (rateRemaining int, resetAt *time.Time, err errors.Error), - opts ...GraphqlClientOption, + opts ...func(*GraphqlAsyncClient), ) (*GraphqlAsyncClient, errors.Error) { ctxWithCancel, cancel := context.WithCancel(taskCtx.GetContext()) @@ -83,7 +81,7 @@ func CreateAsyncGraphqlClient( // Env config wins over everything, only if explicitly set if rateLimit := resolveRateLimit(taskCtx, logger); rateLimit != -1 { - logger.Info("GRAPHQL_RATE_LIMIT env override applied: %d (was %d)", rateLimit, graphqlAsyncClient.rateRemaining) + logger.Info("GRAPHQL_RATE_LIMIT env override applied: %d (was %d)", rateLimit, graphqlAsyncClient.rateRemaining) graphqlAsyncClient.rateRemaining = rateLimit } @@ -156,7 +154,7 @@ func (apiClient *GraphqlAsyncClient) updateRateRemaining(rateRemaining int, rese case <-time.After(nextDuring): newRateRemaining, newResetAt, err := apiClient.getRateRemaining(apiClient.ctx, apiClient.client, apiClient.logger) if err != nil { - apiClient.logger.Warn(err, "failed to update graphql rate limit, will retry next cycle") + apiClient.logger.Info("failed to update graphql rate limit, will retry next cycle: %v", err) apiClient.updateRateRemaining(apiClient.rateRemaining, nil) return } @@ -254,7 +252,7 @@ func (apiClient *GraphqlAsyncClient) Release() { // WithFallbackRateLimit sets the initial/fallback rate limit used when // rate limit information cannot be fetched dynamically. // This value may be overridden later by getRateRemaining. -func WithFallbackRateLimit(limit int) GraphqlClientOption { +func WithFallbackRateLimit(limit int) func(*GraphqlAsyncClient) { return func(c *GraphqlAsyncClient) { if limit > 0 { c.rateRemaining = limit @@ -264,11 +262,11 @@ func WithFallbackRateLimit(limit int) GraphqlClientOption { // resolveRateLimit returns -1 if GRAPHQL_RATE_LIMIT is not set or invalid func resolveRateLimit(taskCtx plugin.TaskContext, logger log.Logger) int { - if v := taskCtx.GetConfig("GRAPHQL_RATE_LIMIT"); v != "" { - if parsed, err := strconv.Atoi(v); err == nil { - return parsed - } - logger.Warn(nil, "invalid GRAPHQL_RATE_LIMIT, using default") - } - return -1 + if v := taskCtx.GetConfig("GRAPHQL_RATE_LIMIT"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + return parsed + } + logger.Warn(nil, "invalid GRAPHQL_RATE_LIMIT, using default") + } + return -1 } From c8927e0ab9d00d2bb5dd788047945702745e39ab Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Wed, 8 Apr 2026 10:28:16 +0530 Subject: [PATCH 14/14] style(github): fix linting --- backend/plugins/github/token/round_tripper.go | 2 +- backend/plugins/github_graphql/tasks/graphql_client.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/plugins/github/token/round_tripper.go b/backend/plugins/github/token/round_tripper.go index 3fbc8d9065c..43db657ef49 100644 --- a/backend/plugins/github/token/round_tripper.go +++ b/backend/plugins/github/token/round_tripper.go @@ -20,7 +20,7 @@ package token import ( "net/http" "strings" - "sync/atomic" + "sync/atomic" ) // RefreshRoundTripper is an HTTP transport middleware that automatically manages OAuth token refreshes. diff --git a/backend/plugins/github_graphql/tasks/graphql_client.go b/backend/plugins/github_graphql/tasks/graphql_client.go index 7682df41779..9c248e15cb6 100644 --- a/backend/plugins/github_graphql/tasks/graphql_client.go +++ b/backend/plugins/github_graphql/tasks/graphql_client.go @@ -20,16 +20,17 @@ package tasks import ( "context" "fmt" - "github.com/apache/incubator-devlake/core/log" - "net/url" "net/http" + "net/url" "time" + "github.com/merico-ai/graphql" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/log" "github.com/apache/incubator-devlake/core/plugin" helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" "github.com/apache/incubator-devlake/plugins/github/models" - "github.com/merico-ai/graphql" ) func CreateGraphqlClient(