Repository: pycontribs/jira

Issue:
"AttributeError: 'list' object has no attribute 'values'" on Jira Error handling
Running jira library 1.0.10 on JIRA (v7.3.1#73012-sha1:68837e3) 
```
Traceback (most recent call last):
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/behave/model.py", line 1456, in run
    match.run(runner.context)
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/behave/model.py", line 1903, in run
    self.func(context, *args, **kwargs)
  File "features/steps/basic.py", line 39, in when_propagate_issues_to_target
    context.jira.create_issues(issue_list)
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/jira/client.py", line 936, in create_issues
    r = self._session.post(url, data=json.dumps(data))
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/jira/resilientsession.py", line 153, in post
    return self.__verb('POST', url, **kwargs)
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/jira/resilientsession.py", line 146, in __verb
    raise_on_error(response, verb=verb, **kwargs)
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/jira/resilientsession.py", line 49, in raise_on_error
    error_list = response['errors'].values()
AttributeError: 'list' object has no attribute 'values'
```
This happen on response:
```
{
    'errors': [
        {
            'status': 400, 'failedElementNumber': 0,
            'elementErrors': {
                'errorMessages': [],
                'errors': {
                    'customfield_10002': "Field 'customfield_10002' cannot be set. It is not on the appropriate screen, or unknown."
                }
            }
        }
    ],
    'issues': []
}
```
Offending code is https://github.com/pycontribs/jira/blob/ff7fe0b8cd901e6d84abafbc10a5eab405568649/jira/resilientsession.py#L47-L50 :
```
                elif 'errors' in response and len(response['errors']) > 0:
                    # JIRA 6.x error messages are found in this array.
                    error_list = response['errors'].values()
                    error = ", ".join(error_list)
```
"AttributeError: 'list' object has no attribute 'values'" on Jira Error handling
Running jira library 1.0.10 on JIRA (v7.3.1#73012-sha1:68837e3) 
```
Traceback (most recent call last):
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/behave/model.py", line 1456, in run
    match.run(runner.context)
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/behave/model.py", line 1903, in run
    self.func(context, *args, **kwargs)
  File "features/steps/basic.py", line 39, in when_propagate_issues_to_target
    context.jira.create_issues(issue_list)
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/jira/client.py", line 936, in create_issues
    r = self._session.post(url, data=json.dumps(data))
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/jira/resilientsession.py", line 153, in post
    return self.__verb('POST', url, **kwargs)
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/jira/resilientsession.py", line 146, in __verb
    raise_on_error(response, verb=verb, **kwargs)
  File "/Users/emmanuelsciara/virtual_envs/propagate_issues/lib/python3.5/site-packages/jira/resilientsession.py", line 49, in raise_on_error
    error_list = response['errors'].values()
AttributeError: 'list' object has no attribute 'values'
```
This happen on response:
```
{
    'errors': [
        {
            'status': 400, 'failedElementNumber': 0,
            'elementErrors': {
                'errorMessages': [],
                'errors': {
                    'customfield_10002': "Field 'customfield_10002' cannot be set. It is not on the appropriate screen, or unknown."
                }
            }
        }
    ],
    'issues': []
}
```
Offending code is https://github.com/pycontribs/jira/blob/ff7fe0b8cd901e6d84abafbc10a5eab405568649/jira/resilientsession.py#L47-L50 :
```
                elif 'errors' in response and len(response['errors']) > 0:
                    # JIRA 6.x error messages are found in this array.
                    error_list = response['errors'].values()
                    error = ", ".join(error_list)
```


Discussion:
Here is what is said on the [JIRA REST API Reference since version 7.1.0] (https://docs.atlassian.com/jira/REST/7.1.0/): (same on the [Cloud REST API I actually tested this on (1000.789.1)](https://docs.atlassian.com/jira/REST/1000.789.1/)) 

> # Error responses
> Most resources will return a response body in addition to the status code. Usually, the JSON schema of the entity returned is the following:
``` json
{
    "id": "https://docs.atlassian.com/jira/REST/schema/error-collection#",
    "title": "Error Collection",
    "type": "object",
    "properties": {
        "errorMessages": {
            "type": "array",
            "items": {
                "type": "string"
            }
        },
        "errors": {
            "type": "object",
            "patternProperties": {
                ".+": {
                    "type": "string"
                }
            },
            "additionalProperties": false
        },
        "status": {
            "type": "integer"
        }
    },
    "additionalProperties": false
}
```
Looks like the following code was written to handle cases with `'elementErrors'` ~~~but does not properly~~~ in fact my understanding is that does only check the error messages for the issues that have not been successfully created during a bulk creation (https://github.com/pycontribs/jira/blob/ff7fe0b8cd901e6d84abafbc10a5eab405568649/jira/client.py#L941-L942)
```python
        for error in raw_issue_json['errors']:
            errors[error['failedElementNumber']] = error['elementErrors']['errors']
```
Could reproduce the problem by running the tests on my local instance of Jira
```
tests.py::IssueTests::test_create_issues FAILED

self = <tests.IssueTests testMethod=test_create_issues>

    @pytest.mark.wip
    def test_create_issues(self):
        field_list = [{
            'project': {
                'key': self.project_b},
            'summary': 'Issue created via bulk create #1',
            'description': "Some new issue for test",
            'issuetype': {
                'name': 'Bug'},
            # 'customfield_10022': 'XSS',
            'priority': {
                'name': 'Major'}},
            {
            'project': {
                'key': self.project_a},
            'issuetype': {
                'name': 'Bug'},
            'summary': 'Issue created via bulk create #2',
            'description': "Another new issue for bulk test",
            'priority': {
                'name': 'Major'}}]
>       issues = self.jira.create_issues(field_list=field_list)

tests.py:805: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <jira.client.JIRA object at 0x10e50fa90>
field_list = [{'description': 'Some new issue for test', 'issuetype': {'name': 'Bug'}, 'priority': {'name': 'Major'}, 'project': {'...ue for bulk test', 'issuetype': {'name': 'Bug'}, 'priority': {'name': 'Major'}, 'project': {'key': 'ZD8306C88A'}, ...}]
prefetch = True

    def create_issues(self, field_list, prefetch=True):
        """Bulk create new issues and return an issue Resource for each successfully created issue.
    
            See `create_issue` documentation for field information.
    
            :param field_list: a list of dicts each containing field names and the values to use. Each dict
                is an individual issue to create and is subject to its minimum requirements.
            :param prefetch: whether to reload the created issue Resource for each created issue so that all
                of its data is present in the value returned from this method.
            """
        data = {'issueUpdates': []}
        for field_dict in field_list:
            issue_data = _field_worker(field_dict)
            p = issue_data['fields']['project']
    
            if isinstance(p, string_types) or isinstance(p, integer_types):
                issue_data['fields']['project'] = {'id': self.project(p).id}
    
            p = issue_data['fields']['issuetype']
            if isinstance(p, integer_types):
                issue_data['fields']['issuetype'] = {'id': p}
            if isinstance(p, string_types) or isinstance(p, integer_types):
                issue_data['fields']['issuetype'] = {'id': self.issue_type_by_name(p).id}
    
            data['issueUpdates'].append(issue_data)
    
        url = self._get_url('issue/bulk')
>       r = self._session.post(url, data=json.dumps(data))

../jira/client.py:961: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <jira.resilientsession.ResilientSession object at 0x10dd7ca58>
url = 'http://192.168.33.10:8080/rest/api/2/issue/bulk'
kwargs = {'data': '{"issueUpdates": [{"fields": {"description": "Some new issue for test", "priority": {"name": "Major"}, "summ..."}, "summary": "Issue created via bulk create #2", "project": {"key": "ZD8306C88A"}, "issuetype": {"name": "Bug"}}}]}'}

    def post(self, url, **kwargs):
>       return self.__verb('POST', url, **kwargs)

../jira/resilientsession.py:153: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <jira.resilientsession.ResilientSession object at 0x10dd7ca58>
verb = 'POST', url = 'http://192.168.33.10:8080/rest/api/2/issue/bulk'
retry_data = None
kwargs = {'data': '{"issueUpdates": [{"fields": {"description": "Some new issue for test", "priority": {"name": "Major"}, "summ...p-alive', 'Accept': 'application/json,*.*;q=0.9', 'Content-Type': 'application/json', 'X-Atlassian-Token': 'no-check'}}
d = {'User-Agent': 'python-requests/2.13.0', 'Accept-Encoding': 'gzip, deflate', 'Cache-Control': 'no-cache', 'Connection'...ep-alive', 'Accept': 'application/json,*.*;q=0.9', 'Content-Type': 'application/json', 'X-Atlassian-Token': 'no-check'}
data = '{"issueUpdates": [{"fields": {"description": "Some new issue for test", "priority": {"name": "Major"}, "summary": "Is...r"}, "summary": "Issue created via bulk create #2", "project": {"key": "ZD8306C88A"}, "issuetype": {"name": "Bug"}}}]}'
retry_number = 1, response = <Response [400]>, exception = None
method = <bound method Session.post of <jira.resilientsession.ResilientSession object at 0x10dd7ca58>>

    def __verb(self, verb, url, retry_data=None, **kwargs):
    
        d = self.headers.copy()
        d.update(kwargs.get('headers', {}))
        kwargs['headers'] = d
    
        # if we pass a dictionary as the 'data' we assume we want to send json
        # data
        data = kwargs.get('data', {})
        if isinstance(data, dict):
            data = json.dumps(data)
    
        retry_number = 0
        while retry_number <= self.max_retries:
            response = None
            exception = None
            try:
                method = getattr(super(ResilientSession, self), verb.lower())
                response = method(url, timeout=self.timeout, **kwargs)
                if response.status_code == 200:
                    return response
            except ConnectionError as e:
                logging.warning(
                    "%s while doing %s %s [%s]" % (e, verb.upper(), url, kwargs))
                exception = e
            retry_number += 1
    
            if retry_number <= self.max_retries:
                response_or_exception = response if response is not None else exception
                if self.__recoverable(response_or_exception, url, verb.upper(), retry_number):
                    if retry_data:
                        # if data is a stream, we cannot just read again from it,
                        # retry_data() will give us a new stream with the data
                        kwargs['data'] = retry_data()
                    continue
                else:
                    break
    
        if exception is not None:
            raise exception
>       raise_on_error(response, verb=verb, **kwargs)

../jira/resilientsession.py:146: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

r = <Response [400]>, verb = 'POST'
kwargs = {'data': '{"issueUpdates": [{"fields": {"description": "Some new issue for test", "priority": {"name": "Major"}, "summ...p-alive', 'Accept': 'application/json,*.*;q=0.9', 'Content-Type': 'application/json', 'X-Atlassian-Token': 'no-check'}}
request = None, error = ''
response = {'errors': [{'elementErrors': {'errorMessages': [], 'errors': {'priority': "Priority name 'Major' is not valid"}}, 'fa...'errors': {'priority': "Priority name 'Major' is not valid"}}, 'failedElementNumber': 1, 'status': 400}], 'issues': []}

    def raise_on_error(r, verb='???', **kwargs):
        request = kwargs.get('request', None)
        # headers = kwargs.get('headers', None)
    
        if r is None:
            raise JIRAError(None, **kwargs)
    
        if r.status_code >= 400:
            error = ''
            if r.status_code == 403 and "x-authentication-denied-reason" in r.headers:
                error = r.headers["x-authentication-denied-reason"]
            elif r.text:
                try:
                    response = json.loads(r.text)
                    if 'message' in response:
                        # JIRA 5.1 errors
                        error = response['message']
                    elif 'errorMessages' in response and len(response['errorMessages']) > 0:
                        # JIRA 5.0.x error messages sometimes come wrapped in this array
                        # Sometimes this is present but empty
                        errorMessages = response['errorMessages']
                        if isinstance(errorMessages, (list, tuple)):
                            error = errorMessages[0]
                        else:
                            error = errorMessages
                    elif 'errors' in response and len(response['errors']) > 0:
                        # JIRA 6.x error messages are found in this array.
>                       error_list = response['errors'].values()
E                       AttributeError: 'list' object has no attribute 'values'

../jira/resilientsession.py:49: AttributeError
```
It fails on receiving the following error message from Jira:
```json
{
  'errors': [
    {
      'status': 400,
      'elementErrors': {
        'errors': {
          'priority': "Priority name 'Major' is not valid"
        },
        'errorMessages': []
      },
      'failedElementNumber': 0
    },
    {
      'status': 400,
      'elementErrors': {
        'errors': {
          'priority': "Priority name 'Major' is not valid"
        },
        'errorMessages': []
      },
      'failedElementNumber': 1
    }
  ],
  'issues': []
}
```
Found the problem: code works well when at least one of the issues passed to `create_issues` is created, in which case the http response status code is `201`. However, if none of the issues are created, then the http response status code is `400` and is caught in the `raise_on_error` method, which then crashes.

Preparing and submitting a fix.
Here is what is said on the [JIRA REST API Reference since version 7.1.0] (https://docs.atlassian.com/jira/REST/7.1.0/): (same on the [Cloud REST API I actually tested this on (1000.789.1)](https://docs.atlassian.com/jira/REST/1000.789.1/)) 

> # Error responses
> Most resources will return a response body in addition to the status code. Usually, the JSON schema of the entity returned is the following:
``` json
{
    "id": "https://docs.atlassian.com/jira/REST/schema/error-collection#",
    "title": "Error Collection",
    "type": "object",
    "properties": {
        "errorMessages": {
            "type": "array",
            "items": {
                "type": "string"
            }
        },
        "errors": {
            "type": "object",
            "patternProperties": {
                ".+": {
                    "type": "string"
                }
            },
            "additionalProperties": false
        },
        "status": {
            "type": "integer"
        }
    },
    "additionalProperties": false
}
```
Looks like the following code was written to handle cases with `'elementErrors'` ~~~but does not properly~~~ in fact my understanding is that does only check the error messages for the issues that have not been successfully created during a bulk creation (https://github.com/pycontribs/jira/blob/ff7fe0b8cd901e6d84abafbc10a5eab405568649/jira/client.py#L941-L942)
```python
        for error in raw_issue_json['errors']:
            errors[error['failedElementNumber']] = error['elementErrors']['errors']
```
Could reproduce the problem by running the tests on my local instance of Jira
```
tests.py::IssueTests::test_create_issues FAILED

self = <tests.IssueTests testMethod=test_create_issues>

    @pytest.mark.wip
    def test_create_issues(self):
        field_list = [{
            'project': {
                'key': self.project_b},
            'summary': 'Issue created via bulk create #1',
            'description': "Some new issue for test",
            'issuetype': {
                'name': 'Bug'},
            # 'customfield_10022': 'XSS',
            'priority': {
                'name': 'Major'}},
            {
            'project': {
                'key': self.project_a},
            'issuetype': {
                'name': 'Bug'},
            'summary': 'Issue created via bulk create #2',
            'description': "Another new issue for bulk test",
            'priority': {
                'name': 'Major'}}]
>       issues = self.jira.create_issues(field_list=field_list)

tests.py:805: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <jira.client.JIRA object at 0x10e50fa90>
field_list = [{'description': 'Some new issue for test', 'issuetype': {'name': 'Bug'}, 'priority': {'name': 'Major'}, 'project': {'...ue for bulk test', 'issuetype': {'name': 'Bug'}, 'priority': {'name': 'Major'}, 'project': {'key': 'ZD8306C88A'}, ...}]
prefetch = True

    def create_issues(self, field_list, prefetch=True):
        """Bulk create new issues and return an issue Resource for each successfully created issue.
    
            See `create_issue` documentation for field information.
    
            :param field_list: a list of dicts each containing field names and the values to use. Each dict
                is an individual issue to create and is subject to its minimum requirements.
            :param prefetch: whether to reload the created issue Resource for each created issue so that all
                of its data is present in the value returned from this method.
            """
        data = {'issueUpdates': []}
        for field_dict in field_list:
            issue_data = _field_worker(field_dict)
            p = issue_data['fields']['project']
    
            if isinstance(p, string_types) or isinstance(p, integer_types):
                issue_data['fields']['project'] = {'id': self.project(p).id}
    
            p = issue_data['fields']['issuetype']
            if isinstance(p, integer_types):
                issue_data['fields']['issuetype'] = {'id': p}
            if isinstance(p, string_types) or isinstance(p, integer_types):
                issue_data['fields']['issuetype'] = {'id': self.issue_type_by_name(p).id}
    
            data['issueUpdates'].append(issue_data)
    
        url = self._get_url('issue/bulk')
>       r = self._session.post(url, data=json.dumps(data))

../jira/client.py:961: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <jira.resilientsession.ResilientSession object at 0x10dd7ca58>
url = 'http://192.168.33.10:8080/rest/api/2/issue/bulk'
kwargs = {'data': '{"issueUpdates": [{"fields": {"description": "Some new issue for test", "priority": {"name": "Major"}, "summ..."}, "summary": "Issue created via bulk create #2", "project": {"key": "ZD8306C88A"}, "issuetype": {"name": "Bug"}}}]}'}

    def post(self, url, **kwargs):
>       return self.__verb('POST', url, **kwargs)

../jira/resilientsession.py:153: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <jira.resilientsession.ResilientSession object at 0x10dd7ca58>
verb = 'POST', url = 'http://192.168.33.10:8080/rest/api/2/issue/bulk'
retry_data = None
kwargs = {'data': '{"issueUpdates": [{"fields": {"description": "Some new issue for test", "priority": {"name": "Major"}, "summ...p-alive', 'Accept': 'application/json,*.*;q=0.9', 'Content-Type': 'application/json', 'X-Atlassian-Token': 'no-check'}}
d = {'User-Agent': 'python-requests/2.13.0', 'Accept-Encoding': 'gzip, deflate', 'Cache-Control': 'no-cache', 'Connection'...ep-alive', 'Accept': 'application/json,*.*;q=0.9', 'Content-Type': 'application/json', 'X-Atlassian-Token': 'no-check'}
data = '{"issueUpdates": [{"fields": {"description": "Some new issue for test", "priority": {"name": "Major"}, "summary": "Is...r"}, "summary": "Issue created via bulk create #2", "project": {"key": "ZD8306C88A"}, "issuetype": {"name": "Bug"}}}]}'
retry_number = 1, response = <Response [400]>, exception = None
method = <bound method Session.post of <jira.resilientsession.ResilientSession object at 0x10dd7ca58>>

    def __verb(self, verb, url, retry_data=None, **kwargs):
    
        d = self.headers.copy()
        d.update(kwargs.get('headers', {}))
        kwargs['headers'] = d
    
        # if we pass a dictionary as the 'data' we assume we want to send json
        # data
        data = kwargs.get('data', {})
        if isinstance(data, dict):
            data = json.dumps(data)
    
        retry_number = 0
        while retry_number <= self.max_retries:
            response = None
            exception = None
            try:
                method = getattr(super(ResilientSession, self), verb.lower())
                response = method(url, timeout=self.timeout, **kwargs)
                if response.status_code == 200:
                    return response
            except ConnectionError as e:
                logging.warning(
                    "%s while doing %s %s [%s]" % (e, verb.upper(), url, kwargs))
                exception = e
            retry_number += 1
    
            if retry_number <= self.max_retries:
                response_or_exception = response if response is not None else exception
                if self.__recoverable(response_or_exception, url, verb.upper(), retry_number):
                    if retry_data:
                        # if data is a stream, we cannot just read again from it,
                        # retry_data() will give us a new stream with the data
                        kwargs['data'] = retry_data()
                    continue
                else:
                    break
    
        if exception is not None:
            raise exception
>       raise_on_error(response, verb=verb, **kwargs)

../jira/resilientsession.py:146: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

r = <Response [400]>, verb = 'POST'
kwargs = {'data': '{"issueUpdates": [{"fields": {"description": "Some new issue for test", "priority": {"name": "Major"}, "summ...p-alive', 'Accept': 'application/json,*.*;q=0.9', 'Content-Type': 'application/json', 'X-Atlassian-Token': 'no-check'}}
request = None, error = ''
response = {'errors': [{'elementErrors': {'errorMessages': [], 'errors': {'priority': "Priority name 'Major' is not valid"}}, 'fa...'errors': {'priority': "Priority name 'Major' is not valid"}}, 'failedElementNumber': 1, 'status': 400}], 'issues': []}

    def raise_on_error(r, verb='???', **kwargs):
        request = kwargs.get('request', None)
        # headers = kwargs.get('headers', None)
    
        if r is None:
            raise JIRAError(None, **kwargs)
    
        if r.status_code >= 400:
            error = ''
            if r.status_code == 403 and "x-authentication-denied-reason" in r.headers:
                error = r.headers["x-authentication-denied-reason"]
            elif r.text:
                try:
                    response = json.loads(r.text)
                    if 'message' in response:
                        # JIRA 5.1 errors
                        error = response['message']
                    elif 'errorMessages' in response and len(response['errorMessages']) > 0:
                        # JIRA 5.0.x error messages sometimes come wrapped in this array
                        # Sometimes this is present but empty
                        errorMessages = response['errorMessages']
                        if isinstance(errorMessages, (list, tuple)):
                            error = errorMessages[0]
                        else:
                            error = errorMessages
                    elif 'errors' in response and len(response['errors']) > 0:
                        # JIRA 6.x error messages are found in this array.
>                       error_list = response['errors'].values()
E                       AttributeError: 'list' object has no attribute 'values'

../jira/resilientsession.py:49: AttributeError
```
It fails on receiving the following error message from Jira:
```json
{
  'errors': [
    {
      'status': 400,
      'elementErrors': {
        'errors': {
          'priority': "Priority name 'Major' is not valid"
        },
        'errorMessages': []
      },
      'failedElementNumber': 0
    },
    {
      'status': 400,
      'elementErrors': {
        'errors': {
          'priority': "Priority name 'Major' is not valid"
        },
        'errorMessages': []
      },
      'failedElementNumber': 1
    }
  ],
  'issues': []
}
```
Found the problem: code works well when at least one of the issues passed to `create_issues` is created, in which case the http response status code is `201`. However, if none of the issues are created, then the http response status code is `400` and is caught in the `raise_on_error` method, which then crashes.

Preparing and submitting a fix.

Resolving patch:
diff --git a/jira/client.py b/jira/client.py
--- a/jira/client.py
+++ b/jira/client.py
@@ -1093,9 +1093,15 @@ def create_issues(self, field_list, prefetch=True):
             data['issueUpdates'].append(issue_data)
 
         url = self._get_url('issue/bulk')
-        r = self._session.post(url, data=json.dumps(data))
-
-        raw_issue_json = json_loads(r)
+        try:
+            r = self._session.post(url, data=json.dumps(data))
+            raw_issue_json = json_loads(r)
+        # Catching case where none of the issues has been created. See https://github.com/pycontribs/jira/issues/350
+        except JIRAError as je:
+            if je.status_code == 400:
+                raw_issue_json = json.loads(je.response.text)
+            else:
+                raise
         issue_list = []
         errors = {}
         for error in raw_issue_json['errors']:
diff --git a/jira/resilientsession.py b/jira/resilientsession.py
--- a/jira/resilientsession.py
+++ b/jira/resilientsession.py
@@ -44,7 +44,8 @@ def raise_on_error(r, verb='???', **kwargs):
                         error = errorMessages[0]
                     else:
                         error = errorMessages
-                elif 'errors' in response and len(response['errors']) > 0:
+                # Catching only 'errors' that are dict. See https://github.com/pycontribs/jira/issues/350
+                elif 'errors' in response and len(response['errors']) > 0 and isinstance(response['errors'], dict):
                     # JIRA 6.x error messages are found in this array.
                     error_list = response['errors'].values()
                     error = ", ".join(error_list)


Test patch:
diff --git a/tests/tests.py b/tests/tests.py
--- a/tests/tests.py
+++ b/tests/tests.py
@@ -769,6 +769,8 @@ def test_create_issues(self):
             'priority': {
                 'name': 'Major'}}]
         issues = self.jira.create_issues(field_list=field_list)
+        self.assertEqual(len(issues), 2)
+        self.assertIsNotNone(issues[0]['issue'], "the first issue has not been created")
         self.assertEqual(issues[0]['issue'].fields.summary,
                          'Issue created via bulk create #1')
         self.assertEqual(issues[0]['issue'].fields.description,
@@ -776,6 +778,7 @@ def test_create_issues(self):
         self.assertEqual(issues[0]['issue'].fields.issuetype.name, 'Bug')
         self.assertEqual(issues[0]['issue'].fields.project.key, self.project_b)
         self.assertEqual(issues[0]['issue'].fields.priority.name, 'Major')
+        self.assertIsNotNone(issues[1]['issue'], "the second issue has not been created")
         self.assertEqual(issues[1]['issue'].fields.summary,
                          'Issue created via bulk create #2')
         self.assertEqual(issues[1]['issue'].fields.description,